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

+21
View File
@@ -274,6 +274,9 @@ export const CAPABILITIES = [
// `_lembas/jobs` and `_lembas/job/*` methods, `jobs` in `status`, `job` on an approval, and a
// note's turn marked `machine`.
"jobs",
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
// said it can show files; the device sends the file with `_lembas/show`.
"show",
] as const
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
@@ -427,6 +430,9 @@ export class AcpAgent {
private get cards() {
return this.lembas && this.clientProtocol >= 2
}
/** The client shows files (`show: true` in `clientCapabilities._meta.lembas`, spec 3.0.0): the
* show tool is offered in the sessions it opens, and nowhere else. */
private shows = false
/** initialize has been answered: notifications about sessions may go out from now on. */
private ready = false
@@ -486,6 +492,8 @@ export class AcpAgent {
const said = p?.clientCapabilities?._meta?.lembas
this.lembas = Boolean(said)
this.clientProtocol = typeof said?.protocol === "number" ? said.protocol : 1
this.shows = said?.show === true
for (const s of this.sessions.values()) if (s.agent === this) this.equipShow(s)
// The sessions this link took over (the hub's) get their command set once the client
// knows the agent — after this answer, not before it.
setTimeout(() => {
@@ -686,6 +694,7 @@ export class AcpAgent {
private adopt(s: Session) {
s.off?.()
s.agent = this
this.equipShow(s)
s.off = s.app.bus.on((e) => {
if (s.agent !== this) return
this.forward(s, e)
@@ -694,6 +703,18 @@ export class AcpAgent {
})
}
/** The show tool's display, for a session this link holds: the client's viewer, reached with
* `_lembas/show`, where the client said it shows files; otherwise none, and show is not offered. */
private equipShow(s: Session) {
const ctx = s.app.engine.o.toolCtx
if (!this.shows) return void delete ctx.show
ctx.show = async (req, callId) => {
if (s.agent !== this || this.peer.isClosed) return { shown: false, reason: "the client is not connected" }
const r = (await this.peer.request("_lembas/show", { sessionId: s.id, toolCallId: callId ?? "", ...req }, 30_000)) as { shown?: unknown; reason?: unknown } | undefined
return r?.shown === true ? { shown: true } : { shown: false, reason: typeof r?.reason === "string" && r.reason ? r.reason : "the client did not show it" }
}
}
/** `available_commands_update`: the session's command set, when it changed (or always). */
private sendCommands(s: Session, always = false) {
if (!this.ready || this.peer.isClosed || s.agent !== this) return
+34 -3
View File
@@ -7,6 +7,8 @@ import LOOP_JSON from "../harness/loop.json"
import t_apply_patch from "../harness/tools/apply_patch.json"
import t_ask_user from "../harness/tools/ask_user.json"
import t_bash from "../harness/tools/bash.json"
import t_branch from "../harness/tools/branch.json"
import t_commit from "../harness/tools/commit.json"
import t_decisions from "../harness/tools/decisions.json"
import t_edit from "../harness/tools/edit.json"
import t_glob from "../harness/tools/glob.json"
@@ -19,20 +21,31 @@ import t_knowledge_get from "../harness/tools/knowledge_get.json"
import t_knowledge_search from "../harness/tools/knowledge_search.json"
import t_list from "../harness/tools/list.json"
import t_memory from "../harness/tools/memory.json"
import t_merge from "../harness/tools/merge.json"
import t_mkdir from "../harness/tools/mkdir.json"
import t_multiedit from "../harness/tools/multiedit.json"
import t_mv from "../harness/tools/mv.json"
import t_note_manage from "../harness/tools/note_manage.json"
import t_note_view from "../harness/tools/note_view.json"
import t_notes_search from "../harness/tools/notes_search.json"
import t_plan_submit from "../harness/tools/plan_submit.json"
import t_plan_update from "../harness/tools/plan_update.json"
import t_plan_write from "../harness/tools/plan_write.json"
import t_pr from "../harness/tools/pr.json"
import t_python from "../harness/tools/python.json"
import t_read from "../harness/tools/read.json"
import t_recall from "../harness/tools/recall.json"
import t_release from "../harness/tools/release.json"
import t_report from "../harness/tools/report.json"
import t_rm from "../harness/tools/rm.json"
import t_search from "../harness/tools/search.json"
import t_session_search from "../harness/tools/session_search.json"
import t_settings from "../harness/tools/settings.json"
import t_show from "../harness/tools/show.json"
import t_skill_manage from "../harness/tools/skill_manage.json"
import t_skill_view from "../harness/tools/skill_view.json"
import t_skills_list from "../harness/tools/skills_list.json"
import t_tag from "../harness/tools/tag.json"
import t_task from "../harness/tools/task.json"
import t_tasks from "../harness/tools/tasks.json"
import t_view_image from "../harness/tools/view_image.json"
@@ -43,9 +56,11 @@ import { fillPrompt } from "./prompt/fill.ts"
export interface ToolSpecDef {
name: string
/** shared: both projects implement it; cli / llembas: only that one does. The execution tools
* (bash, edit, read…) are the CLI's: the web UI runs no agent of its own. */
/** shared: both projects implement it; cli / llembas: only that one does. */
scope: "shared" | "cli" | "llembas"
/** Spec 3.0.0: where it is offered — a chat on the server (its file system), an agent session
* on a device (this CLI, on its own disk), or both. */
sessions: "chat" | "agent" | "both"
risk: "read" | "write" | "execute" | "interact"
exclusive: boolean
/** How a call is shown in a transcript: a shell command, an edit with its diff, a
@@ -60,6 +75,13 @@ export interface ToolSpecDef {
/** `subagent`: offered only to a subagent (report), never to a session's own model. */
offered?: "subagent"
argument_aliases?: Record<string, string>
/** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file
* can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's). */
requires?: ("display" | "page" | "forge")[]
/** What a call answers, word for word, when a requirement went away after the offer. */
unavailable?: string
/** `always`: put to the person in every mode, auto included; never remembered. */
confirm?: "always"
/** `{{name}}` in the description: a fact the projects differ in, filled in by each (fillDescription). */
variables?: Record<string, string>
/** What a project offers of the parameters, where it offers less than all (narrowParameters). */
@@ -68,6 +90,8 @@ export interface ToolSpecDef {
parameters: Record<string, unknown>
}
/** `X.Y.Z`, or a SemVer pre-release while the spec is written ahead of both implementations
* (`3.0.0-draft`). */
export const HARNESS_VERSION = VERSION.trim()
/** How one prompt runs (harness/loop.json): the step backstop, the budgets' defaults, the wrap-up
@@ -163,9 +187,16 @@ export function narrowParameters(parameters: Record<string, unknown>, narrow: Na
}
export const TOOL_SPECS: Record<string, ToolSpecDef> = Object.fromEntries(
([t_apply_patch, t_ask_user, t_bash, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_multiedit, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_read, t_report, t_session_search, t_settings, t_skill_manage, t_skill_view, t_skills_list, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]),
)
/** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and
* offered in an agent session (`sessions` agent or both) — a chat's tools over the server's tree
* (mkdir, mv, rm, search, python, the forge verbs) are not the CLI's. */
export function forCli(t: ToolSpecDef): boolean {
return t.scope !== "llembas" && t.sessions !== "chat"
}
/** A tool's name, description and `exclusive` flag as the spec has them, for its definition:
* `...described("read")`. With `vars`, the description filled and the values kept for its
* parameters (toSpec). */
+5 -2
View File
@@ -16,6 +16,7 @@ import blockAgentsKeep from "../../harness/prompts/blocks/agents-keep.md" with {
import blockLibrary from "../../harness/prompts/blocks/library.md" with { type: "text" }
import blockPrototypes from "../../harness/prompts/blocks/prototypes.md" with { type: "text" }
import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type: "text" }
import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" }
import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" }
import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" }
import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" }
@@ -39,8 +40,9 @@ import personalityFunny from "../../harness/prompts/personality/funny.md" with {
import personalityFormal from "../../harness/prompts/personality/formal.md" with { type: "text" }
import personalitySocratic from "../../harness/prompts/personality/socratic.md" with { type: "text" }
// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas).
// Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/.
// Built-in prompt text, by path relative to harness/prompts (the harness spec shared with LLeMbas):
// every text whose scope is shared or cli. The server's own (blocks/workspace.md, a chat's view of
// its file system) is not here. Any of these can be replaced by a file at the same path under ~/.config/lembas/prompts/.
const BUILTIN: Record<string, string> = {
"system/default.md": defaultSystem,
"system/identity.md": identity,
@@ -53,6 +55,7 @@ const BUILTIN: Record<string, string> = {
"blocks/library.md": blockLibrary,
"blocks/prototypes.md": blockPrototypes,
"blocks/subagent.md": blockSubagent,
"blocks/context.md": blockContext,
"agents/explore.md": agentExplore,
"agents/general.md": agentGeneral,
"modes/manual.md": modeManual,
+1
View File
@@ -353,6 +353,7 @@ export class Engine {
if (!t?.requires) return true
if (t.requires === "vision") return this.model.spec.vision === true
if (t.requires === "project") return this.o.toolCtx.projectDir !== undefined
if (t.requires === "display") return this.o.toolCtx.show !== undefined
if (t.requires === "knowledge") {
const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
+2
View File
@@ -17,6 +17,7 @@ import { memoryTool } from "./memory.ts"
import { sessionSearchTool } from "./session_search.ts"
import { skillManageTool, skillsListTool, skillViewTool } from "./skills.ts"
import { settingsTool } from "./settings.ts"
import { showTool } from "./show.ts"
import { knowledgeGetTool, knowledgeSearchTool, noteManageTool, notesSearchTool, noteViewTool } from "./library.ts"
import type { Tool } from "./tool.ts"
import { writeTool } from "./write.ts"
@@ -56,6 +57,7 @@ export const BUILTIN_TOOLS: Tool[] = [
skillManageTool,
settingsTool,
viewImageTool,
showTool,
// Offered to subagents only (the spec's `offered: subagent`; app.ts SUBAGENT_ONLY).
reportTool,
] as Tool[]
+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) }
},
})
+7 -2
View File
@@ -67,6 +67,9 @@ export interface ToolContext {
set(key: string, value: unknown, scope?: import("../config/settings.ts").Scope): import("../settings.ts").SetResult
models(): string[]
}
/** Open a file in the user's viewer (the show tool): set only where a client over the link said
* it can show files (harness/acp.md, capability `show`). Absent: no display, and show is not offered. */
show?: (req: import("./show.ts").ShowRequest, callId?: string) => Promise<import("./show.ts").ShowReply>
/** The scope sent on every call to the instance's library (`_meta.lembas.scope`, harness/acp.md):
* the session's id, where the client has said what the session may search. */
libraryScope?: string
@@ -112,8 +115,10 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
access: string
/** Runs alone, never alongside other calls of the same step (it waits on the user). */
exclusive?: boolean
/** Offered only when this holds: a model with vision, plan mode, or a project directory. */
requires?: "vision" | "plan" | "project" | "knowledge"
/** Offered only when this holds: a model with vision, plan mode, a project directory, knowledge
* bases with documents, or a display (a client over the link that shows files: the spec's
* `requires: display`). */
requires?: "vision" | "plan" | "project" | "knowledge" | "display"
description: string
schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */