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":
+46
View File
@@ -5,6 +5,7 @@
import type { App } from "../app.ts"
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
import type { Store } from "../session/store.ts"
import type { TreeInfo } from "../team/team.ts"
import { historyTurns } from "./agent.ts"
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
@@ -79,3 +80,48 @@ export function storedJobs(store: Store, sessionId: string): JobInfo[] {
}
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
}
// ── Spec 3.4.0 (acp.md capability `tree`): the subagent tree of a session ────────────────────
/** The tree (subagents.md §12), or null for a session with no subagents. */
export function treeOf(app: App): TreeInfo | null {
const t = app.team.team
if (!t.offered && !t.hasMembers() && !t.ended.length) return null
return t.info()
}
/** `_lembas/team`: the tree and the whole room above `since`. */
export function teamView(app: App, since: unknown) {
const n = typeof since === "number" && since >= 0 ? since : 0
const t = app.team.team
return { tree: treeOf(app), room: t.room.filter((m) => m.n > n).map((m) => ({ n: m.n, from: m.from, to: m.to, recipients: m.recipients, text: m.text, time: m.time, report: m.report, kind: m.kind })) }
}
function subagentOf(app: App, id: unknown) {
const key = String(id ?? "")
const a = app.team.team.find(key)
if (!a || a.id !== key || a.id === "lead") throw new RpcError(INVALID_PARAMS, `this session has no subagent ${key}`)
return a
}
/** `_lembas/team/transcript`: a subagent's session, read-only. */
export function teamTranscript(app: App, id: unknown) {
const a = subagentOf(app, id)
const job = a.jobId ? app.registry.get(app.jobsSessionId(), a.jobId) : undefined
const messages = job?.subagent?.engine.messages ?? (job?.childSessionId && app.store ? app.store.messages(job.childSessionId) : [])
return { id: a.id, state: a.state, turns: historyTurns(messages, app.engine.o.toolCtx.cwd) }
}
/** `_lembas/team/findings`. */
export function teamFindings(app: App, id: unknown) {
const a = subagentOf(app, id)
return { id: a.id, findings: a.findings.map((f) => ({ ...f })) }
}
/** `_lembas/team/stop`: the person stops a live subagent and its subtree. */
export function teamStop(app: App, id: unknown) {
const a = subagentOf(app, id)
if (a.endState) throw new RpcError(INVALID_PARAMS, `${a.id} has already ended`)
const gone = app.team.personStop(a.name)
return { stopped: app.team.team.ended.slice(-gone.length).map((x) => x.id) }
}
+11 -2
View File
@@ -16,7 +16,7 @@ import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
import { readLevel } from "../provider/effort.ts"
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
import type { SessionContext } from "../app.ts"
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
import { dropAttachments, preparePrompt } from "./prompt.ts"
@@ -454,7 +454,7 @@ export class Share {
return
}
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e })
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e.type === "team" ? { ...e, tree: treeOf(this.app) } : e })
if (e.type === "task" && e.state === "end") this.sendCommands()
}
}
@@ -520,6 +520,15 @@ export class Share {
return jobSend(this.app, p?.id, p?.text)
case "transcript":
return jobTranscript(this.app, p?.id)
// Spec 3.4.0 (capability tree): the subagent tree, through the hub as the jobs go.
case "team":
return teamView(this.app, p?.since)
case "team_transcript":
return teamTranscript(this.app, p?.id)
case "team_findings":
return teamFindings(this.app, p?.id)
case "team_stop":
return teamStop(this.app, p?.id)
default:
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
}
+1 -1
View File
@@ -546,7 +546,7 @@ export function createApp(o: AppOptions): App {
let job: Job
// The asker is the session's; a child's ask says which subagent it comes from.
const askerFor = (): Asker => ({
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description } }),
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description, ...(member ? { subagent: { id: member.id, name: member.name, position: member.role } } : {}) } }),
get question() {
return o.asker.question
},
+1 -1
View File
@@ -130,7 +130,7 @@ export interface Asker {
purpose?: string
/** Set when a subagent running as a background job asks, so the card can say which — it may
* ask while the session itself is idle. */
source?: { jobId: string; agent: string; description: string }
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
}): Promise<AskReply>
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
* id, which keys the card on another screen. */
+5 -1
View File
@@ -647,9 +647,13 @@ export class Engine {
* included, as the store keeps them; never a note, a marked message or a machine turn. */
personSaid(): string[] {
const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages
return personTexts(all, this.o.steerMark ?? STEER_MARK)
return [...this.saidBefore, ...personTexts(all, this.o.steerMark ?? STEER_MARK)]
}
/** Spec 3.4.0 (acp.md, *Subagents in an agent chat*): the person's earlier words in a chat this
* device has not seen, as the client sent them (`said` on session/new). */
saidBefore: string[] = []
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
+1 -1
View File
@@ -82,7 +82,7 @@ export interface Pending {
/** What the model says the call is for. */
purpose?: string
/** The subagent (running as a job) that asks, when one does. */
source?: { jobId: string; agent: string; description: string }
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
resolve: (r: AskReply) => void
}