CLI loads harness spec 3.0.0-draft; the show tool over the link

The spec's version may be a pre-release (3.0.0-draft). Every tool file is
loaded, with the new fields `sessions`, `requires`, `unavailable` and
`confirm`; the CLI's tools are those not the server's alone and offered in an
agent session (forCli) — a chat's own tools over the server's tree (mkdir, mv,
rm, search, python, the forge verbs) are not built in. blocks/context.md is
known; blocks/workspace.md is the server's alone.

show is built in with `requires: display`: offered only in a session a client
opened over the link when that client said `show: true` (capability `show`);
the device reads the file (at most 1 MB) and sends it with `_lembas/show`.
The harness tests now say 3.0.0's split of tools instead of "the execution
tools are the CLI's alone".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-27 20:00:02 +00:00
1 parent 9f536d8345
commit bc78aec424
10 files changed
+200 -24

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
// show (harness spec 3.0.0): a file opened in the user's viewer beside the conversation. A terminal
// has no such viewer, so the tool is offered only in a session a client opened over the link and
// only when that client said it can show files (`show: true`, harness/acp.md `_lembas/show`): the
// device reads the file and sends it, since the client cannot reach this disk.
import { statSync } from "node:fs"
import { basename, extname, relative } from "node:path"
import { z } from "zod"
import { described, TOOL_SPECS } from "../harness.ts"
import { absPath, defineTool, readText, showPath, ToolError } from "./tool.ts"
/** The most a file may be to be sent (acp.md: at most 1 MB; a larger one is refused before sending). */
export const SHOW_MAX_BYTES = 1024 * 1024
/** The answer when nothing can be shown (spec `unavailable`), word for word. */
export const SHOW_UNAVAILABLE = TOOL_SPECS.show!.unavailable!
/** What `_lembas/show` carries for one file. */
export interface ShowRequest {
path: string
kind: "markdown" | "code" | "text" | "html"
language?: string
line?: number
text: string
}
export type ShowReply = { shown: true } | { shown: false; reason: string }
// A known source extension is code, with its language; the list is the viewer's highlighters.
const LANGUAGES: Record<string, string> = {
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
}
/** The viewer's kind for a file, by its name (acp.md): `.md` markdown, `.html`/`.htm` html, a known
* source extension code, anything else text. */
export function showKind(path: string): { kind: ShowRequest["kind"]; language?: string } {
const ext = extname(path).slice(1).toLowerCase()
if (ext === "md" || ext === "markdown") return { kind: "markdown" }
if (ext === "html" || ext === "htm") return { kind: "html" }
const language = LANGUAGES[ext] ?? (basename(path) === "Dockerfile" ? "dockerfile" : basename(path) === "Makefile" ? "makefile" : undefined)
return language ? { kind: "code", language } : { kind: "text" }
}
export const showTool = defineTool({
...described("show"),
access: "show",
requires: "display",
schema: z.object({ path: z.string(), line: z.number().int().min(1).optional() }),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "show", class: "interact", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async run(a, ctx) {
// Offered with a display; one that went away since answers the spec's sentence.
if (!ctx.show) return { output: SHOW_UNAVAILABLE, isError: true }
const abs = absPath(a.path, ctx)
let size: number
try {
const st = statSync(abs)
if (!st.isFile()) throw new ToolError(`${showPath(abs, ctx)} is not a file.`)
size = st.size
} catch (e) {
if (e instanceof ToolError) throw e
throw new ToolError(`${showPath(abs, ctx)} does not exist. Write it first.`)
}
if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`)
const text = await readText(abs)
if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`)
const rel = relative(ctx.cwd, abs) || basename(abs)
const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId)
if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true }
return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) }
},
})