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.