The link (spec 3.4.0): capabilities progress, tree and agents; the team event carries the tree; _lembas/team, /transcript, /findings, /stop; progress and tree in status; subagent on an approval; an agent chat's Min/Max and said read per turn
ci / check (push) Waiting to run

acp.md: a turn without agents keeps the device's own setting (an older server), as before 3.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 08:32:03 +00:00
1 parent ecfd1b0b8a
commit b70cfe483b
10 files changed
+324 -11

No files matched your search

+49 -2
View File
@@ -42,7 +42,7 @@ import { CWD_META, REMOTE_META, Store } from "../session/store.ts"
import type { Message } from "../provider/types.ts"
import type { Hub } from "./hub.ts"
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
import { isNote } from "../session/prefix.ts"
export const ACP_VERSION = 1
@@ -273,6 +273,12 @@ export const CAPABILITIES = [
// 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",
// Harness spec 3.4.0: the progress phases (the `phase` event, `progress` in status); the subagent
// tree (the `team` event with the tree, `_lembas/team*`, `subagent` on an approval); the chat's
// Subagents setting read from `_meta.lembas.agents` (an agent chat's subagents are this device's).
"progress",
"tree",
"agents",
] as const
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
@@ -306,6 +312,8 @@ export function permissionParams(sessionId: string, req: AskRequest, o: { patien
...(o.patient ? { patient: true } : {}),
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
// Spec 3.4.0 (capability tree): a subagent of the tree whose call only the person can allow.
...(req.source?.subagent ? { subagent: req.source.subagent } : {}),
},
},
}
@@ -468,6 +476,10 @@ export class AcpAgent {
peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
peer.handle("_lembas/job/send", (p) => this.job("send", p))
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
peer.handle("_lembas/team", (p) => this.tree("team", p))
peer.handle("_lembas/team/transcript", (p) => this.tree("team_transcript", p))
peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", p))
peer.handle("_lembas/team/stop", (p) => this.tree("team_stop", p))
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
@@ -675,6 +687,8 @@ export class AcpAgent {
const effort = effortOf(p?.effort)
// A level the model does not offer is read as its start level; the answer says the one used.
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
// Spec 3.4.0: the chat's Subagents setting, changed between turns.
if (p?.agents) this.takeAgents(s, { agents: p.agents })
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
}
@@ -724,6 +738,19 @@ export class AcpAgent {
private takeContext(s: Session, p: any) {
const ctx = contextOf(p?._meta)
if (ctx) s.app.setContext(ctx, this.instanceConnection)
this.takeAgents(s, p?._meta?.lembas)
}
/** Spec 3.4.0 (capability agents): the chat's Subagents setting, sent with every turn by the
* instance — absent (an older one, an editor on serve --stdio): the device's own, as before.
* `said`: the person's earlier words in the chat. */
private takeAgents(s: Session, m: any) {
if (!this.lembas) return
const team = s.app.team.team
const a = m?.agents
if (Number.isInteger(a?.min) && Number.isInteger(a?.max) && a.min >= 1 && a.max >= 1) team.setSetting(Math.min(a.min, a.max), a.max)
if (m?.subagents === false) team.subagents = false
if (Array.isArray(m?.said) && !s.app.engine.saidBefore.length) s.app.engine.saidBefore = m.said.filter((x: unknown): x is string => typeof x === "string")
}
/** The session's plan, as a `plan` update — after session/new and after opening from the
@@ -1067,6 +1094,8 @@ export class AcpAgent {
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
plan: s.app.engine.plan ?? null,
jobs: jobList(s.app),
progress: s.app.engine.progress.current,
tree: treeOf(s.app),
}
const row = (this.store ??= new Store()).session(id)
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
@@ -1091,6 +1120,24 @@ export class AcpAgent {
return { jobs: jobList(this.get(p).app) }
}
/** `_lembas/team`, `/transcript`, `/findings`, `/stop` (spec 3.4.0, capability tree): the
* session's subagent tree — the terminal's through the hub, as the jobs go. */
private async tree(op: "team" | "team_transcript" | "team_findings" | "team_stop", p: any) {
const t = this.terminalFor(p)
if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op, id: p?.id, since: p?.since }, 15_000)
const app = this.get(p).app
switch (op) {
case "team":
return teamView(app, p?.since)
case "team_transcript":
return teamTranscript(app, p?.id)
case "team_findings":
return teamFindings(app, p?.id)
case "team_stop":
return teamStop(app, p?.id)
}
}
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
const t = this.terminalFor(p)
@@ -1291,7 +1338,7 @@ export class AcpAgent {
private forward(s: Session, e: Event) {
// A model change says whether the model is this link's instance's: its ref is the
// provider's now (`deepseek/x`), so the ref alone no longer tells.
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e.type === "team" ? { ...e, tree: treeOf(s.app) } : e
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
switch (e.type) {
case "text":