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>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-03 14:48:00 +00:00
1 parent b82d4c688a
commit ab0c1434c4
9 files changed
+383 -164

No files matched your search

+33 -17
View File
@@ -7,10 +7,10 @@
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
import { $, h, clear, append } from "./dom.js"
import { api, get, post, patch, follow, why, invoke } from "./bridge.js"
import { api, get, getText, post, patch, follow, why, invoke } from "./bridge.js"
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal, readServer } from "./explorer.js"
import { readLocal } from "./explorer.js"
const MODES = ["manual", "edit", "auto", "plan"]
@@ -101,8 +101,26 @@ function step(s, chat) {
}
}
function shownPath(call) {
return call.show?.path || call.args?.path || call.path || ""
/**
* Where the text of a file the agent showed comes from, or null when there is none to fetch:
* - an agent chat on this computer: the file itself, read here (the card's path, or the call's
* own `args.path` for a step the card does not describe);
* - otherwise the server, through the card (`show`): `?node=&version=` for a file in the tree,
* `?shown=` for the copy a device sent. The server checks the chat's access again then.
*/
function shownSource(call, chat) {
if (onThisComputer(chat) && chat.device.directory) {
const path = call.show?.path || call.args?.path || ""
return path ? { local: true, path } : null
}
const s = call.show
if (!s || !chat?.id) return null
const q = s.shown_id
? `shown=${encodeURIComponent(s.shown_id)}`
: s.node_id && s.version_id
? `node=${encodeURIComponent(s.node_id)}&version=${encodeURIComponent(s.version_id)}`
: ""
return q ? { local: false, path: s.path || "", url: `/chats/${encodeURIComponent(chat.id)}/show?${q}` } : null
}
function toolCall(c, chat) {
@@ -115,7 +133,7 @@ function toolCall(c, chat) {
if (c.text) body.append(h("pre", { class: "call__pre", text: c.text }))
if (c.error) body.append(h("p", { class: "msg__note is-error", text: typeof c.error === "string" ? c.error : JSON.stringify(c.error) }))
if (c.truncated) body.append(h("p", { class: "muted", text: t("Cut short here; the whole of it is in the web interface.") }))
const isShow = c.name === "show" && !failed && shownPath(c)
const isShow = c.name === "show" && !failed && shownSource(c, chat)
const summary = h(
"summary",
{ class: "call__head" },
@@ -132,25 +150,23 @@ function toolCall(c, chat) {
/** A file the agent showed: from this computer when the chat runs here, else from the server. */
async function openShown(call, chat) {
const path = shownPath(call)
const kind = call.show?.kind
const src = shownSource(call, chat)
if (!src) return
const path = src.path
const { language } = showKind(path)
const line = call.show?.line || call.args?.line || 0
const base = { name: path.split("/").pop(), path, kind, language: call.show?.language || language, line }
const line = call.show?.line || (src.local ? call.args?.line : 0) || 0
const base = { name: call.show?.name || path.split("/").pop(), path, kind: call.show?.kind, language: call.show?.language || language, line }
try {
if (onThisComputer(chat) && chat.device.directory) {
if (src.local) {
const dir = chat.device.directory.replace(/\/+$/, "")
const rel = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path
const f = await readLocal(dir, rel)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("This computer")}`, text: f.text, withheld: f.withheld, size: f.size })
} else if (call.show?.shown_id) {
// The copy a device sent (server: `GET /api/chats/{chat}/shown/{id}` is the web UI's; the
// app's door does not have it yet -- see SERVER_FILES in explorer.js).
const f = await get(`/chats/${encodeURIComponent(chat.id)}/shown/${encodeURIComponent(call.show.shown_id)}`)
openViewer({ ...base, where: t("Shown by the agent"), text: f.text })
} else {
const f = await readServer(path)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text: f.text, withheld: f.withheld, size: f.size })
// Plain text, at most 1 MB; the server's 404 (no longer the chat's), 413 (too large) and
// 415 (not text) come back as its own words. HTML is its source: the viewer seals it.
const text = await getText(src.url)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text })
}
} catch (err) {
openViewer({ ...base, where: t("Shown by the agent"), error: why(err) })