Current ACP implementation compared with the direct Claude SDK design · complex interactions reconstructed from source
-Both trees implement the same architecture — a seq-stamped envelope journal on the host, one WebSocket
- replayed with ?since=, and a pure client-side fold into timeline items. What differs is the vocabulary riding
- inside the envelope, and who does the interpretive work. UI code is excluded from every number below.
ACP = current PR implementation · claude-agent-acp 0.56.0 child process · @agentclientprotocol/sdk 1.2.0 · - Claude SDK = archived direct-SDK design · @anthropic-ai/claude-agent-sdk 0.3.202 in-process
-fidelity sources (⑩–⑮, §6) = claude-agent-acp 0.56.0 dist · @anthropic-ai/claude-agent-sdk 0.3.201 types - (this tree) · @agentclientprotocol/codex-acp 1.1.2 npm tarball, source-inspected
-Seven divergences (D1–D7) carry the wire-protocol signal; everything else in phases ①–⑨ is the same machine - twice. Ranked, roughly, by how much they should weigh on the decision. D8–D10 are a different species: not “who does the work” - but “can the vocabulary even say it” — covered in phases ⑩–⑮ and the §6 fidelity matrix.
-ACP: host SQLite keeps the public session → native ACP session binding. A restarted
- host lists the row as offline and resurrects it on demand with session/load. The adapter replays its native
- on-disk transcript into a fresh, bounded 5,000-frame journal.
SDK: the journal is only a delivery buffer; truth is Claude's native JSONL transcript
- on disk — survives restarts, powers retry() epochs.
Both sides recover completed history from Claude's native store. The ACP host owns registry metadata, not
- a durable transcript. getMessages still pages the bounded ring, so content older than that retained window is
- unavailable; disk-backed paging is required follow-up work. In-flight turns die with the host on both sides.
ACP: streams too — one agent_message_chunk per text delta, live during
- the turn; chunks accumulate into one message and are never replaced (the adapter diffs the SDK's consolidated
- message against what already streamed and forwards only any un-streamed tail). Tool calls arrive as UI-ready
- tool_call frames (title/kind/status on the wire).
SDK: journals the raw model stream verbatim (message_start,
- content_block_delta, …) and then a complete assistant message that supersedes the accumulated deltas
- at fold time — ~10× the journal rows, interpretation deferred to the client fold.
Both sides stream token-by-token. The divergence is the vocabulary (semantic chunks vs raw stream events) - and whether a later authoritative message replaces what streamed — ACP: no; SDK: yes.
-ACP: ships the options on the wire; the client taps an optionId back — zero decision - logic on the device.
-SDK: ships raw input + rule suggestions; the client must build a full
- PermissionResult and invent the Allow / Always / Deny UX.
ACP: the host converts the elicitation form into ordinary permission cards — mobile - has zero question code.
-SDK: the host forwards the raw tool input and mobile special-cases it — the host has - zero question code; carries 3 parallel interaction channels vs ACP's 1.
-Same tool, opposite ends do the work.
-ACP: boots a child process per session and needs 3 stdio round-trips - (initialize → session/new → set_mode) before it's usable.
-SDK: one in-process query() call whose options carry cwd, mode and
- model; its callbacks never cross a process boundary.
ACP: takes plain ContentBlocks, allows mid-turn sends, and the host echoes your - bubble into the journal instantly.
-SDK: needs a client-built SDKUserMessage, only accepts when idle, and
- your bubble waits for the CLI's echo.
ACP: turn-end is host-internal — a stopReason answering a parked RPC; devices only - ever see the state snapshot.
-SDK: journals an explicit result frame with duration, cost and usage —
- billing telemetry for free.
ACP: the spawn becomes a generic think tool call, subagent prose is
- deliberately dropped, and the tree survives only via the non-standard _meta.claudeCode.parentToolUseId
- our fold happens to know about.
SDK: a typed spawn (subagent_type, model, isolation), every subagent
- message tagged with parent_tool_use_id, transcript readable later by (sessionId, agentId).
ACP: the four Task tools are suppressed; each mutation re-emits one flat
- plan — content + status only, priority invented as "medium", everything else
- dropped on the adapter floor.
SDK: ID-addressed tasks with dependencies (blocks/blockedBy),
- owner, metadata — a queryable graph.
ACP: the workflow crosses as one opaque kind:"other" tool call; goals
- don't cross at all — the adapter has no mapping for them.
SDK: a first-class Workflow tool (resumable runs, taskId/runId,
- transcript dir) and an active_goal message type.
In the diagram below every diverging hop wears a magenta number — ≠ — with a one-line - “why it matters”. The Differences filter in the sticky bar hides every hop where the two sides are the same machine.
-Added lines vs each tree's base (git diff + untracked), production code only; UI and tests counted separately.
-| metric | ACP (main) | Claude SDK | Δ sdk vs acp |
|---|---|---|---|
| LOC added, non-UI production | 3,012 | 4,428 | +47% |
| protocol / envelope package | 254 | 687 | +170% |
| host session harness | 1,363 | 1,722 | +26% |
| journal (in-memory ring) | 99 | 92 | −7% |
| client bindings (fold, stream, hooks) | 1,296 | 1,927 | +49% |
if ( added (non-UI) | 135 | 201 | +49% |
| ternaries added | ~31 | ~48 | +55% |
| switch / case clauses | 3 / 22 | 4 / 24 | ≈ |
| deepest nesting (both: WS stream route) | 10 | 9 | ≈ |
| new exported symbols (non-UI) | ~65 | ~120 | +85% |
| wire frame kinds / interaction channels | 6 / 1 | 9 / 3 | |
| UI (excluded from comparison) | 1,419 | 1,296 | −9% |
| tests (excluded) | 1,751 | 3,759 | +115% |
Method: ACP = staged + unstaged + untracked on main vs HEAD; SDK = uncommitted work in the worktree (its committed - base is upstream sync only). Untracked files counted whole. “UI” = presentational screens/components; fold, stream and hook logic - count as client bindings even when they live in .tsx. Lockfiles, plan docs, E2E scripts and packaging tooling excluded everywhere. - Caveat: the ACP tree has had more UI iteration; the SDK tree carries heavier excluded infra (978-line relay E2E script, - packaged-host validation, 7 Maestro flows).
- -Envelope {seq, sessionId, ts, frame}, append-only ring journal (cap 5,000), WS replay from a cursor,
- admission-ack sends, parked-promise permissions, full-snapshot state frames, pure fold reducer.
- The protocol choice didn't change the architecture — it changed the vocabulary, and who does the interpretive work.
Semantic update variants: tool_call / tool_call_update with patch-merge, titles and statuses
- born at the protocol layer, permission options carried on the wire, one interaction channel (AskUserQuestion mapped
- onto it via elicitation). Small client vocabulary; the fold does chunk-merge, patches, subagent tree.
Verbatim SDKMessages: partial deltas superseded by authoritative messages, uuid identity, tool results as
- user-role messages, three parallel interaction channels, client-built PermissionResult.
- Buys native-transcript history, retry epochs, catalog, usage/cost — costs +47% non-UI LOC and +49% ifs.
create → attach → prompt → stream → permission → question → two devices → turn end → reset — - then the complex interactions: ⑩ subagents · ⑪ task graphs · - ⑫ plan mode · ⑬ terminal / background Bash · - ⑭ workflows · ⑮ goals. - One numbered arrow per hop; payloads sit under the actor that sends them. Blue is ACP-only, amber is SDK-only, grey exists on - both sides; dashed arrows are in-process calls that never touch a wire. Hops renumber automatically as you filter. - Phases ①–⑨ are captured traffic; ⑩–⑮ are reconstructed from adapter/SDK source and badged as such.
- -envelopes[] (deduped by seq) → fold() → FoldedTimeline { items, meta, state, lastSeq }.
The thread renders from items; status chip, composer gate and pending cards render from state. - Everything here is disposable — refolded from scratch on any resync.
-SessionScopedState mutates only here; every mutation is journaled as a full-snapshot
- state frame — clients receive status, never compute it.
SessionJournal: per-session in-memory catch-up ring, gapless within one runtime incarnation, cap 5,000.
- Every append fans out to every subscribed socket in the same call.
Parked asks: ACP Map<requestId, resolve> of unanswered JSON-RPC requests ·
- SDK three Deferred maps (permissions / dialogs / elicitations).
ACP: one claude-agent-acp child process per active session; its embedded SDK
- persists the native transcript. Host SQLite stores the lookup key, and session/load restores completed history.
SDK: in-process query() handle over a claude subprocess; history truth is
- Claude's native JSONL transcript on disk, which outlives the journal.
for (env of envelopes) {
- if (env.seq <= lastSeq) continue // dedupe
- update|sdk → append / merge an item
- *_requested → attach a card to its tool
- *_resolved → flip that card in place
- state → replace timeline.state
- reset → stop; full resync
-}
- Device mints the public uuid; create is idempotent on it. The host boots the agent — this is divergence - D5: a child process and a 3-RPC handshake vs one in-process call.
- -sessionId = randomUUID() -// acp example: "7d1e4a02-9c1f-4f7e-b1de-2f6d1a0c55aa" -// sdk example: "6f0d1c2e-4b7a-4c8e-9f21-3d5a7b9c1e0f"
POST {relay}/hosts/org_01J8…%3Amac-mini-m2/trpc/acpSessions.create
-authorization: Bearer <host JWT>
-
-{ "json": {
- "sessionId": "7d1e4a02-9c1f-4f7e-b1de-2f6d1a0c55aa",
- "workspaceId": "a34e11f8-24c1-47c3-9d0e-88f2b1c9a771"
-} }POST {relay}/hosts/org_01J8…%3Amac-mini-m2/trpc/sessions.create
-authorization: Bearer <host JWT>
-
-{ "json": {
- "sessionId": "6f0d1c2e-4b7a-4c8e-9f21-3d5a7b9c1e0f",
- "workspaceId": "9a8b7c6d-5e4f-4a3b-8c1d-2e0f9a8b7c6d",
- "permissionMode": "default"
-} }// looks up the host tunnel by routingKey -// "org_01J8…:mac-mini-m2", forwards opaquely -→ POST /trpc/acpSessions.create // acp -→ POST /trpc/sessions.create // sdk
// gate: PRECONDITION_FAILED unless SUPERSET_ACP_SESSIONS=1
-await runtime.acpSessions.create({ sessionId, workspaceId })
-// idempotent — same id returns the existing sessionawait runtime.sessions.create({ sessionId, workspaceId,
- permissionMode: "default" })
-// env from login-shell snapshot, resolves system `claude` binaryspawn(process.execPath, [claude-agent-acp entry],
- { cwd, env: { …process.env, ELECTRON_RUN_AS_NODE: "1" } })
-
-{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
- "params": {
- "protocolVersion": 1,
- "clientInfo": { "name": "superset-host", "version": "1" },
- "clientCapabilities": {
- "fs": { "readTextFile": false, "writeTextFile": false },
- "terminal": false,
- "elicitation": { "form": {} } // re-enables AskUserQuestion
- } } }
- ⤷ elicitation.form is the load-bearing line: without it the adapter disallows Claude Code's - built-in AskUserQuestion tool — see phase ⑥.
{ "id": 1, "result": {
- "protocolVersion": 1,
- "agentCapabilities": { … } } }query({
- prompt: asyncInputQueue, // user turns pushed later
- options: {
- cwd: "/Users/kirill/.superset/worktrees/…",
- sessionId: "c1a2d3e4-…", // native Claude session uuid
- permissionMode: "default",
- includePartialMessages: true,
- persistSession: true,
- canUseTool, onUserDialog, onElicitation // 3 host-local callbacks
- } })
-// catalog = await q.initializationResult()
- ⤷ the three callbacks are host-local functions — an interaction never crosses a process - boundary until the host journals it.
{ "jsonrpc": "2.0", "id": 2, "method": "session/new",
- "params": {
- "cwd": "/Users/kirill/.superset/worktrees/…",
- "mcpServers": []
- } }{ "id": 2, "result": {
- "sessionId": "sess_9f3k…", // adapter-internal
- "modes": {
- "currentModeId": "bypassPermissions",
- "availableModes": [
- { "id": "default", … }, { "id": "acceptEdits", … }, …
- ] } } }{ "jsonrpc": "2.0", "id": 3, "method": "session/set_mode",
- "params": { "sessionId": "sess_9f3k…", "modeId": "default" } }journal.append({ kind: "state", state: { …, "status": "idle",
- "lastSeq": 1 } }) // → envelope { seq: 1, sessionId, ts, frame }{ "result": { "data": { "json": {
- "sessionId": "7d1e4a02-…",
- "workspaceId": "a34e11f8-…",
- "harness": "claude-agent-acp",
- "status": "idle",
- "currentMode": {
- "currentModeId": "default",
- "availableModes": [ … ] },
- "configOptions": [ … ],
- "pendingPermissions": [],
- "cwd": "/Users/kirill/.superset/worktrees/…",
- "lastSeq": 1,
- "lastStopReason": null,
- "lastError": null } } } }{ "result": { "data": { "json": {
- "sessionId": "6f0d1c2e-…",
- "claudeSessionId": "c1a2d3e4-…",
- "workspaceId": "9a8b7c6d-…",
- "harness": "claude",
- "status": "idle",
- "model": "claude-sonnet-5",
- "permissionMode": "default",
- "effort": null,
- "pendingPermissions": [],
- "pendingUserDialogs": [],
- "pendingElicitations": [],
- "cwd": "/Users/kirill/.superset/worktrees/…",
- "lastSeq": 1,
- "lastError": null } } } }Every cold attach, every resync, and a second device's join are the same triple: read state, read a history - page, dial the WS from a cursor. Hop two is divergence D1 — the single most consequential difference.
- -GET …/trpc/acpSessions.get?input=%7B%22json%22%3A…
-// decoded: { "json": { "sessionId": "7d1e4a02-…" } }GET …/trpc/sessions.get?input=…
-// decoded: { "json": { "sessionId": "6f0d1c2e-…" } }← 200 SessionScopedState -// same shape as ①'s "200 — full SessionScopedState"; -// this object IS the session's top-level state: status chip, -// composer gate and pending cards all render from it
GET …/trpc/acpSessions.getMessages?input=…
-// { "json": { "sessionId": "7d1e4a02-…", "limit": 200 } }GET …/trpc/sessions.getMessages?input=…
-// { "json": { "sessionId": "6f0d1c2e-…", "limit": 200 } }{ "result": { "data": { "json": {
- "items": [
- { "seq": 38, "sessionId": "7d1e…", "ts": 1752107660102,
- "frame": { "kind": "update", "update": {
- "sessionUpdate": "user_message_chunk",
- "content": { "type": "text",
- "text": "Create hello.txt…" } } } },
- … // timeline frame kinds only — state/reset excluded
- ],
- "nextCursor": "s38" // older page: pass cursor "s38"
-} } } }{ "result": { "data": { "json": {
- "items": [
- { "type": "user", "uuid": "b4e2…",
- "session_id": "c1a2d3e4-…",
- "message": { "role": "user", "content": [
- { "type": "text", "text": "List the files…" } ] },
- "parent_tool_use_id": null,
- "parent_agent_id": null },
- … // Claude's own JSONL rows, 200/page
- ],
- "nextCursor": "1kf3z" // base36, cycle-guarded
-} } } }WSS {relay}/hosts/org_01J8…%3Amac-mini-m2
- /acp-sessions/7d1e4a02-…/stream?token=<JWT>&since=51
-// since = folded.lastSeq (fallback state.lastSeq)WSS …/sessions/6f0d1c2e-…/stream?token=<JWT>&since=57 -// since = state.lastSeq — emitState stamps its own seq -// into state.lastSeq so resume lands right after it
subscribe({ sessionId, since, onEnvelope })
-// journal.after(since) = (since, latest] replayed
-// synchronously before live attach → no gap window// envelopes (since, latest], then live — shapes as in the -// phases below. Client rules make delivery exactly-once: -// seq <= lastSeq → drop (at-least-once dedupe) -// seq == lastSeq + 1 → fold (the only accept path) -// seq > lastSeq + 1 → gap → reconnect from cursor
foldTimeline(journalPages)
-→ { "items": [ MessageItem | ToolCallItem | PlanItem ],
- "meta": { "title", "usage", "currentMode", … },
- "state": { … }, "lastSeq": 51 }timelineFromSessionMessages(history) // transcript → seq-0 items
-fold(bufferedEnvelopes) // live rows on top
-→ { "items": [ Message | ToolCall | UserDialog | Elicitation ],
- "meta": { "model", "permissionMode", "lastResult", … },
- "state": { … }, "lastSeq": 57 }
-// same uuid live+transcript → upsert, not duplicateSends are admission-acks on both sides — a turn can block for hours on a human, so the RPC returns the moment - the host accepts; completion only ever arrives on the stream. Divergence D6 lives in the payload shape, the send gate, - and who echoes your bubble.
- -{ "json": {
- "sessionId": "7d1e4a02-…",
- "prompt": [ { "type": "text",
- "text": "Implement exponential retry for webhook delivery." } ]
-} }
-// ← { "accepted": true }
- ⤷ mid-turn sends are allowed — prompts queue on the adapter.
{ "json": {
- "sessionId": "6f0d1c2e-…",
- "message": {
- "type": "user",
- "message": { "role": "user", "content": [ { "type": "text",
- "text": "Implement exponential retry for webhook delivery." } ] },
- "parent_tool_use_id": null
- }
-} }
-// ← { "accepted": true }
- ⤷ requires status "idle" — mid-turn sends throw PRECONDITION_FAILED. The wire shape is the - SDK's own message type, built client-side.
{ "jsonrpc": "2.0", "id": 10, "method": "session/prompt",
- "params": {
- "sessionId": "sess_9f3k…",
- "prompt": [ { "type": "text",
- "text": "Implement exponential retry for webhook delivery." } ]
-} }input.push(message) // AsyncInputQueue<SDKUserMessage> -// the SDK's for-await loop consumes it and the subprocess -// begins the turn; turn end is detected from the stream -// (a `result` frame), not from any RPC response
{ "seq": 2, "sessionId": "7d1e4a02-…", "ts": 1752107660001,
- "frame": {
- "kind": "update",
- "update": {
- "sessionUpdate": "user_message_chunk",
- "content": { "type": "text",
- "text": "Implement exponential retry for webhook delivery." }
- } } }
- ⤷ fold: appendChunk → new user MessageItem { id: "user:2" }.
{ "kind": "state",
- "state": {
- "sessionId": "7d1e4a02-…", "harness": "claude-agent-acp",
- "status": "running", "title": null,
- "pendingPermissions": [], "lastError": null,
- "currentMode": { "currentModeId": "default" },
- "lastSeq": 3, … } }{ "kind": "state",
- "state": {
- "sessionId": "6f0d1c2e-…", "status": "running",
- "model": "claude-sonnet-5", "permissionMode": "default",
- "pendingPermissions": [], "pendingUserDialogs": [],
- "pendingElicitations": [], "lastSeq": 2, … } }// host mutates SessionScopedState, then journals the WHOLE -// snapshot. Fold: not a timeline item — -// timeline.state = frame.state (replace, never merge)
Divergence D2, in the flesh. Agent → host → journal → every socket, one call. Both sides stream - live, token by token; ACP's rows are semantic chunks that are never replaced, the SDK's are the raw model stream, later - superseded by an authoritative message.
- -{ "seq": 4, "sessionId": "7d1e4a02-…", "ts": 1752107663210,
- "frame": { "kind": "update", "update": {
- "sessionUpdate": "agent_message_chunk",
- "content": { "type": "text",
- "text": "I'll inspect the delivery path and existing tests." }
-} } }
- ⤷ fold appendChunk: last item is an agent message → blocks[last].text += chunk —
- merged in place, no new item. agent_thought_chunk is identical, folds to role "thought".
- The SDK's consolidated assistant message never crosses: the adapter diffs it against the already-streamed blocks
- (acp-agent.js:658) and forwards only an un-streamed tail.
{ "seq": 3, "sessionId": "6f0d1c2e-…", "ts": 1752191830123,
- "frame": { "kind": "sdk", "message": {
- "type": "stream_event",
- "uuid": "3c9d41aa-…", "session_id": "c1a2d3e4-…",
- "event": {
- "type": "content_block_delta", "index": 0,
- "delta": { "type": "text_delta", "text": "I'll inspect the " }
- },
- "parent_tool_use_id": null
-} } }
- ⤷ fold appendPartialBlock: accrete into
- MessageItem{ partial: true, id: "message:assistant:3c9d…:0" }.
{ "kind": "sdk", "message": {
- "type": "assistant", "uuid": "3c9d41aa-…",
- "message": {
- "role": "assistant",
- "content": [
- { "type": "text",
- "text": "I'll inspect the delivery path and existing tests." },
- { "type": "tool_use", "id": "toolu_01AbCdEfGh", "name": "Write",
- "input": { "file_path": "src/retry.ts", "content": "…" } }
- ] }
-} }
- ⤷ fold: same uuid → replace the partial wholesale (partial: false); the embedded
- tool_use spawns ToolCallItem{ status: "pending" }. The SDK user prompt also echoes back here
- as an sdk user frame — that echo is what lands in the timeline.
// no replacement pass exists on ACP: chunks were already -// final, and the tool call arrives as its own frame (next)
The parked-promise pattern both sides share: the agent asks, the host parks the ask + journals two rows
- (request, then state snapshot), the answer RPC resolves the parked promise back into the agent. Divergence D3 is
- what rides the wire and who constructs the decision.
observed in the current real-adapter lane:
- Claude can emit two Bash tool uses in parallel, but claude-agent-acp 0.56.0 exposes their permission callbacks to the
- client one at a time. Superset supports a true multi-request stack; the deterministic ACP adapter pins that case.
{ "kind": "update", "update": {
- "sessionUpdate": "tool_call",
- "toolCallId": "toolu_01XyZaBcDe",
- "title": "Write src/retry.ts",
- "kind": "edit",
- "status": "pending",
- "rawInput": { "file_path": "src/retry.ts", "content": "…" }
-} }
- ⤷ fold upsertToolCall → new ToolCallItem. Later tool_call_update frames are - patches: present fields replace, absent keep — merged only at fold time, each patch its own row.
// the tool call already rode inside the authoritative -// assistant message in ④ — no separate tool-call frame
{ "jsonrpc": "2.0", "id": 7,
- "method": "session/request_permission",
- "params": {
- "sessionId": "sess_9f3k…",
- "toolCall": { "toolCallId": "toolu_01XyZaBcDe",
- "title": "Write src/retry.ts", "kind": "edit", "status": "pending" },
- "options": [
- { "optionId": "allow", "name": "Allow", "kind": "allow_once" },
- { "optionId": "reject", "name": "Reject", "kind": "reject_once" }
- ] } }
- ⤷ host parks the response promise in pendingResolvers keyed by the JSON-RPC id
- ("7") and pushes onto state.pendingPermissions.
canUseTool("Write",
- { file_path: "src/retry.ts", content: "…" },
- { requestId: "req_011CNxT9aB",
- toolUseID: "toolu_01AbCdEfGh",
- suggestions: [ /* addRules … */ ],
- signal }) // ← parkPermission stores a Deferred
- // keyed by the SDK's own requestId
- ⤷ same parking pattern, no wire: the Deferred sits in one of three pending maps - (permissions / dialogs / elicitations), each with its own frames and RPC.
{ "kind": "permission_requested",
- "pending": {
- "requestId": "7",
- "toolCall": { "toolCallId": "toolu_01XyZaBcDe",
- "title": "Write src/retry.ts", "kind": "edit", "status": "pending" },
- "options": [
- { "optionId": "allow", "name": "Allow", "kind": "allow_once" },
- { "optionId": "reject", "name": "Reject", "kind": "reject_once" }
- ],
- "requestedAt": 1752107665004 } }
-
-{ "kind": "state", "state": {
- "status": "awaiting_permission",
- "pendingPermissions": [ { "requestId": "7", … } ],
- "lastSeq": 7, … } }
- ⤷ the UI acts on the state copy: PermissionStack renders from
- state.pendingPermissions. The timeline copy attaches a PermissionView to its ToolCallItem —
- for the record.
{ "kind": "permission_requested",
- "request": {
- "requestId": "req_011CNxT9aB",
- "toolUseID": "toolu_01AbCdEfGh",
- "toolName": "Write",
- "input": { "file_path": "src/retry.ts", "content": "…" },
- "suggestions": [ { "type": "addRules", "behavior": "allow",
- "rules": [ { "toolName": "Write",
- "ruleContent": "src/retry.ts" } ],
- "destination": "localSettings" } ],
- "requestedAt": 1752191835001 } }
-
-{ "kind": "state", "state": {
- "status": "requires_action",
- "pendingPermissions": [ { "requestId": "req_011CNxT9aB", … } ],
- "lastSeq": 8, … } }
- ⤷ PendingActions renders every card from the three pending arrays in state — never from - the timeline.
{ "json": {
- "sessionId": "7d1e4a02-…",
- "requestId": "7",
- "outcome": { "outcome": "selected", "optionId": "allow" }
-} }
-// ← { "status": "resolved" }
- ⤷ the client sends back an optionId it was given — zero decision logic on the device.
{ "json": {
- "sessionId": "6f0d1c2e-…",
- "requestId": "req_011CNxT9aB",
- "response": {
- "behavior": "allow",
- "updatedInput": { "file_path": "src/retry.ts", "content": "…" },
- "toolUseID": "toolu_01AbCdEfGh",
- "decisionClassification": "user_temporary"
- }
-} }
-// ← { "status": "resolved" }
- ⤷ the client builds a full PermissionResult: echo (or edit) the input, classify the decision;
- “Always allow” sends updatedPermissions: suggestions. bypassPermissions is stripped at this
- boundary.
{ "jsonrpc": "2.0", "id": 7,
- "result": { "outcome": { "outcome": "selected", "optionId": "allow" } } }
- ⤷ turn-cancel or adapter death settles unanswered asks as { "outcome": "cancelled" }
- via the request's AbortSignal — nothing leaks.
// settlePermission(requestId).resolve(…) → canUseTool returns:
-{ behavior: "allow",
- updatedInput: { file_path: "src/retry.ts", content: "…" },
- toolUseID: "toolu_01AbCdEfGh",
- decisionClassification: "user_temporary" }
- ⤷ the SDK runs the tool with updatedInput — which is also exactly how question answers - travel, next phase.
{ "kind": "permission_resolved", "requestId": …,
- "outcome"/"response": { … } } // resolution edge
-
-{ "kind": "state", "state": { "status": "running",
- "pendingPermissions": [], … } } // pending card disappears
-// SDK: status returns to "running" only once ALL THREE
-// pending maps are emptycaptured against claude-agent-acp 0.56.0 with an authenticated Sonnet/low real-adapter run
- Claude asks: “Which failures should retry?” The turn stays parked — answering does not create a new
- prompt. Divergence D4: the interpretive work happens at opposite ends.
{ "jsonrpc": "2.0", "id": 41, "method": "elicitation/create",
- "params": {
- "mode": "form",
- "sessionId": "sess_9f3k…",
- "toolCallId": "toolu_question_01",
- "message": "Which failures should retry?",
- "requestedSchema": {
- "type": "object",
- "properties": {
- "question_0": {
- "type": "string",
- "oneOf": [
- { "const": "429 + 5xx" },
- { "const": "Also network failures" }
- ] },
- "question_0_custom": { "type": "string" }
- } } } }
- ⤷ extractElicitationQuestions() keeps only question_<n> fields
- (*_custom ignored — mobile is tap-only); non-form modes → cancel; no recognizable questions → decline.
canUseTool("AskUserQuestion",
- { questions: [ {
- header: "Retry scope",
- question: "Which failures should retry?",
- multiSelect: false,
- options: [
- { label: "429 + 5xx" },
- { label: "Also network failures" } ] } ] },
- { requestId: "req_011CQq2xYz",
- toolUseID: "toolu_question_01", signal })
- ⤷ parked in the same permissions map as any Write or Bash — the host doesn't know questions - exist.
{ "kind": "permission_requested",
- "pending": {
- "requestId": "q-3f7c9d2e-…", // randomUUID, host-minted
- "toolCall": { "toolCallId": "toolu_question_01",
- "title": "Which failures should retry?",
- "kind": "other", "status": "pending" },
- "options": [
- { "optionId": "option-0", "name": "429 + 5xx",
- "kind": "allow_once" },
- { "optionId": "option-1", "name": "Also network failures",
- "kind": "allow_once" },
- { "optionId": "skip", "name": "Skip", "kind": "reject_once" }
- ],
- "requestedAt": 1752107668210 } }
-// + { "kind": "state", "state": { "status": "awaiting_permission", … } }
- ⤷ options = enum labels + Skip; card title = the question. Mobile renders and resolves this - exactly like the Write ask in ⑤.
{ "kind": "permission_requested",
- "request": {
- "requestId": "req_011CQq2xYz",
- "toolUseID": "toolu_question_01",
- "toolName": "AskUserQuestion",
- "input": { "questions": [ {
- "header": "Retry scope",
- "question": "Which failures should retry?",
- "multiSelect": false,
- "options": [ { "label": "429 + 5xx" },
- { "label": "Also network failures" } ] } ] },
- "suggestions": [],
- "requestedAt": 1752191841007 } }
-// + { "kind": "state", "state": { "status": "requires_action", … } }
- ⤷ UI: parseAskUserQuestions(request) matches toolName === "AskUserQuestion" and
- strictly validates input.questions — match → multi-question QuestionCard; mismatch → plain
- permission card.
{ "json": {
- "sessionId": "7d1e4a02-…",
- "requestId": "q-3f7c9d2e-…",
- "outcome": { "outcome": "selected", "optionId": "option-0" }
-} }
-// ← { "status": "resolved" }{ "json": {
- "sessionId": "6f0d1c2e-…",
- "requestId": "req_011CQq2xYz",
- "response": {
- "behavior": "allow",
- "updatedInput": {
- "questions": [ …input echoed… ],
- "answers": { "Which failures should retry?": "429 + 5xx" }
- },
- "toolUseID": "toolu_question_01",
- "decisionClassification": "user_temporary"
- }
-} }
-// ← { "status": "resolved" }
- ⤷ tapped chips + free text merged per question (deduped, comma-joined), keyed by question text.
- Declining sends behavior: "deny".
{ "jsonrpc": "2.0", "id": 41,
- "result": {
- "action": "accept",
- "content": { "question_0": "429 + 5xx" }
-} }
- ⤷ Skip → field omitted; cancel mid-form → action: "cancel" aborts the tool.
- The adapter converts this into its own inner canUseTool result.
// canUseTool("AskUserQuestion", …) resolves to:
-{ behavior: "allow",
- updatedInput: { questions: […],
- answers: { "Which failures should retry?": "429 + 5xx" } },
- toolUseID: "toolu_question_01",
- decisionClassification: "user_temporary" }
- ⤷ one hop instead of ACP's three (question → cards → aggregate), because host and agent share - a process.
Second phone, same account. It cold-attaches with the phase-② triple; the fetched state already carries the - pending card, so it renders the question before a single WS frame arrives. Then both devices answer.
-// Device B ran the phase-② triple: get + getMessages + WSS -// The fetched state already contained -// pendingPermissions: [ the question card ] -// From here every journal append fans out to BOTH sockets -// in the same call.
// Device B's respondToPermission → { "status": "resolved" }
-// Device A answers the same requestId a moment later:
-POST …/respondToPermission { "requestId": "q-3f7c9d2e-…", … }
-// ← { "status": "already_resolved" }{ "kind": "permission_resolved", "requestId": "q-3f7c9d2e-…", … }
-{ "kind": "state", "state": { "status": "running",
- "pendingPermissions": [], … } }
-// A's card clears because the state frame REPLACED its
-// sessionState — not because A was told it lost a race.
-// Any number of devices stay consistent by this one rule.The tool finishes, then the turn closes — divergence D7: ACP's turn-end never appears on the device - wire; the SDK journals an explicit result with usage and cost.
- -{ "kind": "update", "update": {
- "sessionUpdate": "tool_call_update",
- "toolCallId": "toolu_01XyZaBcDe",
- "status": "completed",
- "content": [ { "type": "content",
- "content": { "type": "text", "text": "Wrote src/retry.ts" } } ]
-} }
- ⤷ fold: patch-merge onto the ToolCallItem → completed, content attached.
{ "kind": "sdk", "message": {
- "type": "user",
- "message": { "role": "user", "content": [
- { "type": "tool_result",
- "tool_use_id": "toolu_01AbCdEfGh",
- "content": [ { "type": "text", "text": "Wrote src/retry.ts" } ],
- "is_error": false } ] }
-} }
- ⤷ fold: match tool_use_id → flip the ToolCallItem to completed / error in place.
{ "jsonrpc": "2.0", "id": 10,
- "result": { "stopReason": "end_turn" } }{ "kind": "sdk", "message": {
- "type": "result", "subtype": "success",
- "duration_ms": 41230, "num_turns": 3,
- "total_cost_usd": 0.084,
- "result": "Done. Retry with backoff implemented.",
- "usage": { … }
-} }
- ⤷ host rule: a result with zero pending callbacks is the authoritative turn end — the CLI - doesn't reliably emit session_state_changed(idle).
{ "kind": "state", "state": {
- "status": "idle", "lastStopReason": "end_turn",
- "title": "Webhook retry with backoff",
- "pendingPermissions": [], "lastSeq": 15, … } }{ "kind": "state", "state": {
- "status": "idle",
- "pendingPermissions": [], "pendingUserDialogs": [],
- "pendingElicitations": [], "lastSeq": 17, … } }Both sides use cursor replay inside one journal incarnation. The divergence is the history source used - for a full rebuild — D1 again, and where it bites hardest.
- -WSS …/stream ?since=15 // lastSeq the device has folded -// dedupe / gap rules as in ② — “exactly once, in order” is -// enforced by the CLIENT with two integer comparisons; -// the server just replays. The current ACP client performs only -// a shallow envelope check; full runtime parsing remains TODO.
{ "seq": 0, "sessionId": "7d1e4a02-…", "ts": …,
- "frame": { "kind": "reset",
- "reason": "journal_evicted" } }
-// also: invalid_since, session_not_found, session_load_failed
-// not journaled — seq 0 is out-of-band by construction{ "seq": 0, "sessionId": "6f0d1c2e-…", "ts": …,
- "frame": { "kind": "reset",
- "reason": "cursor_unavailable",
- "latestSeq": 57 } }
-// also fired when the cursor is AHEAD of the journal.
-// retry() starts a new epoch: fresh journal, seq restarts
-// at 1, old subscribers get reset("session_restarted")GET …/acpSessions.get // fresh SessionScopedState -GET …/acpSessions.getMessages // newest journal page -// refold from scratch (pure) → resubscribe ?since=folded.lastSeq -// older pages on demand via cursor "s<seq>"-
⤷ the session lists as "offline" from its SQLite registry row. The first live call
- (prompt / getMessages / stream attach) runs session/load and rebuilds a fresh journal. Numeric seqs
- restart at 1; because the cursor has no incarnation id, an overlapping pre-restart cursor is not always detected.
- Epoch-aware cursors are required follow-up work.
GET …/sessions.get // state, incl. lastSeq -WSS …/stream ?since=state.lastSeq // open FIRST, buffer -GET …/sessions.getMessages ×N // drain full transcript -// timelineFromSessionMessages(history) -// → fold buffered envelopes on top → hydrated-
⤷ transcript rows fold at seq 0 (uuid-keyed), buffered live envelopes land after — the overlap - upserts instead of duplicating. More moving parts; history survives.
captured against claude-agent-acp 0.56.0; the current acceptance fixture runs a named five-agent Sonnet Workflow to completion with real token and tool usage; lifecycle details cross-checked against sdk 0.3.201 types
- Native: the Agent tool is a first-class spawn — the subagent has an id, a type, its own transcript file, and
- every message it produces is tagged with the spawning tool_use. Over ACP: the adapter renames the spawn to a generic
- think tool call, deliberately drops subagent prose, and tags nested tool calls with non-standard
- _meta that our fold happens to understand — D8.
{ "kind": "update", "update": {
- "sessionUpdate": "tool_call",
- "toolCallId": "toolu_01TaskAa",
- "title": "Audit retry edge cases", // = input.description
- "kind": "think", // ← a SPAWN, labelled a thought
- "status": "pending",
- "content": [ { "type": "content", "content": {
- "type": "text", "text": "Find every call site that retries webhook delivery…" } } ],
- "rawInput": { "description": "Audit retry edge cases",
- "prompt": "Find every call site that retries webhook delivery…",
- "subagent_type": "Explore", "model": "haiku" },
- "_meta": { "claudeCode": { "toolName": "Task" } }
-} }
- ⤷ subagent_type / model / isolation are not protocol fields —
- no ACP client can rely on them; they ride only inside the untyped rawInput echo.
{ "kind": "sdk", "message": {
- "type": "assistant", "uuid": "9e2f61bb-…",
- "message": { "role": "assistant", "content": [
- { "type": "tool_use", "id": "toolu_01TaskAa", "name": "Agent",
- "input": {
- "description": "Audit retry edge cases",
- "prompt": "Find every call site that retries webhook delivery…",
- "subagent_type": "Explore",
- "model": "haiku",
- "name": "recon-retries",
- "run_in_background": false,
- "isolation": "worktree"
- } } ] }
-} }
- ⤷ the input is a typed spawn: subagent_type · model · name · run_in_background · mode · - isolation ("worktree"/"remote"). The client sees exactly what was launched, and with what powers.
{ "kind": "sdk", "message": {
- "type": "assistant", "uuid": "b7c31d9a-…",
- "session_id": "c1a2d3e4-…",
- "parent_tool_use_id": "toolu_01TaskAa", // ← the spawn
- "message": { "role": "assistant", "content": [
- { "type": "text", "text": "Scanning delivery/ for retry loops…" },
- { "type": "tool_use", "id": "toolu_02Grep", "name": "Grep",
- "input": { "pattern": "retry", "path": "src/delivery" } }
- ] }
-} }
- ⤷ prose AND tool calls arrive, all tagged. Identity outlives the stream:
- listSubagents(sessionId) → getSubagentMessages(sessionId, agentId) replays the
- subagent's own JSONL transcript (…/<sessionId>/subagents/agent-<agentId>.jsonl);
- SubagentStart/SubagentStop hooks carry agent_id + agent_type.
// subagent assistant message (parent_tool_use_id !== null): -// "It is never streamed live and its text/thinking is internal -// to the tool call — keep dropping it so subagent prose -// doesn't leak into the top-level feed." -content = message.message.content.filter( - (item) => item.type !== "text" && item.type !== "thinking");-
⤷ only the tool_use blocks survive the filter and continue to the next hop.
{ "kind": "update", "update": {
- "sessionUpdate": "tool_call",
- "toolCallId": "toolu_02Grep",
- "title": "grep \"retry\" src/delivery",
- "kind": "search", "status": "pending",
- "rawInput": { "pattern": "retry", "path": "src/delivery" },
- "_meta": { "claudeCode": {
- "toolName": "Grep",
- "parentToolUseId": "toolu_01TaskAa" } } // ← the whole tree
-} }
- ⤷ _meta is ACP's sanctioned extension point, but nothing about this key is standard:
- a generic ACP client renders this call flat, as a sibling of the Task it belongs to.
// OUR fold knows the adapter's private key: -claudeParentToolUseId(src) => - src._meta?.claudeCode?.parentToolUseId ?? null -// a tagged frame re-homes the item under its Task — splice -// from top level, re-insert as ToolCallItem.children[] -// Different adapter → different key. Standard client → no tree. -// session/load can rebuild retained tree frames from native history; -// frames older than the host ring remain unavailable.
// by (sessionId, agentId), long after the stream:
-getSubagentMessages("c1a2d3e4-…", "3f2b…")
-// → the subagent's full message history from its own
-// transcript file; survives host restartsreconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
- Native: an ID-addressed task graph (TaskCreate / TaskUpdate / TaskList / TaskGet) with dependencies, owner and metadata.
- Over ACP: the four tools are suppressed and each mutation re-emits one flat plan snapshot —
- content + status only — D9.
{ "type": "tool_use", "name": "TaskCreate",
- "input": { "subject": "Add retry queue",
- "description": "Durable queue with exponential backoff",
- "activeForm": "Adding retry queue",
- "metadata": { "epic": "webhooks" } } }
-// → tool_result: { "task": { "id": "task-1", … } } ← addressable
-
-{ "type": "tool_use", "name": "TaskUpdate",
- "input": { "taskId": "task-2", "status": "in_progress",
- "addBlockedBy": [ "task-1" ], // dependency edges
- "owner": "recon-retries" } } // who's on it
-
-// TaskGet → { task: { id, subject, description, status,
-// blocks: [], blockedBy: ["task-1"] } }
-// TaskList → { tasks: [ { id, subject, status, owner,
-// blockedBy: [] }, … ] }
- ⤷ ids, dependency edges (blocks/blockedBy), owner, metadata — a real graph,
- queryable mid-run. TodoWrite (content/status/activeForm) still exists alongside as the lightweight form.
// no tool_call frame is ever emitted for these: -shouldEmitToolCall = toolName !== "TodoWrite" - && !isTaskTool(toolName) // TaskCreate/Update/List/Get -// at tool_result, mutate an in-adapter Map instead: -applyTaskCreate(state, input, output) // keeps subject, status, -applyTaskUpdate(state, input) // activeForm, description -// TaskList / TaskGet: invisible — reads never cross
{ "kind": "update", "update": {
- "sessionUpdate": "plan",
- "entries": [
- { "content": "Add retry queue",
- "status": "in_progress", "priority": "medium" },
- { "content": "Wire dead-letter alerts",
- "status": "pending", "priority": "medium" }
- ] } }
- ⤷ entries carry content + status only; priority is hardcoded "medium".
- Dropped on the adapter floor: taskId · description · activeForm ·
- blocks/blockedBy · owner · metadata. TodoWrite takes the same door (tools.js:553) and loses its
- activeForm too. Fold: PlanItem is replaced wholesale per frame — no diffing possible without ids.
observed in the ACP spike · recorded in plans/done/20260710-session-harness-acp.md · shapes matched to adapter source 0.56.0
- The one complex interaction ACP handles better than raw: plan-exit crosses as an ordinary permission request wearing
- kind:"switch_mode", with the plan document as content and the target permission modes as tappable options.
{ "jsonrpc": "2.0", "id": 12, "method": "session/set_mode",
- "params": { "sessionId": "sess_9f3k…", "modeId": "plan" } }
-// ← { "id": 12, "result": {} }
-// then, as its own session/update notification:
-{ "sessionUpdate": "current_mode_update",
- "currentModeId": "plan" }
-// → journaled inside the next state snapshot (currentMode){ "jsonrpc": "2.0", "id": 19, "method": "session/request_permission",
- "params": {
- "sessionId": "sess_9f3k…",
- "toolCall": {
- "toolCallId": "toolu_01ExitPlan",
- "title": "Ready to code?",
- "kind": "switch_mode",
- "content": [ { "type": "content", "content": {
- "type": "text", "text": "## Plan\n1. Add retry queue…" } } ],
- "rawInput": { "plan": "## Plan\n1. Add retry queue…" }
- },
- "options": [
- { "optionId": "auto", "name": "Yes, and use \"auto\" mode", "kind": "allow_always" },
- { "optionId": "acceptEdits", "name": "Yes, and auto-accept edits", "kind": "allow_always" },
- { "optionId": "default", "name": "Yes, and manually approve edits", "kind": "allow_once" },
- { "optionId": "plan", "name": "No, keep planning", "kind": "reject_once" }
- ] } }
- ⤷ crosses to devices via the exact phase-⑤ machinery: permission_requested row + state
- snapshot; mobile renders option buttons it already knows how to render. A bypassPermissions option is prepended
- only when the adapter allows bypass.
{ "id": 19, "result": { "outcome": {
- "outcome": "selected", "optionId": "acceptEdits" } } }
-// adapter reacts: emits
-{ "sessionUpdate": "current_mode_update",
- "currentModeId": "acceptEdits" }
-// and resolves ExitPlanMode inside the SDK as
-// { behavior: "allow", updatedInput, updatedPermissions:
-// [ { type: "setMode", mode: "acceptEdits",
-// destination: "session" } ] }
-// tapping "plan" instead → deny:
-// "User rejected request to exit plan mode."canUseTool("ExitPlanMode",
- { plan: "## Plan\n1. Add retry queue…" },
- { requestId, toolUseID,
- suggestions: [ /* setMode … */ ] })
-// the wire says "a tool wants permission" — nothing more.
-// Building "Ready to code?" UX, offering target modes, and
-// flipping permissionMode on accept is all client code.reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
- ACP the spec has client-owned terminals (terminal/create, terminal/output, …) gated on
- clientCapabilities.terminal. Our host says false — and this adapter wouldn't call them anyway:
- it never invokes terminal/* and gates Bash output streaming on a non-standard _meta
- capability instead.
"clientCapabilities": {
- "fs": { "readTextFile": false, "writeTextFile": false },
- "terminal": false, // ← no client-owned terminals
- "elicitation": { "form": {} }
-}
-// and no _meta["terminal_output"] either — see below
- ⤷ grep the adapter dist for terminal/create: zero calls. Standard ACP terminals are
- simply not implemented by claude-agent-acp; its only terminal integration is its own _meta extension.
{ "kind": "update", "update": {
- "sessionUpdate": "tool_call",
- "toolCallId": "toolu_01Bash",
- "title": "bun test --watch=false",
- "kind": "execute", "status": "pending",
- "content": [ { "type": "content", "content": {
- "type": "text", "text": "Run the test suite" } } ],
- "rawInput": { "command": "bun test --watch=false",
- "description": "Run the test suite",
- "run_in_background": true },
- "_meta": { "claudeCode": { "toolName": "Bash" } } } }
-
-// long-running: heartbeat patches, NOT output
-{ "sessionUpdate": "tool_call_update",
- "toolCallId": "toolu_01Bash", "status": "in_progress",
- "_meta": { "claudeCode": { "toolResponse":
- { "elapsedTimeSeconds": 45 } } } }
-
-// completion — stdout lands once, in one piece
-{ "sessionUpdate": "tool_call_update",
- "toolCallId": "toolu_01Bash", "status": "completed",
- "content": [ /* full output as text */ ], "rawOutput": { … } }
- ⤷ a client that advertises _meta["terminal_output"] = true unlocks the adapter's own
- lifecycle (_meta.terminal_info / terminal_output / terminal_exit on these same frames) — still
- adapter-owned, still not ACP's terminal/* methods.
{ "type": "tool_use", "name": "Bash",
- "input": { "command": "bun test --watch=false",
- "run_in_background": true } }
-// → tool_result names the background task; from then on:
-// TaskOutput { task_id } — incremental output, on demand
-// TaskStop { task_id } — kill it
-// result frames enumerate background_tasks:
-// { id, type: "shell", command, status, … }
- ⤷ over ACP those follow-ups have no special case in the adapter switch — TaskOutput / TaskStop
- surface as generic kind:"other" tool calls (tools.js default arm).
reconstructed from adapter source — claude-agent-acp 0.56.0 dist + sdk 0.3.201 types · not a captured run
- Native: a first-class Workflow tool runs a multi-agent program — agent() /
- parallel() / pipeline() / phase() — with a run id and a resume path.
- Over ACP: one opaque tool call — D10.
{ "type": "tool_use", "name": "Workflow",
- "input": {
- "script": "export const meta = { name: \"triage\", … }
- const findings = await parallel([
- agent(\"scan delivery/\"), agent(\"scan api/\") ])
- await phase(\"fix\", () => pipeline(…))",
- "args": { "area": "webhooks" } } }
-// → tool_result (WorkflowOutput):
-{ "status": "async_launched",
- "taskId": "task-9", "taskType": "local_workflow",
- "workflowName": "triage",
- "runId": "run-01H…", // ← resumeFromRunId target
- "transcriptDir": "…/subagents/",
- "scriptPath": "…/workflows/triage.ts" }
- ⤷ the run is addressable (taskId/runId), its transcripts land on disk, and a re-run with
- resumeFromRunId replays completed agent() calls from cache instead of re-executing.
{ "kind": "update", "update": {
- "sessionUpdate": "tool_call",
- "toolCallId": "toolu_01Wf",
- "title": "Workflow", // default: title = tool name
- "kind": "other", "status": "pending",
- "rawInput": { "script": "export const meta = …", "args": { … } },
- "_meta": { "claudeCode": { "toolName": "Workflow" } } } }
-// …then a single "completed" patch carrying the
-// WorkflowOutput as rawOutput. That's the whole story:
-// task_started / task_summary system frames → no case in the
-// adapter's system-subtype switch → dropped. Sub-run activity
-// crosses only as whatever agent() spawns emit (phase ⑩ rules).verified at type/dist level only — no captured frames exist for this one
- Goals exist in the SDK as a streamed message type — not a tool — and the ACP adapter has no mapping for them.
- This is the cleanest example of D10: a capability that simply does not cross the wire.
// bundled sdk.mjs forwards it into the message stream:
-if (e.type === "active_goal") { this.inputStream.enqueue(e) }
-// StdoutMessage names coreTypes.SDKActiveGoalMessage
-// (alongside SDKTaskSummaryMessage, SDKPostTurnSummaryMessage)
-// — but 0.3.201 exports no shape for it and ships no Goal
-// tool in ToolInputSchemas: plumbing, not yet public API.// known-but-ignored types get an explicit `break`: -// tool_use_summary · auth_status · prompt_suggestion · -// conversation_reset · control_request_progress -// anything else — active_goal included — hits: -default: unreachable(message) // logged, dropped, never emitted
// thread/goal/updated crosses as:
-{ "sessionUpdate": "session_info_update",
- "_meta": { "codex": { "goal": {
- "objective": "…", "status": "…", "tokenBudget": … } } } }
-// thread/goal/cleared → _meta.codex.goal: null
-// standard ACP field? no — the extension point, again.
-// Two adapters, two private _meta dialects for the same idea.The four load-bearing shapes, trimmed to essentials. Where the two sides are identical, the shape is shown once.
- Current ACP validation status: the shared TypeScript surface is strict, but the runtime boundary is not fully Zod-checked.
- tRPC scalar inputs are parsed; ACP content uses shallow checks; relay outputs, nested WS frames, adapter responses and SQLite
- rows are still trusted after partial checks. The exact boundary matrix lives in packages/host-service/docs/acp-sessions.md.
L1 · wire envelope — what travels mobile ↔ host
-Same wrapper on both sides. The difference is the frame union: 6 kinds vs 9 — three interaction channels instead of one.
-export type SessionUpdateFrame =
- | { kind: "update"; update: SessionUpdate } // ACP session/update, verbatim
- | { kind: "permission_requested"; pending: PendingPermission }
- | { kind: "permission_resolved"; requestId: string;
- outcome: RequestPermissionOutcome }
- | { kind: "prompt_rejected"; reason: string; promptStartSeq: number }
- | { kind: "state"; state: SessionScopedState } // full snapshot on every change
- | { kind: "reset"; reason: string }; // cursor unservable — resync
-
-export interface SessionUpdateEnvelope {
- seq: number; // per-session, monotonic from 1, gapless
- sessionId: string;
- ts: number;
- frame: SessionUpdateFrame;
-}
- export type SessionEventFrame =
- | { kind: "sdk"; message: SDKMessage } // raw SDK frame, verbatim
- | { kind: "permission_requested"; request: PendingPermissionRequest }
- | { kind: "permission_resolved"; requestId: string;
- response: SessionPermissionResult }
- | { kind: "user_dialog_requested"; request: PendingUserDialogRequest }
- | { kind: "user_dialog_resolved"; requestId: string;
- response: UserDialogResult }
- | { kind: "elicitation_requested"; request: PendingElicitationRequest }
- | { kind: "elicitation_resolved"; requestId: string;
- response: ElicitationResult }
- | { kind: "state"; state: SessionScopedState }
- | { kind: "reset"; reason: string; latestSeq?: number };
-
-export interface SessionEventEnvelope {
- seq: number; sessionId: string; ts: number;
- frame: SessionEventFrame;
-}
- L2 · journal row — what the host stores
-The journal row IS the envelope on both sides, and the append is the same idea character for character.
-append(sessionId: string, frame: Frame): Envelope {
- const envelope = { seq: this.nextSeq, sessionId, ts: Date.now(), frame };
- this.nextSeq += 1;
- this.entries.push(envelope);
- if (this.entries.length > this.capacity) this.entries.shift(); // in-memory ring, cap 5,000
- return envelope; // fanned out to WS subscribers in the same call
-}
- ACP — the bounded journal currently serves both catch-up and history:
- getMessages pages retained rows backwards (cursor s<seq>, message-frame kinds only). A restart uses the SQLite
- registry and session/load to rebuild a fresh ring, but rows beyond the cap remain unavailable. ·
- Claude SDK — the journal is only a live-delivery buffer: history truth is Claude's native
- JSONL transcript; retry() opens a new journal epoch (seq restarts at 1).
L3 · host session surface — the harness wrapping the agent
-One manager class each, same lifecycle verbs. ACP wraps a child process spoken to over JSON-RPC/stdio; - the SDK holds an in-process query() handle whose callbacks are host-local.
-export class AcpSessionManager {
- create({ sessionId, workspaceId }) // idempotent
- prompt({ sessionId, prompt }) // ContentBlock[] → accepted
- respondToPermission({ sessionId, requestId, outcome })
- cancel · setMode · setConfigOption
- get · list · getMessages({ beforeSeq }) // pages the journal
- subscribe({ sessionId, since, onEnvelope }) // sync replay + live
-}
-// per session: claude-agent-acp CHILD PROCESS (ACP JSON-RPC over
-// stdio), journal, subscribers, parked permission resolvers
-// (Map<requestId, resolve>)
- export class ClaudeSessionManager {
- create(input) // starts query(); idempotent
- retry(input) // NEW Query + native transcript
- sendMessage(input) // requires status "idle"
- respondToPermission · respondToUserDialog · respondToElicitation
- interrupt · setModel · setPermissionMode · getCatalog
- get · list · getMessages(input) // native JSONL transcript
- subscribe({ sessionId, since, onEnvelope })
-}
-// per session: IN-PROCESS query() handle (canUseTool / onUserDialog /
-// onElicitation callbacks), AsyncInputQueue, journal, 3 pending maps
-// + claude-runtime.ts: executable resolution, trusted-shell env (228 LOC)
- L4 · client timeline item — what the fold produces for the UI
-ACP nests subagents as a tree and has first-class plans; the SDK tracks partial-stream state and gives - dialogs/elicitations their own timeline kinds.
-export type TimelineItem = MessageItem | ToolCallItem | PlanItem;
-
-interface MessageItem { kind: "message"; role: "user" | "agent" | "thought";
- blocks: ContentBlock[]; failed: boolean }
-interface ToolCallItem { kind: "tool_call"; id: string; // toolCallId
- call: ToolCall; // merged patches
- permissions: PermissionView[];
- children: TimelineItem[] } // subagent TREE (Task)
-interface PlanItem { kind: "plan"; entries: PlanEntry[]; removed: boolean }
-// every item carries startSeq / endSeq
- export type TimelineItem =
- MessageItem | ToolCallItem | UserDialogItem | ElicitationItem;
-
-interface MessageItem { kind: "message"; role: "user" | "assistant" | "system";
- blocks: TimelineContentBlock[];
- partial: boolean } // delta-accretion state
-interface ToolCallItem { kind: "tool_call"; id: string; // toolUseID
- name: string; input: unknown; result: unknown | null;
- status: "pending" | "completed" | "error" | "denied";
- permissions: { request; response }[] }
-// + UserDialogItem / ElicitationItem: { request, response | null }
-// every item carries startSeq / endSeq
- Where the two sides converged exactly, and where the SDK fold earns its extra 109 lines.
- -ACP
-session/load replay. getMessages then walks the bounded ring backwards (cursor s<seq>; state/reset frames excluded), so this is not complete disk-backed pagination yet.(since, latest]; client drops seq ≤ lastSeq, treats seq > lastSeq+1 as a gap (reconnect from cursor), treats reset as stop-and-resync. Backoff 250ms → 10s._meta.claudeCode.parentToolUseId · placeholder tool item when a permission's tool_call fell off the loaded page.Claude SDK
-retry() creates a fresh journal restarting at 1 and pushes reset("session_restarted") to old subscribers.cursor_ahead detection (first replayed seq below the cursor after a restart) and zod validation of every frame — an invalid frame forces a reconnect.toolUseID with a placeholder when the permission wins the race · tool results arriving as user-role messages · transcript-vs-live merge at seq 0.Worked example · raw journal rows → folded timeline
-Six rows in on both sides. ACP folds to three items; the SDK folds to two — its text and tool call ride the same assistant message.
-seq 10 update user_message_chunk "write hello.txt"
-seq 11 update tool_call tc-1 "Write hello.txt" pending
-seq 12 permission_requested req 7 → tc-1, options [allow, reject]
-seq 13 permission_resolved req 7 → selected "allow"
-seq 14 update tool_call_update tc-1 completed, content "done"
-seq 15 update agent_message_chunk "Created hello.txt"
-// ────────────────────── folds to ──────────────────────
-MessageItem { role: "user", blocks: ["write hello.txt"] } // 10
-ToolCallItem { id: "tc-1", status: "completed", // 11–14
- permissions: [{ req 7, resolution: allow }] }
-MessageItem { role: "agent", blocks: ["Created hello.txt"] } // 15
- seq 41 sdk stream_event content_block_start "I'll run" (uuid A)
-seq 42 sdk stream_event content_block_delta " ls for you." (uuid A)
-seq 43 sdk assistant (A) [text "I'll run ls for you.",
- tool_use toolu_9 Bash {command: "ls"}]
-seq 44 permission_requested req_7 → toolu_9 Bash
-seq 45 permission_resolved req_7 → { behavior: "allow" }
-seq 46 sdk user [tool_result toolu_9 "README.md\nsrc"]
-// ────────────────────── folds to ──────────────────────
-MessageItem { role: "assistant", partial: false, // 41–43
- blocks: ["I'll run ls for you."] }
- // deltas built it partial; 43 replaced it authoritative
-ToolCallItem { id: "toolu_9", name: "Bash", status: "completed", // 43–46
- permissions: [{ req_7, response: allow }] }
- What each capability looks like natively, and what's left of it after crossing an ACP adapter. - Claude columns are verified against claude-agent-acp 0.56.0 dist + sdk 0.3.201 types in this tree; Codex columns against the - @agentclientprotocol/codex-acp 1.1.2 npm tarball (source-inspected). Magenta - marks fidelity lost in translation.
-| capability | -native claude sdk | -claude → acp | -native codex app-server | -codex → acp | -
|---|---|---|---|---|
| conversation | -session (uuid) + JSONL transcript on disk; resume, fork, retry epochs | -conversation survives through native transcript + host registry + session/load; host paging is limited to the rebuilt ring (D1) | -thread → turns → items; persisted, resumable, listable | -full fidelity — resume / list / close / delete all advertised | -
| plan | -ExitPlanMode plan document + TodoWrite entries (content/status/activeForm) | -flat plan entries; activeForm dropped, priority invented "medium"; plan doc rides the switch_mode permission (⑫) |
- turn plan items with step + status per step | -flat plan; every entry forced priority "medium" (updatePlan maps status + step only) |
-
| workflow | -first-class Workflow tool — script, taskId/runId, transcript dir, resumable (⑭) |
- one opaque kind:"other" tool call; lifecycle frames dropped | -no equivalent first-class workflow entity | -n/a — nothing to map | -
| subagent identity | -agent id + subagent_type + own transcript, readable by (sessionId, agentId) (⑩) | -mostly lost — a kind:"think" call; identity = the tool_use id; type only in untyped rawInput | -child threads with collab-agent items carrying sender/receiver thread ids | -not exposed — child threads excluded from session/list (sourceKinds filter), fork not advertised; thread ids only inside rawInput | -
| subagent activity | -full message stream tagged parent_tool_use_id — prose + tools + hooks | -prose deliberately dropped; nested tool calls only via private _meta.claudeCode |
- collaboration items + subAgentActivity events on the thread | -collab calls become kind:"other"; subAgentActivity explicitly ignored (under the dist's "//ignored types" arm) |
-
| dependencies + owners | -tasks carry blocks/blockedBy edges, owner, metadata (⑪) | -all dropped — the adapter never reads those fields | -not represented in the item model | -not represented | -
| parallel execution | -parallel() in workflows, background agents/shells, task graph as coordination |
- opaque — concurrent tool calls interleave with no grouping construct | -agent collaboration primitives (spawn/send across threads) | -opaque — generic tool calls, relationships live only in rawInput | -
ACP itself has no standard model for any row below "plan": no workflow entity, no task graph, no subagent
- addressing — _meta is the protocol's sanctioned extension point, and both adapters lean on it in mutually
- incompatible dialects (_meta.claudeCode.parentToolUseId vs _meta.codex.goal). Codex "native" cells
- describe the app-server protocol as consumed by codex-acp's own handlers; we have not run a Codex app-server directly.
Conversation, permissions, modes, plans: standardized. Workflows, task graphs, subagent identity: not in the
- protocol at all — every adapter invents a private _meta dialect or silently flattens. The spike doc said
- it early: “_meta fields are the sanctioned extension point on every type.”
As a common chat/control plane ACP earns its keep — one client vocabulary, permission options on the wire, - plan checklists for free. As a portable workflow/multi-agent model it isn't one: the richest capabilities of both - agents either flatten (tasks → checklist), go opaque (workflows), or vanish (subagent prose, goals).
-Either agent-specific _meta contracts (we already parse claudeCode.parentToolUseId;
- we'd add codex.goal, and re-do it per agent) or a Superset-owned normalized layer above the adapters —
- native SDK/app-server in, one internal schema out. The waterfall above is the inventory of what that layer must preserve.