Files
HomerandClaude Opus 5.5 ab0c1434c4 One approval for app and agent; /api/v2 files and show; app_api 2
- The app's device login asks for `app models` and, where discovery says
  login.device.agent_scope, `agent_scope=models memory link` in the same
  approval. The agent's token goes to the bundled CLI on stdin
  (`lembas login --token-stdin --server <url> --json`) and is never kept
  here; if the CLI does not take it, it is given up on the server. A
  computer linked already keeps its link; a server without agent_scope
  gets the old two steps.
- The server pane reads GET /api/v2/files and /files/content (mounts open
  like folders; `withheld` shown in the server's words). The "not offered
  yet" state is gone.
- A show card's file comes from GET /api/v2/chats/{chat}/show?node=&version=
  or ?shown= as plain text (new `api_text` command, 1 MB cap); args.path is
  read only for agent chats on this computer, locally.
- The gate is link protocol 3 plus app_api 2 (APP_API = 2), not the
  server's version number, which says 1.7.2 on dev until its first beta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:48:00 +00:00

73 lines
2.5 KiB
JavaScript

// The page's one way out: Tauri's commands (src-tauri/src/lib.rs). The page holds no token and
// makes no request of its own -- the content security policy allows it none but the bridge. The
// server is reached through `api` and `follow`, which Rust sends with the app's sign-in.
const tauri = window.__TAURI__
export const invoke = tauri.core.invoke
export const listen = tauri.event.listen
export const win = tauri.window.getCurrentWindow()
/** A request to the server's /api/v2: `path` from there on. Rejects with {status, detail}. */
export function api(method, path, body) {
return invoke("api", { method, path, body: body === undefined ? null : body })
}
export const get = (path) => api("GET", path)
export const post = (path, body) => api("POST", path, body ?? {})
export const patch = (path, body) => api("PATCH", path, body)
export const del = (path) => api("DELETE", path)
/** A `GET` on /api/v2 that answers plain text (a shown file). Rejects with {status, detail}. */
export const getText = (path) => invoke("api_text", { path })
/** An error's words, whatever shape it came in. */
export function why(err) {
if (err && typeof err === "object" && "detail" in err) return String(err.detail)
return String(err)
}
// Frames of every followed reply arrive as one event; they are handed on by stream id. A frame
// can arrive before `stream_open` has answered with its id, so frames for an unknown id wait.
const followers = new Map()
const early = new Map()
listen("stream", (e) => {
const { id, event, data } = e.payload
const f = followers.get(id)
if (f) deliver(f, id, event, data)
else {
if (!early.has(id)) early.set(id, [])
early.get(id).push([event, data])
}
})
function deliver(f, id, event, data) {
if (event === "end") {
followers.delete(id)
f.onEnd(data)
} else f.onFrame(event, data)
}
/**
* Follow a reply: `onFrame(event, data)` for each frame, `onEnd(error|null)` once. The handle's
* `close()` stops it (onEnd still comes).
*/
export async function follow(path, { onFrame, onEnd }) {
const id = await invoke("stream_open", { path })
const f = { onFrame, onEnd }
followers.set(id, f)
for (const [event, data] of early.get(id) || []) {
if (!followers.has(id)) break
deliver(f, id, event, data)
}
early.delete(id)
return {
id,
close() {
invoke("stream_close", { id })
},
}
}
/** A web link, in the system's browser. */
export function openLink(url) {
return invoke("open_external", { url }).catch(() => {})
}