snappy-agent-host skill
start runtime cwd promptwrite-reversibleprompt session textwrite-reversiblestatus sessionreadcancel sessionwrite-reversiblesessionsreadmodels runtimeread/mcp/local$ npx snappy-skills install snappy-agent-host
$ npx snappy-skills install --all
$ npx snappy-skills update
This hand is the ACP road through the skills MCP. It uses the exact adapters already bundled in SnappyOS.app and imports @agentclientprotocol/sdk@1.4.0 from that runtime. The SDK owns NDJSON, JSON-RPC, method validation, and protocol constants. The hand does not install or reimplement ACP.
| Verb | Arguments | Result | |||||
|---|---|---|---|---|---|---|---|
start |
`runtime cwd prompt [--permissions allow-all | ask] [--model ID] [--effort LEVEL]` | Starts or reuses the one durable ACP session for the folder, runs the prompt, and prints the first bounded page of JSON-line steps. Without --model/--effort the runtime uses the person's own config. |
||||
prompt |
`session text [--answer allow_once | allow_always | reject_once | reject_always | cancelled | OPTION_ID]` | Resolves a pending permission when answer is present, queues the text, and prints that turn's bounded step page. |
status |
session [--cursor N] |
Prints the next bounded page of stored JSON-line steps. The final page line names next_cursor. |
|||||
cancel |
session |
Sends ACP session/cancel, cancels a pending permission, and kills the complete local process tree within two seconds. |
|||||
sessions |
none | Lists durable session IDs, runtimes, statuses, folders, and the model and effort each session reports. | |||||
models |
runtime |
Opens one throwaway session and prints the config options THE RUNTIME advertises — the model picker, the reasoning-level picker, their allowed values and current values. |
bashapi.ts start codex ~/my-repo "reply with exactly: five" --model gpt-5.6-sol --effort low
api.ts models codex
Through the MCP:
json{"name":"snappy-agent-host","verb":"start","arguments":{"runtime":"claude","cwd":"/Users/robertboulos/snappy-skills","prompt":"list the five tools this repo serves, one line each","permissions":"allow-all"},"timeout_ms":600000}
Continue the skill's step page with status and its next_cursor. If the MCP runner also returns its own byte next_cursor, continue that envelope first with {call_id,cursor}. These are separate cursors and neither starts work.
The model is the person's own config unless the AI asks for one, and the list is the
runtime's. This skill pins no model and keeps no model list, so it cannot go stale.
--model and --effort. Nothing is set, so codex reads~/.codex/config.toml, Claude reads its own settings, Gemini reads its own.
--model <id> --effort <level>. Both are carried over the ACP road(session/set_config_option) against the option the runtime advertises for the
reserved model and thought_level categories — matched by **category, never by a
hardcoded id**, so a runtime may rename or add ids freely. A value that the runtime
does not advertise is refused, and the refusal names the values that do exist.
models <runtime> returns what the runtime itself advertises. If a runtimeadvertises nothing, the answer says state: "not-advertised" rather than inventing a
list; for codex only it then also shows ~/.codex/models_cache.json, clearly labelled
as the CLI's own cache.
session event, status and sessions report modeland effort as the runtime REPORTS them after the session opens, plus config_from
(runtime-default | protocol | adapter-config | not-advertised). A live
config_option_update keeps them current mid-session. What was asked for is kept
separately under asked.
never re-spawned underneath a live session.
cwd is the requested folder and is immutable for the session._meta.claudeCode.options.settingSources is ["user","project","local"]; project is required so CLAUDE.md and project settings load.allow-all still records every permission ask and answer. ask waits for prompt --answer.~/.snappy-agent-host/sessions/<folder-hash>/steps.ndjson.CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, ANTHROPIC_API_KEY, and OPENAI_API_KEY are not inherited.claude-agent-acp@0.73.0 and codex-acp@1.8.0; Gemini uses the installed CLI's --acp mode.CODEX_CONFIG names a model only when the caller asked for one; a key absent there is a key codex reads from the person's own config.toml. When it is named it is only a seed — the protocol road is what the report is read back from.GEMINI_API_KEY or GOOGLE_API_KEY through snappy-settings/load.ts.bashnode --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts start claude ~/snappy-skills "inspect this repo" --permissions allow-all
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts status SESSION --cursor 12
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts prompt SESSION "continue"
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts cancel SESSION
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts sessions
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts models codex
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts start codex ~/my-repo "reply with exactly: five" --model gpt-5.6-sol --effort low
SKILL.md contains the full protocol, process, permission, rendering, auth, and native-Mac research. references/snappy-os-app-seams.md documents the proven app integration.
Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-agent-host Index]|root: ~/.claude/skills/snappy-agent-host|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}|references:{agmente-codex-app-server.md,agmente-test-oracle.md,desktop-acp-clients.md,extract-agent-host.md,extract-agmente-rendering.md,snappy-os-app-seams.md,zed-acp-client.md,zed-plan-usage-modes-commands.md}
<!-- SKILL-INDEX-END -->
snappy-axsnappy-voice-control<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
start |
runtime, cwd, prompt |
write-reversible |
npx tsx ~/.claude/skills/snappy-agent-host/api.ts start <runtime> <cwd> "<prompt>" |
prompt |
session, text |
write-reversible |
npx tsx ~/.claude/skills/snappy-agent-host/api.ts prompt <session> "<text>" |
status |
session |
read |
npx tsx ~/.claude/skills/snappy-agent-host/api.ts status <session> |
cancel |
session |
write-reversible |
npx tsx ~/.claude/skills/snappy-agent-host/api.ts cancel <session> |
sessions |
— | read |
npx tsx ~/.claude/skills/snappy-agent-host/api.ts sessions |
models |
runtime |
read |
npx tsx ~/.claude/skills/snappy-agent-host/api.ts models <runtime> |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
name: snappy-agent-host
role: Run durable Claude Code, Codex, and Gemini CLI sessions through ACP and the skills MCP
loaded-by: Native skill discovery
---
# snappy-agent-host
This hand is the ACP road through the skills MCP. It uses the exact adapters already bundled in SnappyOS.app and imports `@agentclientprotocol/sdk@1.4.0` from that runtime. The SDK owns NDJSON, JSON-RPC, method validation, and protocol constants. The hand does not install or reimplement ACP.
## Verbs
| Verb | Arguments | Result |
|---|---|---|
| `start` | `runtime cwd prompt [--permissions allow-all|ask] [--model ID] [--effort LEVEL]` | Starts or reuses the one durable ACP session for the folder, runs the prompt, and prints the first bounded page of JSON-line steps. Without `--model`/`--effort` the runtime uses the person's own config. |
| `prompt` | `session text [--answer allow_once|allow_always|reject_once|reject_always|cancelled|OPTION_ID]` | Resolves a pending permission when `answer` is present, queues the text, and prints that turn's bounded step page. |
| `status` | `session [--cursor N]` | Prints the next bounded page of stored JSON-line steps. The final `page` line names `next_cursor`. |
| `cancel` | `session` | Sends ACP `session/cancel`, cancels a pending permission, and kills the complete local process tree within two seconds. |
| `sessions` | none | Lists durable session IDs, runtimes, statuses, folders, and the model and effort each session reports. |
| `models` | `runtime` | Opens one throwaway session and prints the config options THE RUNTIME advertises — the model picker, the reasoning-level picker, their allowed values and current values. |
```bash
api.ts start codex ~/my-repo "reply with exactly: five" --model gpt-5.6-sol --effort low
api.ts models codex
```
Through the MCP:
```json
{"name":"snappy-agent-host","verb":"start","arguments":{"runtime":"claude","cwd":"/Users/robertboulos/snappy-skills","prompt":"list the five tools this repo serves, one line each","permissions":"allow-all"},"timeout_ms":600000}
```
Continue the skill's step page with `status` and its `next_cursor`. If the MCP runner also returns its own byte `next_cursor`, continue that envelope first with `{call_id,cursor}`. These are separate cursors and neither starts work.
## Choosing the model
The model is the person's own config unless the AI asks for one, and the list is the
runtime's. This skill pins no model and keeps no model list, so it cannot go stale.
- **Default:** omit `--model` and `--effort`. Nothing is set, so codex reads
`~/.codex/config.toml`, Claude reads its own settings, Gemini reads its own.
- **Ask:** `--model <id> --effort <level>`. Both are carried over the ACP road
(`session/set_config_option`) against the option the runtime advertises for the
reserved `model` and `thought_level` categories — matched by **category, never by a
hardcoded id**, so a runtime may rename or add ids freely. A value that the runtime
does not advertise is refused, and the refusal names the values that do exist.
- **See:** `models <runtime>` returns what the runtime itself advertises. If a runtime
advertises nothing, the answer says `state: "not-advertised"` rather than inventing a
list; for codex only it then also shows `~/.codex/models_cache.json`, clearly labelled
as the CLI's own cache.
- **Read back, never echo:** the `session` event, `status` and `sessions` report `model`
and `effort` as the runtime REPORTS them after the session opens, plus `config_from`
(`runtime-default` | `protocol` | `adapter-config` | `not-advertised`). A live
`config_option_update` keeps them current mid-session. What was asked for is kept
separately under `asked`.
- Asking on an already-running session goes down the protocol road too; the adapter is
never re-spawned underneath a live session.
## Invariants
- The session `cwd` is the requested folder and is immutable for the session.
- `_meta.claudeCode.options.settingSources` is `["user","project","local"]`; `project` is required so CLAUDE.md and project settings load.
- One durable session exists per real folder because ACP resume outranks a later cwd.
- `allow-all` still records every permission ask and answer. `ask` waits for `prompt --answer`.
- Every ACP update, permission ask, runtime log, turn result, and cancellation is a bounded JSON line in `~/.snappy-agent-host/sessions/<folder-hash>/steps.ndjson`.
- Adapter child environments are allowlisted. `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION`, `ANTHROPIC_API_KEY`, and `OPENAI_API_KEY` are not inherited.
- Claude and Codex use the app-pinned `claude-agent-acp@0.73.0` and `codex-acp@1.8.0`; Gemini uses the installed CLI's `--acp` mode.
- `CODEX_CONFIG` names a model only when the caller asked for one; a key absent there is a key codex reads from the person's own `config.toml`. When it is named it is only a seed — the protocol road is what the report is read back from.
- Claude and Codex can use their CLI login, so the hand declares no credential requirement. Gemini reads an optional `GEMINI_API_KEY` or `GOOGLE_API_KEY` through `snappy-settings/load.ts`.
## Direct CLI
```bash
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts start claude ~/snappy-skills "inspect this repo" --permissions allow-all
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts status SESSION --cursor 12
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts prompt SESSION "continue"
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts cancel SESSION
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts sessions
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts models codex
node --experimental-strip-types ~/.claude/skills/snappy-agent-host/api.ts start codex ~/my-repo "reply with exactly: five" --model gpt-5.6-sol --effort low
```
## Read next
`SKILL.md` contains the full protocol, process, permission, rendering, auth, and native-Mac research. `references/snappy-os-app-seams.md` documents the proven app integration.
Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.
<!-- SKILL-INDEX-START -->
[snappy-agent-host Index]|root: ~/.claude/skills/snappy-agent-host|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md}|references:{agmente-codex-app-server.md,agmente-test-oracle.md,desktop-acp-clients.md,extract-agent-host.md,extract-agmente-rendering.md,snappy-os-app-seams.md,zed-acp-client.md,zed-plan-usage-modes-commands.md}
<!-- SKILL-INDEX-END -->
## Used by
- `snappy-ax`
- `snappy-voice-control`
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `start` | `runtime`, `cwd`, `prompt` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-agent-host/api.ts start <runtime> <cwd> "<prompt>"` |
| `prompt` | `session`, `text` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-agent-host/api.ts prompt <session> "<text>"` |
| `status` | `session` | `read` | `npx tsx ~/.claude/skills/snappy-agent-host/api.ts status <session>` |
| `cancel` | `session` | `write-reversible` | `npx tsx ~/.claude/skills/snappy-agent-host/api.ts cancel <session>` |
| `sessions` | — | `read` | `npx tsx ~/.claude/skills/snappy-agent-host/api.ts sessions` |
| `models` | `runtime` | `read` | `npx tsx ~/.claude/skills/snappy-agent-host/api.ts models <runtime>` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
api.ts)#start {runtime,cwd,prompt,permissions?} opens or reuses the one durable ACP
session for that real folder. prompt {session,text,answer?} continues it and
can answer a pending permission. status {session,cursor?} pages the skill's
own JSON-line steps. cancel {session} cooperatively cancels and then kills the
full process tree within two seconds. sessions lists durable IDs and folders.
models {runtime} lists the config options the runtime advertises.
The model is chosen, visible, and never pinned here. start takes
--model <id> and --effort <level>; with neither, nothing is set and the
runtime uses the person's own config (codex: ~/.codex/config.toml). Both are
applied over ACP session/set_config_option against whichever option the
runtime advertises under the reserved model and thought_level categories —
selected by category, never by a hardcoded id, so this skill holds no model
list and cannot go stale as runtimes add models. models <runtime> is how the
AI sees the choices; the session event, status and sessions report the
model and effort the runtime REPORTS (with config_from), separately from what
was asked for. Measured 2026-09-08 against the installed adapters: codex-acp
advertises ids model + reasoning_effort, claude-agent-acp model +
effort — two different id sets behind the same two categories, which is the
reason the category is the key.
The TypeScript host imports the app-pinned @agentclientprotocol/sdk@1.4.0
from SnappyOS.app's existing runtime. The SDK owns ACP v1 framing and method
validation; this skill does not hand-write the protocol and installs nothing.
Claude and Codex use the same bundled adapters as the app:
claude-agent-acp@0.73.0 and codex-acp@1.8.0.
Three constraints are tests, not advice: the requested folder is the session
cwd; Claude's _meta.claudeCode.options.settingSources includes project so
CLAUDE.md loads; and one durable session keys one real folder because resume
outranks any later cwd.
Sources (all cited in references/extract-agent-host.md, 678 lines, ~130 URLs): FAZM=~/projects/fazm (acp-bridge + Swift side), the ACP v1 spec + schema, @agentclientprotocol/claude-agent-acp 0.73.0, @anthropic-ai/claude-agent-sdk 0.3.258, @agentclientprotocol/codex-acp 1.8.0 / codex app-server, @google/gemini-cli 0.58.0 --acp, and the host apps in §6. Tags: [docs] official, [blog] third-party, [issue] tracker, [src] local source.
The one-paragraph answer: speak ACP (Agent Client Protocol) natively from Swift — NDJSON + JSON-RPC 2.0, protocolVersion: 1, ~25 methods — and spawn three adapters as child processes: @agentclientprotocol/claude-agent-acp (wraps the Claude Agent SDK, which spawns the real claude), @agentclientprotocol/codex-acp (wraps codex app-server), and gemini --acp. You get uniform tool_call{kind,status}, diff, terminal, plan, session/request_permission, modes, and usage_update for free. Do not build a fazm-style Node bridge — fazm's bridge exists only because it also ran its own MCP tools and OAuth in Node; the Swift⇄bridge protocol is a second, custom protocol you don't need (FAZM/acp-bridge/src/index.ts:1-27; Desktop/Sources/Chat/ACPBridge.swift:212-277 — Swift never speaks ACP there).
| wiedymi/swift-acp | aptove/swift-sdk | rebornix/acp-swift-sdk | |
|---|---|---|---|
| License / tag / floor | MIT / v0.1.0 / macOS 12, tools 5.9 | Apache-2.0 / v0.1.16 / macOS 12, Swift 6 | MIT / untagged / macOS 13, Swift 6 |
| Last push (2026-09-02) | 2026-07-24 | 2026-04-25 | 2026-02-07 |
| request_permission / usage_update / terminal / local spawn | ✅ / ✅ / ✅ / ✅ Process + shell-PATH resolver |
✅ / ❌ / ✅ / minimal | ❌ (app layer) / ❌ / ❌ / ❌ (FileDescriptor only, iOS) |
Shipped precedents: rebornix/Agmente (MIT, 540★) — iOS and native macOS ACP + Codex app-server client; connects over WebSocket (@rebornix/stdio-to-ws), so it never spawns. Read references/extract-agmente-rendering.md (494 lines, cited) before borrowing: the "high-performance" transcript (ListViewKit + MarkdownView) is UIKit-only — on macOS it falls back to a SwiftUI LazyVStack with inline-only markdown; its ACP fold drops plan, usage_update, session_info_update, diff/terminal content blocks and locations; there is no unified-diff renderer. What IS worth copying: the data model (ChatMessage/AssistantSegment/ToolCallDisplay), the chunk/tool-call merge rules and their tests (our oracle, §G of the extract), ChatEntry/mapper/diff/height-cache/scroll-policy (platform-neutral), permission bookkeeping + "cancel pending permissions before session/cancel", and the Codex thread/read hydration merge with JSON fixtures. §H of the extract is the copy/adapt/skip table with a port order. Poolside Desktop Assistant — closed-source native macOS ACP client. Full list: agentclientprotocol.com/get-started/clients. Pick wiedymi/swift-acp for a Mac app that spawns adapters locally; port the allowlisted spawn environment from api.ts (no library does the hygiene). Verified 2026-09-02: references/swift-acp-probe/ (SwiftPM, ~90 lines) drove a real Claude turn and a Write-tool permission round-trip through it, and the same binary drove codex-acp → OpenRouter (plain + Write turns, ~6 s) — build it first when the adapter or the library moves. Two policy layers: the host answers session/request_permission, but Codex only asks when its own approval_policy/sandbox_mode (config.toml) say so — a workspace write under workspace-write never reaches the host. Set Codex's config per session to match the posture you want. swift-acp 0.1.0 ceilings, found building P0 for SnappyOS (2026-09-02): (1) it never exposes the child pid/pgid and its stderr reader DISCARDS every byte (ProcessManager.startReadingStderr; stderrLines() is main-only) — front every adapter with a /bin/sh shim: printf "%s" "$" > <pgid-file>; exec <argv> 2>> <stderr-log> (exec keeps the pid, so $ is the group leader; exit 127 = not installed); (2) ACPProcessManager.launch MERGES your env on top of ShellEnvironment.loadUserShellEnvironment() — an allowlist is not enough, actively blank ANTHROPIC_API_KEY, OPENAI_API_KEY, CLAUDECODE, CLAUDE_CODE_CHILD_SESSION with ""; (3) it writes its own registry at ~/Library/Application Support/ACP/acp-processes.json, not disableable; (4) stdout is read on GCD via Pipe.readabilityHandler and the transport is not injectable — Zed's ≥4 MB reader-thread rule cannot be applied without vendoring the transport. If any of these bite, vendor the transport in-tree; do not move to an untagged commit. PermissionOutcome(optionId:) is a struct, not an enum. Reference implementation with 38 tests: snappy-os-app branch acp-host-p0 (Launch/AgentHost*.swift, Tests/SnappyOSTests/AgentHostTests.swift).
references/desktop-acp-clients.md §I, agmente-test-oracle.md)#undefined = unchanged; a tool_call_update for an unknown id SYNTHESIZES the row; repeated tool_call with the same id mutates one row; force-settle every running tool on turn close (claude-agent-acp #1061 keeps emitting tool starts after session/cancel); missing messageId → synthesize auto:<stream>:<n>; open thinking auto-finalizes when content arrives; a ~250 ms quiescence timer opens/settles agent-initiated turns (#864).pendingPermissions lives on the session model (one source for band, sidebar, row); default button allow_once; "always allow" only as an LRU written when the user picked allow_always (Jockey) — never inferred; no host-side bypass flag in Emdash/Gold Band/Jockey — risk rides the ACP mode selector; pass settingSources: [] / an explicit initial mode (#1056: ~/.claude/settings.json silently overrides the model too).~/.local/bin, ~/.cargo/bin, ~/.volta/bin, every ~/.nvm/versions/node/*/bin, /opt/homebrew/{bin,sbin}, /usr/local/{bin,sbin}; POSIX_SPAWN_SETPGROUP, write the pgid to a file and kill it on next launch (Gold Band; Emdash #2153 = 240 descendants / 21 GB); race initialize against process death with the stderr tail in the error; idle watchdog on every await (#1023: 26-minute hang).session/resume → load → new, and say which happened; persist a versioned INTENT, never the environment or MCP credentials; pool one process per (provider, cwd) and fence routes by generation; queue prompts instead of rejecting them.env is [{name,value}]; gate http/sse on advertised mcpCapabilities; assert your server appears in tools/list (#883 live bug)..claude.json hasTrustDialogAccepted).references/zed-acp-client.md): target protocolVersion: 1 and give every wire enum an unknown(String) case (crate 2.0.0 is an SDK version, not protocol 2); tool_call_update.content is a full snapshot and tool-call text is the accumulated string — only agent_message_chunk is a delta (v1 has no append primitive); register the session id BEFORE sending session/load — the transcript replays as notifications before the response; run the stdio reader on a Thread with ≥4 MB stackSize, not GCD (512 KiB overflows on the first message); spawn through $SHELL -c and parse exit 127 as "not installed"; setsid() + killpg(SIGKILL) plus the launch-time pgid reaper Zed lacks (zed#61303: 55 orphans); model 7 client tool states, not the wire's 4; distinguish cancelled from superseded-by-follow-up; terminals: keep the TAIL under outputByteLimit (spec; Zed keeps the head), PAGER="", GIT_PAGER=cat, exec </dev/null or the first git log hangs, buffer output that arrives before terminal/create is registered; do not advertise terminal: true until the PTY exists; _meta keys namespaced (_snappy.*); build a FakeAgentConnection before any UI; watchdog on initialize/session/new, none on session/prompt. Zed treats an unknown permission kind as allow; we treat it as reject unless auto-approve is on.CLAUDE_CONFIG_DIR STARVES the login — CLAUDE_CONFIG_DIR=<empty dir> claude -p → "Not logged in", which over ACP is -32000 — symlink the user's ~/.claude/.credentials.json into the session dir, never copy it, and pre-seed project trust there; AF_UNIX sun_path is 104 bytes — keep $SNAPPY_STATE_ROOT/agent-host.sock under ~100 chars or bind fails; MCP presence is not client-observable at ACP v1 / swift-acp 0.1.0 (no tools/list; mcpCapabilities flags only optional transports; the adapter's mcpServerStatus never crosses the wire; available_commands_update is slash commands only) — the only provable signal is your own MCP child announcing itself, so do the presence check on the runtime side; bundle adapters with npm i --omit=optional (518 MB → 56 MB: drops the vendored @openai/codex-darwin-arm64 and @anthropic-ai/claude-agent-sdk-darwin-arm64 CLIs) and point them at the user's own binaries via CLAUDE_CODE_EXECUTABLE / CODEX_PATH; the pgid reaper needs an INDEX of session roots when sessions live under per-run workspaces (a state-root sweep finds nothing). Reference: snappy-os-app branch acp-host-p0 (Launch/AgentHostSocket.swift, AgentHostTurnTests, scripts/embed-acp-adapters.sh; 85 tests + 2 live).total_cost_usd / ACP usage_update.cost on a SUBSCRIPTION login too — it is an estimate at API rates, not a charge. Record mode: subscription|api|unknown (Swift knows: keys stripped + CLI login = subscription; provider key kept = api), estimatedAmount, chargedAmount (0 under subscription, null when unknown — Codex publishes no cost), coveredBy. Never render an estimate as a charge; never render null as $0.00. Robert's rule 2026-09-02.claude leak per turn; coalesce usage_update → one RUN_METADATA per turn boundary, not one per update (8 sidecar lines per short turn otherwise); name the step by the SETTLED tool title (arrives on request_permission), not the transient tool_call title ("Preparing file…"); check usage_update on done — tokensSize came back as an output count, not the context window; the adapter spawns claude --allow-dangerously-skip-permissions --setting-sources=user,project,local — pin settingSources: []; a machine where nobody ever opened the app window can never produce a boot beacon — give headless installs a launch knob that opens the window or a documented gate override; run installs inside the console session (sudo launchctl asuser <uid> …) so the login keychain is already unlocked; SNAPPY_STATE_ROOT must be injected by the app for ANY app-spawned runtime, not only one inside /Contents/Resources/.acp-host-p0 @ 164f56767, 94 tests + 2 live turns): a turn reaps its own process group on done unless session_key ≠ run_id (a room key) — then a 10-minute idle ceiling reaps it; terminateAllProcessGroups() on app quit; terminal tokensSize = the last STREAMED window (null if none streamed — never the response's output count); RUN_METADATA = first usage_update + one per 2 s + one on done (4 per 13-s turn); _meta.claudeCode.options.settingSources = [] on session/new (swift-acp 0.1.0: build NewSessionRequest yourself and Client.sendRequest) — the claude child then shows --setting-sources= empty; the session-root index prunes dead roots on write and tests must inject their own state root. Two install traps: with /Applications/SnappyOS.app moved aside a late gate failure rolls back to NOTHING (APP_INSTALL_HAD_STABLE=false) — leave the app in place; and to make the app open its window headlessly, quit it, rm -rf ~/Library/Saved\ Application\ State/ai.snappy.os.savedState, relaunch — a beacon lands ~25 s later. Unaddressed runs are claimed by ANY computer on the deployment (the MacBook took one and settled it runtime_unavailable); locality is not targeting.references/snappy-os-app-seams.md, 835 lines, this product)#The product already spawns real claude/codex as batch runtimes and folds their output into AG-UI frames, run steps and receipts; permissions are off only because of two literal flags (--dangerously-skip-permissions, approval_policy = "never"). actionExecutionPolicy → stage_only|approve_each|delegated|no_grant is a 1:1 map onto ACP permission kinds; snappyMcpSpec is already the mcpServers shape. Rule: Swift owns the process and the protocol; the run's identity, launch receipt, approvals and Activity stay in the Node runtime; emit the existing AG-UI vocabulary (new frames only for permission, plan, diff, terminal, usage); sessions under SNAPPY_STATE_ROOT, never a third root; no silent runtime fallback (the launch door refuses it). Verified: Codex shell tools cannot reach 127.0.0.1:3147 under its sandbox (000 exit=7), Claude can. Plan: ~/projects/snappy-os-app/research/ACP-HOST-PLAN.md (v3).
Host a coding agent in SnappyOS.app?
├─ Want Claude + Codex + Gemini behind ONE client → ACP from Swift, three adapters (§2, §3) [recommended]
│ ├─ Claude: claude-agent-acp; need raw SDK events (cost, rate_limit, compact)? → _meta.claudeCode.emitRawSDKMessages
│ ├─ Codex: codex-acp (ACP) or codex app-server directly (OpenAI's own desktop surface; real approvals)
│ └─ Gemini: gemini --acp + authenticate{methodId:"gemini-api-key"} + GEMINI_CLI_TRUST_WORKSPACE=true
├─ Claude only, need canUseTool.updatedInput / maxBudgetUsd / spawnClaudeCodeProcess → Claude Agent SDK in bundled Node (§3.1b)
├─ Zero policy ambiguity about the user's subscription → drive the interactive `claude` in a PTY (Piebald's road; screen-scraping)
└─ Fire-and-forget batch, no approvals → `claude -p --output-format stream-json` / `codex exec --json` / `gemini -p --output-format stream-json`
Before any of it: §5 auth policy and §7 native-Mac traps — that's where shipping fails.
Fazm.app (Swift) ──Pipe()s, custom newline-JSON──▶ bundled node acp-bridge/dist/index.js
└─▶ node patched-acp-entry.mjs = @agentclientprotocol/claude-agent-acp 0.29.2 (ACP over stdio)
└─▶ @anthropic-ai/claude-agent-sdk 0.2.112 spawns the `claude` CLI
└─▶ MCP servers the SDK spawns: fazm_tools (node), playwright, macos-use, whatsapp, google-workspace
└─▶ (lazy) node_modules/@zed-industries/codex-acp-darwin-arm64/bin/codex-acp ← Codex, ACP
└─▶ (lazy) node bundle/gemini.js --experimental-acp ← Gemini, ACP
└─▶ unix socket $TMPDIR/fazm-tools-<pid>.sock ← the app's own MCP tools dial BACK to reach Swift
process.execPath (the bundled Node) by absolute path — no PATH lookup ever (index.ts:1557-1585).CLAUDECODE ("without this, --resume silently fails when Claude Code detects it's being launched from inside another Claude Code session"), sets NODE_NO_WARNINGS=1 (index.ts:1558-1567). All three agents spawned detached:true so kill(-pid) reaches the group (:1185-1210).session/new — spawned by the agent, so it dials back over a Unix socket; a tools/call becomes tool_use to Swift, Swift answers tool_result (index.ts:2495-2523, fazm-tools-stdio.ts:55-209). With the SDK road this is replaced by in-process tools (createSdkMcpServer, §3.1b).allow_always → allow_once → literal "allow", index.ts:1616-1628). Upstream (v2.9.89, 2026-09-02) added approval-gate.ts with FAZM_APPROVAL_MODE off|destructive|always, 300 s timeout — and had to force session/set_mode default and settingSources: [] because the user's own ~/.claude/settings.json (defaultMode: bypassPermissions, permissions.allow) was silently starving the gate (upstream index.ts:1940-1951, 3029-3038).diff, terminal, or permission options — ToolCallStatus is only .running|.completed (ChatProvider.swift:276-279). That's the ceiling you'd inherit by copying it./protocol/v1/*)#_meta everywhere, _-prefixed custom methods.-32700/-32600/-32601/-32602/-32603 standard, -32800 request cancelled, -32000 authentication required, -32002 resource not found. claude-agent-acp sometimes wraps a 401 as -32603 with /401|failed to authenticate/ in the message (index.ts:1802-1811) — spec says -32000.| Side | Method | ||||
|---|---|---|---|---|---|
| agent | initialize, authenticate, logout, session/new, session/load, session/resume, session/list, session/close, session/delete, session/prompt, session/set_mode, session/set_config_option; notification session/cancel |
||||
| client (you) | session/request_permission (request), session/update (notification — but claude-agent-acp can send it WITH an id; ack it index.ts:1629-1633), fs/read_text_file, fs/write_text_file, `terminal/create |
output | wait_for_exit | kill | release, elicitation/create` |
| either | $/cancel_request |
session/set_model and session/fork are NOT spec — adapter extensions (Claude: session/set_model, unstable_forkSession; Gemini: unstable_setSessionModel). The spec road is session/set_config_option with a model category (B.2, B.10).{protocolVersion:1, clientCapabilities:{fs:{readTextFile:false,writeTextFile:false}, terminal:false, auth:{terminal:true}}, clientInfo:{name,title,version}} → {protocolVersion, agentCapabilities{loadSession, promptCapabilities{image,audio,embeddedContext}, mcpCapabilities{http,sse}, sessionCapabilities{list,resume,close,delete,…}}, agentInfo, authMethods[]}. Advertise fs:false unless you are an editor (it's for unsaved buffers); terminal:true means you own every shell command's PTY (§7).authMethods: {id,name} (call authenticate{methodId}) or {type:"terminal", id, args, env} → "the client runs the configured agent program as a separate interactive process for the user to authenticate via a TUI… zero exit status signals success… MUST NOT pass this method to authenticate" — this is how Claude subscription login is exposed (§5). Terminal methods are advertised only if you set clientCapabilities.auth.terminal.{cwd (absolute), mcpServers:[{name, command (absolute), args[], env:[{name,value}]}]} — env is an array, not an object; http entries {type:"http", name, url, headers:[{name,value}]}. Returns {sessionId, modes?, configOptions?}. Expect an available_commands_update immediately, before your per-session handler exists (index.ts:1700-1741). A hung MCP spawn hangs session/new for the whole timeout (:3203-3222).{sessionId, prompt:[{type:"text",text} | {type:"image", data, mimeType} (FLAT — not Anthropic's source) | {type:"resource", resource:{uri,text|blob,mimeType}} | resource_link]} → {stopReason, _meta} only — end_turn | max_tokens | max_turn_requests | refusal | cancelled. No tokens/cost in v1; adapters put per-turn usage in _meta (Claude 0.71+: _meta.quota{input_tokens, output_tokens, cache_*, model_usage}; Gemini: _meta.quota.token_count). There is no PDF/document block — point the agent at the path.session/update variants (11): user_message_chunk / agent_message_chunk / agent_thought_chunk {content, messageId?} (messageId change = new message); tool_call {toolCallId, title, kind: read|edit|delete|move|search|execute|think|fetch|switch_mode|other, status: pending|in_progress|completed|failed, content?[], locations?, rawInput?, rawOutput?}; tool_call_update (only changed fields); plan {entries[{content, priority, status}]} (replace whole plan); available_commands_update; current_mode_update; config_option_update; session_info_update; usage_update {used, size, cost?} (cumulative). ToolCallContent = {type:"content"} | {type:"diff", path, oldText, newText} | {type:"terminal", terminalId} — render all three (fazm flattens to text).{sessionId, toolCall{toolCallId,title,kind,status:"pending"}, options:[{optionId, name, kind: allow_once|allow_always|reject_once|reject_always}]} → {outcome:{outcome:"selected", optionId}} or {outcome:{outcome:"cancelled"}}. Option ids are agent-defined strings — never hard-code "allow"; codex-acp fails closed on unadvertised ids. Only two outcomes exist. Cancel ≠ reject (Claude's permission extension).session/cancel {sessionId} (notification) ends the turn, not the session; the prompt "MUST" return stopReason:"cancelled"; you "MUST respond to all pending session/request_permission requests with the cancelled outcome". It is cooperative — a wedged tool subprocess will not die (fazm SIGKILLs playwright children, index.ts:143-198). $/cancel_request {requestId} cancels any single request./compact as text (protocol.ts:182-186).terminal/create {command,args,env,cwd,outputByteLimit} → {terminalId}; terminal/output → {output, truncated, exitStatus?}; wait_for_exit; kill; release. Advertise true and every agent shell command is a PTY you own and can render in SwiftTerm.initialize → (auth) → session/new → session/prompt → stream of session/update → session/request_permission → tool_call_update{completed, content:[diff|terminal|content]} → usage_update → result {stopReason}. Debug with ACP Inspector (github.com/newioapp/acp-inspector).@agentclientprotocol/sdk 1.4.0 (ndJsonStream, client({name}).onRequest(...).connectWith(stream, …)), Rust crate (powers Zed). No Swift SDK — write the ~300-line client. @zed-industries/* packages are archived redirects.@agentclientprotocol/claude-agent-acp 0.73.0 (Node ≥ 22) [docs] repo README, src/acp-agent.ts#node …/claude-agent-acp/dist/index.js (or npx -y @agentclientprotocol/claude-agent-acp@0.73.0), stdio: pipe. Flags: --cli <args…> forwards to the bundled claude (login), --hide-claude-auth. Env: CLAUDE_CODE_EXECUTABLE=<user's claude> so the user keeps ONE login (Emdash's pattern), CLAUDE_AGENT_LOGS=<dir>, ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN, Bedrock/Vertex vars. All console.* go to stderr.query() with includePartialMessages:true, settingSources:["user","project","local"], systemPrompt:{type:"preset",preset:"claude_code"} (override via _meta.systemPrompt), canUseTool, CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1.promptCapabilities{image, embeddedContext} (no audio), mcpCapabilities{http, sse}, loadSession, sessionCapabilities{additionalDirectories, close, delete, fork, list, resume, subagents}, _meta.claudeCode.promptQueueing, _meta.steering (_session/steering injects into a running turn). authMethods (terminal type): claude-ai-login (args:["--cli","auth","login","--claudeai"]), console-login; authenticate() accepts only gateway ids — anything else "Method not implemented".default (Manual), acceptEdits, plan, auto, bypassPermissions (disabled as root). Config options: mode, model, effort, fast-mode._meta.claudeCode.options on session/new forwards raw SDK Options (merges hooks, mcpServers, disallowedTools); _meta.claudeCode.emitRawSDKMessages: true | [filter] streams every raw SDK message as _claude/sdkMessage — the supported replacement for fazm's monkey-patch (cost, rate_limit_event, compact_boundary, tasks). usage_update from modelUsage; per-turn PromptResponse._meta.quota since 0.71.session/resume|close|delete, listSessions, unstable_forkSession, logout (runs claude auth logout), _session/async_task/stop. Permission extension _meta.permission{title,description}, fixed ids allow-once, allow-with-updates, exit-plan-*, reject; opt-in session-failure extension surfaces usage-limit/auth failures instead of a silent end_turn.ScheduleWakeup, CronCreate/Delete/List, RemoteTrigger, Monitor, PushNotification via disallowedTools (index.ts:2948-2964).--hide-claude-auth), #744 stale ANTHROPIC_API_KEY in settings, #880 session/new blocked ~100 s by getContextUsage() through a gateway, #630 session/prompt may never resolve → race it against an idle timer (fazm: 20 s idle, 180 s during compaction).@anthropic-ai/claude-agent-sdk 0.3.258) [docs] code.claude.com/docs/en/agent-sdk/*#query({prompt, options}) → AsyncGenerator<SDKMessage>; tool() + createSdkMcpServer() = in-process tools (no separate MCP process, no socket relay); Query controls interrupt(), setPermissionMode(), setModel(), getContextUsage(), accountInfo(), rewindFiles(), streamInput(), close().permissionMode default|acceptEdits|bypassPermissions|plan|dontAsk|auto (bypass needs allowDangerouslySkipPermissions:true), canUseTool ({behavior:'allow', updatedInput?, updatedPermissions?} | {behavior:'deny', message, interrupt?}; order Hooks → deny → ask → mode → allow → callback; "can stay pending indefinitely"), mcpServers (stdio|sse|http|sdk), resume/forkSession/resumeSessionAt, settingSources (CLAUDE.md loads only via this), systemPrompt {type:'preset', preset:'claude_code'} (default is minimal!), maxBudgetUsd, env (replaces the environment — spread process.env), spawnClaudeCodeProcess (own the spawn from Swift), pathToClaudeCodeExecutable, executable: 'bun'|'node'.system/init{session_id, apiKeySource, model, permissionMode, tools, mcp_servers}, assistant, user{tool_use_result}, stream_event, system/compact_boundary, rate_limit_event{five_hour|seven_day, utilization, resetsAt}, task_started/notification, result{subtype, total_cost_usd (running total per session), usage, modelUsage{…costUSD, contextWindow}, num_turns, permission_denials}.npm ci --omit=optional drops the bundled binary. The SDK↔CLI wire (claude -p --input-format stream-json --output-format stream-json --permission-prompt-tool=stdio + control_request{can_use_tool|hook_callback|…}/control_response) is what Vibe Kanban drives from Rust with no Node — a Swift app could too, but the framing has no docs page.~/.claude/projects/<cwd, non-alnum→'-'>/<id>.jsonl; since CLI 2.1.223 --resume searches other projects too (retires fazm's cwd-migration machinery). One CLAUDE_CONFIG_DIR per hosted session or concurrent instances corrupt ~/.claude.json (issues #28847 #28922 #3117…).| Surface | Approvals | Use | ||
|---|---|---|---|---|
codex app-server (stdio; --listen ws:// / unix://) |
yes — item/commandExecution/requestApproval, item/fileChange/requestApproval, item/permissions/requestApproval |
What OpenAI's own desktop + VS Code use. initialize{clientInfo} → initialized → thread/start{model, cwd, approvalPolicy, sandbox} → turn/start{threadId, input[]}; streams item/* deltas, turn/diff/updated, turn/plan/updated, thread/tokenUsage/updated. "jsonrpc":"2.0" is omitted on the wire. Auth: account/login/start{type:"chatgpt"} → {authUrl}; account/rateLimits/read. "experimental" per docs. |
||
@agentclientprotocol/codex-acp 1.8.0 |
yes → ACP session/request_permission (accept→allow_once, acceptForSession→allow_always, decline→reject_once; unknown ids fail closed) |
If the app speaks ACP for every agent. Env CODEX_API_KEY/OPENAI_API_KEY, CODEX_PATH, CODEX_CONFIG (JSON), `INITIAL_AGENT_MODE read-only |
agent | agent-full-access. Its real error text is **ANSI-coloured on stderr** ("Internal error" on the wire; codex-provider.ts:82-86,259-274`). |
codex exec --json / @openai/codex-sdk |
no — approvals auto-rejected | batch only. Hangs at 0% CPU if stdin is an inherited-but-never-closed pipe → < /dev/null (#20919). |
@zed-industries/codex-acp is archived. Any OpenAI-compatible provider works via config.toml [model_providers.<id>] base_url / env_key / wire_api = "responses" (codex ≥ 0.152 rejects "chat"); api.ts --provider openrouter writes an isolated CODEX_HOME doing exactly that — verified live through OpenRouter when the ChatGPT quota was at 0. Auth file ~/.codex/auth.json or keyring; a respawn is needed after login because the subprocess won't re-read it (index.ts:1418-1428). thread/start with a writable sandbox marks the project trusted in config.toml.
gemini --acp (--experimental-acp deprecated). Env: GEMINI_API_KEY, GEMINI_CLI_TRUST_WORKSPACE=true ("silently skips MCP server registration when the workspace isn't trusted"), GEMINI_SANDBOX=false, NO_COLOR=1, GEMINI_TELEMETRY_ENABLED=false.initialize → authMethods oauth-personal | gemini-api-key | vertex-ai | gateway; you must authenticate {methodId:"gemini-api-key"} or session/new fails -32000. OAuth-personal "requires an interactive browser flow that's hostile to a background subprocess" — refuse (gemini-provider.ts:74-86). Modes default|auto_edit|yolo|plan. Per-turn tokens in _meta.quota.token_count.session/update under a mismatched sessionId (gemini-provider.ts:88-106). Sessions ~/.gemini/tmp/<hash>/chats/.protocol.ts:560-580).sessionId→cwd, pre-checked the JSONL, and physically moved transcripts between project dirs (index.ts:2000-2030, 3292-3387). CLI ≥ 2.1.223 searches across projects — prefer that.index.ts:3524-3526).index.ts:5076-5090). Resuming a cancelled session replayed stale chunks until adapter 0.29.2 (ACP #442).session/close every warm session you abandon — "structural cause of the CPU regression 2026-05-14"; a cwd change left "an orphaned claude SDK process at 70–90% CPU forever" (index.ts:3806-3818, 6940-6947).end_turn) → restart the whole adapter, 30 s cooldown; classify by HTTP status first because the SDK tags 529 as rate_limit (api-failure.ts:14-30; incident 2026-05-14).acpRequest doesn't observe AbortSignal → race the prompt against abort + idle + TTFT watchdogs, or a Stop during a 77 s Terminal tool hangs forever (index.ts:4634-4670; "May 5 2026 incident").index.ts:5625-5633).index.ts:1830-1832).ANTHROPIC_AUTH_TOKEN → ANTHROPIC_API_KEY ("in -p/SDK mode the key is always used when present" — it silently bills the API instead of the subscription) → apiKeyHelper → CLAUDE_CODE_OAUTH_TOKEN (claude setup-token, 1 year) → profile → subscription OAuth. fazm strips ANTHROPIC_API_KEY from the child env in personal mode (ACPBridge.swift:2367-2369).Claude Code-credentials (falls back to ~/.claude/.credentials.json 0600 when the Keychain rejects the write, e.g. SSH); CLAUDE_CONFIG_DIR keys a separate Keychain entry. The item's partition list is apple-tool: — a differently-signed Swift app reading it gets the Keychain prompt, and "Always Allow" resets on every token refresh (TN3137; CodexBar #624). Rule: let the claude binary own its credential; never read or write it from Swift.claude.ai/oauth/authorize writing the Keychain item (oauth-flow.ts:24-31, 380-420) is exactly the forbidden pattern. The compliant road = the terminal-auth method: spawn claude-agent-acp --cli auth login --claudeai in a real PTY and wait for exit 0 (Zed, Emdash do this).claude -p, and third-party app usage still draw from your subscription's usage limits." Conductor stayed on the SDK; Piebald moved to driving the interactive TUI to be safe. Branding for SDK products: "Powered by Claude", not "Claude Code".~/.codex/auth.json ("treat like a password"), app-server account/login/start runs the browser flow for you — no hand-rolled OAuth needed. Gemini: API key; OAuth-personal is not headless-safe.CLAUDECODE (nested-session guard) and CLAUDE_CODE_CHILD_SESSION; set DISABLE_AUTOUPDATER=1, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1, DISABLE_TELEMETRY=1 (any non-empty value, even 0, turns these ON), CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 (also skips the background title-model call), CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 (strips creds from Bash/hook/MCP children; 2.1.251 also stripped CLAUDE_CONFIG_DIR).| Host | Road | Lesson |
|---|---|---|
| Zed | ACP, Rust | The reference client. Spawns registry agents with its own managed Node (v24) into ~/Library/Application Support/Zed/external_agents/; setsid in pre_exec, killpg(SIGKILL); resolves PATH via <shell> -l -i -c … --printenv under setsid. Its bug tracker previews yours: 55 orphaned agent processes / 3.1 GB (#61303), archived sessions keeping MCP servers alive (#56747), usage-limit exit 143 dead session (#55501), permission prompts never rendering (#62788). |
| Emdash | ACP + node-pty, Electron | Closest OSS analogue. CLAUDE_CODE_EXECUTABLE: <host claude> so the user keeps one login; pre-seeds ~/.claude.json projects[<path>].hasTrustDialogAccepted=true so the trust dialog never blocks; installs marker-tagged hooks; ships app-sandbox=false, allow-jit, allow-unsigned-executable-memory, disable-library-validation. |
| Conductor | Claude Agent SDK, Tauri+Rust | "Conductor uses native Claude Code… through the Claude Agent SDK"; each task = worktree + branch + terminal + diff. Per-agent auth is the CLI's own. |
| Piebald | interactive claude over PTY |
"we now run Claude Code interactively in the background without relying on the Agent SDK or claude -p" — zero policy ambiguity, at the cost of screen-scraping; reimplements the hooks contract; persists pending approvals across reboots. |
| Vibe Kanban | SDK wire from Rust, no Node | claude -p --permission-prompt-tool=stdio --input-format stream-json … + control_request/control_response — proof a non-Node host can drive Claude's own protocol. |
| CodeLayer/hld | Go daemon | Approvals via --permission-prompt-tool mcp__approvals__request_permission — the pre-SDK way to get a callback out of claude -p. |
| Happy Coder | PTY + SDK | launchd agents outside the Aqua session can't reach the Keychain → 401; Ink leaves stdin O_NONBLOCK; CLAUDE_CODE_ENTRYPOINT=sdk-ts hides sessions from --resume. |
| Claude Desktop (Anthropic) | first-party | Worktree per session at .claude/worktrees/; "reads your shell profile… to extract PATH" — even Anthropic recovers PATH from the shell. |
Converged: nobody re-implements the agent loop; every desktop host isolates sessions with git worktrees (and hits "worktree lacks .env/node_modules"); approvals come from the protocol, never from scraping the TUI (except Piebald); the two auth postures are "the CLI logs itself in" (compliant) vs "we intermediate the token" (fazm).
app-sandbox+inherit; the user's claude isn't. fazm and Emdash ship app-sandbox=false → no Mac App Store; Developer ID + notarization.*.node, dylibs, rg) needs --options runtime --timestamp; node needs cs.allow-jit (+ allow-unsigned-executable-memory); Python/addons disable-library-validation; keep .pyc out of the bundle (PYTHONDONTWRITEBYTECODE=1) or the seal breaks Sparkle; rsync --delete node_modules (a nested duplicate SDK once shadowed the top-level one); node must be a 16 K-page build or "macOS 26 will crash".codesign --verify but gets SIGKILLed → copy node to $TMPDIR/<bundle-scoped> and probe node --version before every spawn (NodeBinaryHelper.swift:1-95; dev and prod once clobbered each other's copy)./usr/bin:/bin:/usr/sbin:/sbin; either bundle everything by absolute path (fazm), resolve once via $SHELL -ilc (Claude Desktop, VS Code, Zed's --printenv trick), or a hard-coded ladder. LaunchServices may hand you /private/var/folders/… as cwd — pin to $HOME. The npm claude shim's #!/usr/bin/env -S node … shebang fails from a GUI; spawn the native binary (~/.local/bin/claude → Bun single-file, Anthropic-signed).claude (override with CLAUDE_CODE_EXECUTABLE); Codex is a Rust binary in @openai/codex-darwin-arm64; Gemini is pure Node. fazm ships Node 22.14 + full node_modules — hundreds of MB, every Mach-O signed.-p/SDK/ACP run on pipes; interactive claude needs a PTY ("Raw mode is not supported… Ink"); codex app-server is TTY-free; codex exec hangs on an open inherited stdin; Gemini --acp won't start with sandbox + non-TTY stdin. For a real terminal use SwiftTerm's forkpty (LocalProcess.startProcess) — its default env omits PATH; node-pty on macOS is posix_spawn + a spawn-helper that shipped without +x.NO_COLOR=1 for Gemini.Process.terminate() = SIGTERM to the direct child only; kill(-pgid) misses grandchildren (MCP servers, claude) that start their own groups → walk pgrep -P depth-first and SIGTERM bottom-up; PPID watchdog in children (poll every 5 s, exit when PPID flips to 1 — "20+ orphan bridges", 14 of 20 survived SIGTERM and needed SIGKILL); or posix_spawn with POSIX_SPAWN_SETSID + killpg (Zed), kqueue EVFILT_PROC NOTE_EXIT for orphan detection.tool_use never gets its tool_result and the API parks forever → drain (SIGHUP path), then exit. SDK: SIGTERM → exit 143 turn unfinished; interrupt()/SIGINT clean.waitUntilExit — >16 KB of output deadlocks child and actor ("caused 200%+ CPU and stuck bridge launch", ACPBridge.swift:779-782). Resume pending continuations in deinit; generation-count terminationHandlers.hasTrustDialogAccepted), Gemini's trusted folders, Codex's git-repo check.~/.claude.json — one CLAUDE_CONFIG_DIR (+ CLAUDE_CODE_PROJECT_DIR_NAME) per hosted session; two processes resuming one session interleave messages.--max-old-space-size=256) and treat exit 133/134/5/6 as OOM.timeouts warmup 240 s (45 s custom endpoint) · per-tool MCP 300 s / Bash 900 s / Task 1800 s / interactive 1800 s
idle-finalize 20 s (check 3 s) · compaction ceiling 180 s · stall flag 15 s · approval gate 300 s
resume sessionId→cwd map ~/.fazm/acp-sessions.json · re-send model after resume
restart credit-exhaustion adapter restart, 30 s cooldown · SIGHUP drain ≤5 min @500 ms · PPID poll 5 s
process detached:true · --max-old-space-size=256 · OOM exit codes 133/134/5/6 · orphan sweep on every start
images flat {type,data,mimeType} · Playwright shots resized to 1920 px · Claude limit 2000 px (use 1568)
mcp env array of {name,value} · command absolute · fazm_tools dials back over $TMPDIR/fazm-tools-<pid>.sock
bashA=~/.claude/skills/snappy-agent-host/api.ts
npx tsx $A doctor # CLIs, versions, node≥22?, adapters, auth state, env hazards (CLAUDECODE, ANTHROPIC_API_KEY)
npx tsx $A spawn-spec --agent claude|codex|gemini # the exact command + env a Swift port must reproduce
npx tsx $A capabilities --agent claude # initialize only: agentCapabilities + authMethods
npx tsx $A prompt --agent claude "reply with exactly: hello" [--cwd DIR] [--permission allow-once|allow-always|reject] [--timeout 300] [--keep-api-key]
npx tsx $A prompt --agent codex --provider openrouter [--model openai/gpt-4.1-mini] "…" # Codex engine, any OpenAI-compatible model; ~/.codex untouched
npx tsx $A login --agent claude|codex|gemini # terminal-auth in THIS terminal (needs a TTY); never touches tokens
prompt = spawn (detached, CLAUDECODE deleted, ANTHROPIC_API_KEY removed unless --keep-api-key, CLAUDE_CODE_EXECUTABLE = your installed claude) → initialize → session/new{cwd} → session/prompt → stream session/update to stderr → answer session/request_permission by kind per policy → return {stopReason, text, toolCalls, usage(_meta.quota), sessionId}; on timeout session/cancel, then kill the tree (group + pgrep -P). It is the reference implementation for the Swift client, line for line.
claude and parse its output" — the TUI needs a PTY and emits Ink frames; use -p --output-format stream-json, the SDK, or ACP.claude -p is a different engine" — the SDK spawns the same claude binary.session/prompt returns tokens" — v1 returns only stopReason; usage is _meta/RFD.session/set_model is spec" — adapter extension; use session/set_config_option.env is an object" — array of {name,value}; command absolute.allow/deny" — agent-defined ids; kinds allow_once|allow_always|reject_once|reject_always; codex-acp fails closed.session/cancel kills the session" — ends the turn; prompt must return cancelled.cancelled on turn cancel.source blocks" — flat {type:"image",data,mimeType}; no PDF block.codex exec prompts for approvals" — forces approval_policy: Never; use app-server or codex-acp."jsonrpc":"2.0" omitted on the wire.@zed-industries/*-acp" — archived; @agentclientprotocol/*.gemini --experimental-acp" — deprecated → --acp; won't start with sandbox + non-TTY stdin.authenticate{methodId:"gemini-api-key"} first.GEMINI_CLI_TRUST_WORKSPACE; Claude blocks on the trust dialog.kill(-pgid) cleans the tree" — grandchildren start their own groups; pgrep -P + PPID watchdog.tool_use, API parks forever; drain first./usr/bin:/bin:/usr/sbin:/sbin.codesign --verify passing means it runs" — macOS 26 CSM SIGKILL; copy the runtime out.ANTHROPIC_API_KEY alongside the login is harmless" — it silently bills the API.references/extract-agent-host.md — §A fazm (cited), §B ACP with message shapes, §C per-agent recipes, §D prior art, §E native-Mac gotchas, §F top 25, full source list.
mcp-server-builder (your own MCP tools for the hosted agent) · snappy-ax · snappy-voice-control · macos-patterns · swift-concurrency · snappy-jcode (lane workers on the Mini) · snappy-dispatch.
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
snappy-ai-models |
Direct-API interface to OpenAI, Anthropic, and Replicate for the Snappy system -- the three model providers... |
snappy-artifact-loop |
Build published Artifacts as I/O devices where the AGENT is the backend, not as static output documents |
snappy-ax |
Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it —... |
snappy-box |
Box server -- self-editing Express server on Mac Mini (Docker, Node 20, 180+ routes) exposing HTTP API for... |
snappy-browse |
THE DEFAULT for actually driving a browser on this machine -- Snappy stack and client sites via agent-brows... |
snappy-client-total |
Jordan Cameron's mortgage adviser CRM for New Zealand -- the largest and most active client engagement |
snappy-deploy |
Meta-deployment skill that orchestrates ALL Snappy project deployments across the four supported platforms... |
snappy-desktop |
macOS desktop automation primitive for the Snappy stack via Midscene vision AI (npx @midscene/computer@1) |
snappy-dom-cartographer |
Master DOM mapping agent for the Snappy swarm |
snappy-gateway |
Snappy Skills Gateway -- publish, gate, and distribute Claude Code skills via skills.snappy.ai (Cloudflare... |
snappy-github |
Centralized GitHub operations across all Snappy client repos via the gh CLI -- pull request creation, cod... |
snappy-gmail |
Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gmail's REST API... |
snappy-image |
Centralized image generation, editing, and capture for Snappy: Nano Banana / Gemini, Grok / xAI edits, gpt... |
snappy-imessage |
iMessage on THIS Mac -- the one holding Messages.app -- through the hand's own verbs (`api.ts send/recent/c... |
snappy-jcode |
Dispatch GPT 5.6 (Luna/Sol) agents as sandboxed lane workers via the local jcode CLI, on this Mac or the Ma... |
snappy-maintenance |
Snappy project maintenance -- keeping all client and internal systems healthy across Vercel, Fly.io, Cloudf... |
snappy-nightshift |
The overnight orchestration operating system: one orchestrator drives a repo toward 100% all night with bui... |
snappy-ops |
The Snappy operator shell |
snappy-os-operator |
Operate SnappyOS like a pro through product doors only: governed connector reads, staged writes with approv... |
snappy-resident |
The non-stop user seat: drive the Snappy OS app as a real user through a real browser (agent-browser), with... |
snappy-session-close |
Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md... |
snappy-shell |
Kernel-loaded fallback runner |
snappy-swarm |
Orchestrate swarms of parallel AI agents for multi-wave quality passes across a project |
snappy-telegram |
Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
snappy-tool-design |
Contract-first ergonomics lint for AI-operated skills, unlike snappy-artifact-loop which manages implementa... |
snappy-update |
Snappy Update -- dev updates to consulting clients |
snappy-voice-control |
Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Agent! by Agenti... |
snappy-walkthrough |
Recipe-driven capture and annotation of step-by-step tutorials |
snappy-watchtower |
Standing error monitors and the probes that lie: arm live typecheck, build, and test monitors at session st... |
snappy-xano-mcp |
THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
---
name: snappy-agent-host
description: "Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable per-folder sessions and every tool call, diff, plan, permission ask, answer, and cancellation printed as paged JSON lines. Also holds the full native SnappyOS.app ACP host research and proven launch constraints. Use for start/prompt/status/cancel/sessions, hosted coding agents, agent client protocol, or showing every agent step instead of a black box. NOT building an MCP server (see mcp-server-builder). Triggers on: ACP, agent client protocol, claude-agent-acp, codex-acp, app-server, host agent, embed CLI, durable agent session, agent steps."
---
# snappy-agent-host — running Claude Code / Codex / Gemini inside a native Mac app
## Skills MCP road (implemented in `api.ts`)
`start {runtime,cwd,prompt,permissions?}` opens or reuses the one durable ACP
session for that real folder. `prompt {session,text,answer?}` continues it and
can answer a pending permission. `status {session,cursor?}` pages the skill's
own JSON-line steps. `cancel {session}` cooperatively cancels and then kills the
full process tree within two seconds. `sessions` lists durable IDs and folders.
`models {runtime}` lists the config options the runtime advertises.
**The model is chosen, visible, and never pinned here.** `start` takes
`--model <id>` and `--effort <level>`; with neither, nothing is set and the
runtime uses the person's own config (codex: `~/.codex/config.toml`). Both are
applied over ACP `session/set_config_option` against whichever option the
runtime advertises under the reserved `model` and `thought_level` categories —
selected **by category, never by a hardcoded id**, so this skill holds no model
list and cannot go stale as runtimes add models. `models <runtime>` is how the
AI sees the choices; the `session` event, `status` and `sessions` report the
model and effort the runtime REPORTS (with `config_from`), separately from what
was `asked` for. Measured 2026-09-08 against the installed adapters: codex-acp
advertises ids `model` + `reasoning_effort`, claude-agent-acp `model` +
`effort` — two different id sets behind the same two categories, which is the
reason the category is the key.
The TypeScript host imports the app-pinned `@agentclientprotocol/sdk@1.4.0`
from SnappyOS.app's existing runtime. The SDK owns ACP v1 framing and method
validation; this skill does not hand-write the protocol and installs nothing.
Claude and Codex use the same bundled adapters as the app:
`claude-agent-acp@0.73.0` and `codex-acp@1.8.0`.
Three constraints are tests, not advice: the requested folder is the session
`cwd`; Claude's `_meta.claudeCode.options.settingSources` includes `project` so
CLAUDE.md loads; and one durable session keys one real folder because resume
outranks any later cwd.
Sources (all cited in `references/extract-agent-host.md`, 678 lines, ~130 URLs): `FAZM=~/projects/fazm` (acp-bridge + Swift side), the ACP v1 spec + schema, `@agentclientprotocol/claude-agent-acp` 0.73.0, `@anthropic-ai/claude-agent-sdk` 0.3.258, `@agentclientprotocol/codex-acp` 1.8.0 / `codex app-server`, `@google/gemini-cli` 0.58.0 `--acp`, and the host apps in §6. Tags: **[docs]** official, **[blog]** third-party, **[issue]** tracker, **[src]** local source.
**The one-paragraph answer:** speak **ACP (Agent Client Protocol) natively from Swift** — NDJSON + JSON-RPC 2.0, `protocolVersion: 1`, ~25 methods — and spawn three adapters as child processes: `@agentclientprotocol/claude-agent-acp` (wraps the Claude Agent SDK, which spawns the real `claude`), `@agentclientprotocol/codex-acp` (wraps `codex app-server`), and `gemini --acp`. You get uniform `tool_call{kind,status}`, `diff`, `terminal`, `plan`, `session/request_permission`, modes, and `usage_update` for free. **Do not build a fazm-style Node bridge** — fazm's bridge exists only because it also ran its own MCP tools and OAuth in Node; the Swift⇄bridge protocol is a second, custom protocol you don't need (`FAZM/acp-bridge/src/index.ts:1-27`; `Desktop/Sources/Chat/ACPBridge.swift:212-277` — Swift never speaks ACP there).
---
## 0a. Swift libraries and precedents (do not hand-roll the protocol) **[docs]**
| | wiedymi/swift-acp | aptove/swift-sdk | rebornix/acp-swift-sdk |
|---|---|---|---|
| License / tag / floor | MIT / v0.1.0 / macOS 12, tools 5.9 | Apache-2.0 / v0.1.16 / macOS 12, Swift 6 | MIT / untagged / macOS 13, Swift 6 |
| Last push (2026-09-02) | 2026-07-24 | 2026-04-25 | 2026-02-07 |
| request_permission / usage_update / terminal / local spawn | ✅ / ✅ / ✅ / ✅ `Process` + shell-PATH resolver | ✅ / ❌ / ✅ / minimal | ❌ (app layer) / ❌ / ❌ / ❌ (FileDescriptor only, iOS) |
**Shipped precedents:** `rebornix/Agmente` (MIT, 540★) — iOS **and native macOS** ACP + Codex app-server client; connects over WebSocket (`@rebornix/stdio-to-ws`), so it never spawns. **Read `references/extract-agmente-rendering.md` (494 lines, cited) before borrowing:** the "high-performance" transcript (ListViewKit + MarkdownView) is UIKit-only — on macOS it falls back to a SwiftUI `LazyVStack` with inline-only markdown; its ACP fold drops `plan`, `usage_update`, `session_info_update`, `diff`/`terminal` content blocks and `locations`; there is no unified-diff renderer. What IS worth copying: the data model (`ChatMessage`/`AssistantSegment`/`ToolCallDisplay`), the chunk/tool-call merge rules and their tests (our oracle, §G of the extract), `ChatEntry`/mapper/diff/height-cache/scroll-policy (platform-neutral), permission bookkeeping + "cancel pending permissions before `session/cancel`", and the Codex `thread/read` hydration merge with JSON fixtures. §H of the extract is the copy/adapt/skip table with a port order. Poolside Desktop Assistant — closed-source native macOS ACP client. Full list: agentclientprotocol.com/get-started/clients. Pick **wiedymi/swift-acp** for a Mac app that spawns adapters locally; port the allowlisted spawn environment from `api.ts` (no library does the hygiene). **Verified 2026-09-02:** `references/swift-acp-probe/` (SwiftPM, ~90 lines) drove a real Claude turn and a Write-tool permission round-trip through it, and the same binary drove `codex-acp` → OpenRouter (plain + Write turns, ~6 s) — build it first when the adapter or the library moves. **Two policy layers:** the host answers `session/request_permission`, but Codex only asks when its own `approval_policy`/`sandbox_mode` (config.toml) say so — a workspace write under `workspace-write` never reaches the host. Set Codex's config per session to match the posture you want. **swift-acp 0.1.0 ceilings, found building P0 for SnappyOS (2026-09-02):** (1) it never exposes the child pid/pgid and its stderr reader DISCARDS every byte (`ProcessManager.startReadingStderr`; `stderrLines()` is main-only) — front every adapter with a `/bin/sh` shim: `printf "%s" "$$" > <pgid-file>; exec <argv> 2>> <stderr-log>` (exec keeps the pid, so `$$` is the group leader; exit 127 = not installed); (2) `ACPProcessManager.launch` MERGES your env on top of `ShellEnvironment.loadUserShellEnvironment()` — an allowlist is not enough, actively blank `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `CLAUDECODE`, `CLAUDE_CODE_CHILD_SESSION` with `""`; (3) it writes its own registry at `~/Library/Application Support/ACP/acp-processes.json`, not disableable; (4) stdout is read on GCD via `Pipe.readabilityHandler` and the transport is not injectable — Zed's ≥4 MB reader-thread rule cannot be applied without vendoring the transport. If any of these bite, vendor the transport in-tree; do not move to an untagged commit. `PermissionOutcome(optionId:)` is a struct, not an enum. Reference implementation with 38 tests: `snappy-os-app` branch `acp-host-p0` (`Launch/AgentHost*.swift`, `Tests/SnappyOSTests/AgentHostTests.swift`).
---
## 0b. Rules that cost other hosts real bugs (Emdash, Gold Band, Jockey, Inspector, Agmente — `references/desktop-acp-clients.md` §I, `agmente-test-oracle.md`)
- **Fold:** status `undefined` = unchanged; a `tool_call_update` for an unknown id SYNTHESIZES the row; repeated `tool_call` with the same id mutates one row; force-settle every `running` tool on turn close (claude-agent-acp #1061 keeps emitting tool starts after `session/cancel`); missing `messageId` → synthesize `auto:<stream>:<n>`; open thinking auto-finalizes when content arrives; a ~250 ms quiescence timer opens/settles agent-initiated turns (#864).
- **Permissions:** the prompt body IS the already-rendered tool row; `pendingPermissions` lives on the session model (one source for band, sidebar, row); default button `allow_once`; "always allow" only as an LRU written when the user picked `allow_always` (Jockey) — never inferred; no host-side bypass flag in Emdash/Gold Band/Jockey — risk rides the ACP mode selector; pass `settingSources: []` / an explicit initial mode (#1056: `~/.claude/settings.json` silently overrides the model too).
- **Process:** env from an ALLOWLIST, never the whole environment; PATH from a login shell + `~/.local/bin`, `~/.cargo/bin`, `~/.volta/bin`, every `~/.nvm/versions/node/*/bin`, `/opt/homebrew/{bin,sbin}`, `/usr/local/{bin,sbin}`; `POSIX_SPAWN_SETPGROUP`, write the pgid to a file and kill it on next launch (Gold Band; Emdash #2153 = 240 descendants / 21 GB); race `initialize` against process death with the stderr tail in the error; idle watchdog on every await (#1023: 26-minute hang).
- **Sessions:** `session/resume` → `load` → `new`, and say which happened; persist a versioned INTENT, never the environment or MCP credentials; pool one process per (provider, cwd) and fence routes by generation; queue prompts instead of rejecting them.
- **MCP:** `env` is `[{name,value}]`; gate http/sse on advertised `mcpCapabilities`; assert your server appears in `tools/list` (#883 live bug).
- **Auth:** detect authenticated / unauthenticated / unknown; delegate login to the CLI; pre-seed project trust visibly (`.claude.json` `hasTrustDialogAccepted`).
- **Zed (the reference client, `references/zed-acp-client.md`):** target `protocolVersion: 1` and give every wire enum an `unknown(String)` case (crate 2.0.0 is an SDK version, not protocol 2); **`tool_call_update.content` is a full snapshot and tool-call text is the accumulated string — only `agent_message_chunk` is a delta** (v1 has no append primitive); register the session id BEFORE sending `session/load` — the transcript replays as notifications before the response; run the stdio reader on a `Thread` with ≥4 MB `stackSize`, not GCD (512 KiB overflows on the first message); spawn through `$SHELL -c` and parse exit 127 as "not installed"; `setsid()` + `killpg(SIGKILL)` plus the launch-time pgid reaper Zed lacks (zed#61303: 55 orphans); model 7 client tool states, not the wire's 4; distinguish cancelled from superseded-by-follow-up; terminals: keep the TAIL under `outputByteLimit` (spec; Zed keeps the head), `PAGER=""`, `GIT_PAGER=cat`, `exec </dev/null` or the first `git log` hangs, buffer output that arrives before `terminal/create` is registered; do not advertise `terminal: true` until the PTY exists; `_meta` keys namespaced (`_snappy.*`); build a `FakeAgentConnection` before any UI; watchdog on `initialize`/`session/new`, none on `session/prompt`. Zed treats an unknown permission kind as allow; we treat it as reject unless auto-approve is on.
- **Found building P1 for SnappyOS (2026-09-02, all live-verified on the Mini):** a per-session `CLAUDE_CONFIG_DIR` STARVES the login — `CLAUDE_CONFIG_DIR=<empty dir> claude -p` → "Not logged in", which over ACP is `-32000` — **symlink the user's `~/.claude/.credentials.json` into the session dir, never copy it**, and pre-seed project trust there; AF_UNIX `sun_path` is 104 bytes — keep `$SNAPPY_STATE_ROOT/agent-host.sock` under ~100 chars or `bind` fails; **MCP presence is not client-observable at ACP v1 / swift-acp 0.1.0** (no `tools/list`; `mcpCapabilities` flags only optional transports; the adapter's `mcpServerStatus` never crosses the wire; `available_commands_update` is slash commands only) — the only provable signal is your own MCP child announcing itself, so do the presence check on the runtime side; bundle adapters with `npm i --omit=optional` (518 MB → 56 MB: drops the vendored `@openai/codex-darwin-arm64` and `@anthropic-ai/claude-agent-sdk-darwin-arm64` CLIs) and point them at the user's own binaries via `CLAUDE_CODE_EXECUTABLE` / `CODEX_PATH`; the pgid reaper needs an INDEX of session roots when sessions live under per-run workspaces (a state-root sweep finds nothing). Reference: `snappy-os-app` branch `acp-host-p0` (`Launch/AgentHostSocket.swift`, `AgentHostTurnTests`, `scripts/embed-acp-adapters.sh`; 85 tests + 2 live).
- **Billing is three states, never one number:** Claude reports `total_cost_usd` / ACP `usage_update.cost` on a SUBSCRIPTION login too — it is an estimate at API rates, not a charge. Record `mode: subscription|api|unknown` (Swift knows: keys stripped + CLI login = subscription; provider key kept = api), `estimatedAmount`, `chargedAmount` (0 under subscription, null when unknown — Codex publishes no cost), `coveredBy`. Never render an estimate as a charge; never render null as $0.00. Robert's rule 2026-09-02.
- **Acceptance lessons (SnappyOS, 2026-09-02, one hosted Claude turn end to end on the Mini):** reap the host process after settle unless a REAL resume key (room/thread, not run id) will reuse it — otherwise one adapter + one `claude` leak per turn; coalesce `usage_update` → one `RUN_METADATA` per turn boundary, not one per update (8 sidecar lines per short turn otherwise); name the step by the SETTLED tool title (arrives on `request_permission`), not the transient `tool_call` title ("Preparing file…"); check `usage_update` on `done` — `tokensSize` came back as an output count, not the context window; the adapter spawns `claude --allow-dangerously-skip-permissions --setting-sources=user,project,local` — pin `settingSources: []`; a machine where nobody ever opened the app window can never produce a boot beacon — give headless installs a launch knob that opens the window or a documented gate override; run installs inside the console session (`sudo launchctl asuser <uid> …`) so the login keychain is already unlocked; `SNAPPY_STATE_ROOT` must be injected by the app for ANY app-spawned runtime, not only one inside `/Contents/Resources/`.
- **Leak fix, verified (2026-09-02, branch `acp-host-p0` @ 164f56767, 94 tests + 2 live turns):** a turn reaps its own process group on `done` unless `session_key ≠ run_id` (a room key) — then a 10-minute idle ceiling reaps it; `terminateAllProcessGroups()` on app quit; terminal `tokensSize` = the last STREAMED window (`null` if none streamed — never the response's output count); `RUN_METADATA` = first `usage_update` + one per 2 s + one on `done` (4 per 13-s turn); `_meta.claudeCode.options.settingSources = []` on `session/new` (swift-acp 0.1.0: build `NewSessionRequest` yourself and `Client.sendRequest`) — the `claude` child then shows `--setting-sources=` empty; the session-root index prunes dead roots on write and tests must inject their own state root. Two install traps: with `/Applications/SnappyOS.app` moved aside a late gate failure rolls back to NOTHING (`APP_INSTALL_HAD_STABLE=false`) — leave the app in place; and to make the app open its window headlessly, quit it, `rm -rf ~/Library/Saved\ Application\ State/ai.snappy.os.savedState`, relaunch — a beacon lands ~25 s later. Unaddressed runs are claimed by ANY computer on the deployment (the MacBook took one and settled it `runtime_unavailable`); locality is not targeting.
- **Debug view:** tap the NDJSON stream with a transform, fold stderr + process exit into the same message union (Inspector); nobody ships export, replay, or pending-request latency — open ground.
## 0c. Hosting inside SnappyOS.app (`references/snappy-os-app-seams.md`, 835 lines, this product)
The product already spawns real `claude`/`codex` as batch runtimes and folds their output into AG-UI frames, run steps and receipts; permissions are off only because of two literal flags (`--dangerously-skip-permissions`, `approval_policy = "never"`). `actionExecutionPolicy` → `stage_only|approve_each|delegated|no_grant` is a 1:1 map onto ACP permission kinds; `snappyMcpSpec` is already the `mcpServers` shape. **Rule:** Swift owns the process and the protocol; the run's identity, launch receipt, approvals and Activity stay in the Node runtime; emit the existing AG-UI vocabulary (new frames only for permission, plan, diff, terminal, usage); sessions under `SNAPPY_STATE_ROOT`, never a third root; no silent runtime fallback (the launch door refuses it). Verified: Codex shell tools cannot reach `127.0.0.1:3147` under its sandbox (`000 exit=7`), Claude can. Plan: `~/projects/snappy-os-app/research/ACP-HOST-PLAN.md` (v3).
---
## 0. Decision tree
```
Host a coding agent in SnappyOS.app?
├─ Want Claude + Codex + Gemini behind ONE client → ACP from Swift, three adapters (§2, §3) [recommended]
│ ├─ Claude: claude-agent-acp; need raw SDK events (cost, rate_limit, compact)? → _meta.claudeCode.emitRawSDKMessages
│ ├─ Codex: codex-acp (ACP) or codex app-server directly (OpenAI's own desktop surface; real approvals)
│ └─ Gemini: gemini --acp + authenticate{methodId:"gemini-api-key"} + GEMINI_CLI_TRUST_WORKSPACE=true
├─ Claude only, need canUseTool.updatedInput / maxBudgetUsd / spawnClaudeCodeProcess → Claude Agent SDK in bundled Node (§3.1b)
├─ Zero policy ambiguity about the user's subscription → drive the interactive `claude` in a PTY (Piebald's road; screen-scraping)
└─ Fire-and-forget batch, no approvals → `claude -p --output-format stream-json` / `codex exec --json` / `gemini -p --output-format stream-json`
```
Before any of it: **§5 auth policy** and **§7 native-Mac traps** — that's where shipping fails.
---
## 1. fazm's topology (what "runs the real Claude Code in a Mac app" actually is) **[src]**
```
Fazm.app (Swift) ──Pipe()s, custom newline-JSON──▶ bundled node acp-bridge/dist/index.js
└─▶ node patched-acp-entry.mjs = @agentclientprotocol/claude-agent-acp 0.29.2 (ACP over stdio)
└─▶ @anthropic-ai/claude-agent-sdk 0.2.112 spawns the `claude` CLI
└─▶ MCP servers the SDK spawns: fazm_tools (node), playwright, macos-use, whatsapp, google-workspace
└─▶ (lazy) node_modules/@zed-industries/codex-acp-darwin-arm64/bin/codex-acp ← Codex, ACP
└─▶ (lazy) node bundle/gemini.js --experimental-acp ← Gemini, ACP
└─▶ unix socket $TMPDIR/fazm-tools-<pid>.sock ← the app's own MCP tools dial BACK to reach Swift
```
- Three processes deep for Claude; every spawn is `process.execPath` (the bundled Node) by absolute path — **no PATH lookup ever** (`index.ts:1557-1585`).
- Env: **deletes `CLAUDECODE`** ("without this, `--resume` silently fails when Claude Code detects it's being launched from inside another Claude Code session"), sets `NODE_NO_WARNINGS=1` (`index.ts:1558-1567`). All three agents spawned `detached:true` so `kill(-pid)` reaches the group (`:1185-1210`).
- Its own tools are an MCP stdio server listed in every `session/new` — spawned by the *agent*, so it dials back over a Unix socket; a `tools/call` becomes `tool_use` to Swift, Swift answers `tool_result` (`index.ts:2495-2523`, `fazm-tools-stdio.ts:55-209`). **With the SDK road this is replaced by in-process tools** (`createSdkMcpServer`, §3.1b).
- Local checkout (2026-07-29) **auto-approves every permission request** (`allow_always → allow_once → literal "allow"`, `index.ts:1616-1628`). Upstream (v2.9.89, 2026-09-02) added `approval-gate.ts` with `FAZM_APPROVAL_MODE off|destructive|always`, 300 s timeout — and had to force `session/set_mode default` and `settingSources: []` because **the user's own `~/.claude/settings.json` (`defaultMode: bypassPermissions`, `permissions.allow`) was silently starving the gate** (upstream `index.ts:1940-1951, 3029-3038`).
- Swift renders **no** `diff`, `terminal`, or permission options — `ToolCallStatus` is only `.running|.completed` (`ChatProvider.swift:276-279`). That's the ceiling you'd inherit by copying it.
---
## 2. ACP — enough to implement a client **[docs]** agentclientprotocol.com (v1 pages under `/protocol/v1/*`)
- **Transport:** client spawns the agent; NDJSON, one JSON-RPC 2.0 object per line, "MUST NOT contain embedded newlines"; agent stdout is ACP-only, stderr is free for logs. Absolute paths, 1-based lines, camelCase keys, snake_case discriminators, `_meta` everywhere, `_`-prefixed custom methods.
- **Errors:** `-32700/-32600/-32601/-32602/-32603` standard, **`-32800` request cancelled, `-32000` authentication required**, `-32002` resource not found. claude-agent-acp sometimes wraps a 401 as `-32603` with `/401|failed to authenticate/` in the message (`index.ts:1802-1811`) — spec says `-32000`.
- **Methods:**
| Side | Method |
|---|---|
| agent | `initialize`, `authenticate`, `logout`, `session/new`, `session/load`, `session/resume`, `session/list`, `session/close`, `session/delete`, `session/prompt`, `session/set_mode`, `session/set_config_option`; notification `session/cancel` |
| **client (you)** | `session/request_permission` (request), `session/update` (notification — **but claude-agent-acp can send it WITH an id; ack it** `index.ts:1629-1633`), `fs/read_text_file`, `fs/write_text_file`, `terminal/create|output|wait_for_exit|kill|release`, `elicitation/create` |
| either | `$/cancel_request` |
- **`session/set_model` and `session/fork` are NOT spec** — adapter extensions (Claude: `session/set_model`, `unstable_forkSession`; Gemini: `unstable_setSessionModel`). The spec road is `session/set_config_option` with a `model` category (B.2, B.10).
- **initialize:** `{protocolVersion:1, clientCapabilities:{fs:{readTextFile:false,writeTextFile:false}, terminal:false, auth:{terminal:true}}, clientInfo:{name,title,version}}` → `{protocolVersion, agentCapabilities{loadSession, promptCapabilities{image,audio,embeddedContext}, mcpCapabilities{http,sse}, sessionCapabilities{list,resume,close,delete,…}}, agentInfo, authMethods[]}`. Advertise `fs:false` unless you are an editor (it's for unsaved buffers); `terminal:true` means **you** own every shell command's PTY (§7).
- **`authMethods`**: `{id,name}` (call `authenticate{methodId}`) **or `{type:"terminal", id, args, env}`** → "the client runs the configured agent program as a separate interactive process for the user to authenticate via a TUI… zero exit status signals success… MUST NOT pass this method to `authenticate`" — this is how Claude subscription login is exposed (§5). Terminal methods are advertised **only if you set `clientCapabilities.auth.terminal`**.
- **session/new** `{cwd (absolute), mcpServers:[{name, command (absolute), args[], env:[{name,value}]}]}` — `env` is an **array**, not an object; http entries `{type:"http", name, url, headers:[{name,value}]}`. Returns `{sessionId, modes?, configOptions?}`. Expect an `available_commands_update` **immediately, before your per-session handler exists** (`index.ts:1700-1741`). A hung MCP spawn hangs `session/new` for the whole timeout (`:3203-3222`).
- **session/prompt** `{sessionId, prompt:[{type:"text",text} | {type:"image", data, mimeType} (FLAT — not Anthropic's `source`) | {type:"resource", resource:{uri,text|blob,mimeType}} | resource_link]}` → **`{stopReason, _meta}` only** — `end_turn | max_tokens | max_turn_requests | refusal | cancelled`. **No tokens/cost in v1**; adapters put per-turn usage in `_meta` (Claude 0.71+: `_meta.quota{input_tokens, output_tokens, cache_*, model_usage}`; Gemini: `_meta.quota.token_count`). There is **no PDF/document block** — point the agent at the path.
- **`session/update` variants (11):** `user_message_chunk` / `agent_message_chunk` / `agent_thought_chunk` `{content, messageId?}` (messageId change = new message); `tool_call {toolCallId, title, kind: read|edit|delete|move|search|execute|think|fetch|switch_mode|other, status: pending|in_progress|completed|failed, content?[], locations?, rawInput?, rawOutput?}`; `tool_call_update` (only changed fields); `plan {entries[{content, priority, status}]}` (replace whole plan); `available_commands_update`; `current_mode_update`; `config_option_update`; `session_info_update`; `usage_update {used, size, cost?}` (cumulative). `ToolCallContent` = `{type:"content"}` | **`{type:"diff", path, oldText, newText}`** | **`{type:"terminal", terminalId}`** — render all three (fazm flattens to text).
- **Permission (agent→client request):** `{sessionId, toolCall{toolCallId,title,kind,status:"pending"}, options:[{optionId, name, kind: allow_once|allow_always|reject_once|reject_always}]}` → `{outcome:{outcome:"selected", optionId}}` or `{outcome:{outcome:"cancelled"}}`. **Option ids are agent-defined strings — never hard-code `"allow"`**; codex-acp fails closed on unadvertised ids. Only two outcomes exist. **Cancel ≠ reject** (Claude's permission extension).
- **Cancel:** `session/cancel {sessionId}` (notification) ends the **turn, not the session**; the prompt "MUST" return `stopReason:"cancelled"`; you "MUST respond to all pending `session/request_permission` requests with the cancelled outcome". It is cooperative — a wedged tool subprocess will not die (fazm SIGKILLs playwright children, `index.ts:143-198`). `$/cancel_request {requestId}` cancels any single request.
- **Slash commands** are just prompts: send `/compact` as text (`protocol.ts:182-186`).
- **Terminal capability:** `terminal/create {command,args,env,cwd,outputByteLimit}` → `{terminalId}`; `terminal/output` → `{output, truncated, exitStatus?}`; `wait_for_exit`; `kill`; `release`. Advertise `true` and every agent shell command is a PTY you own and can render in SwiftTerm.
- **Full turn:** spawn → `initialize` → (auth) → `session/new` → `session/prompt` → stream of `session/update` → `session/request_permission` → `tool_call_update{completed, content:[diff|terminal|content]}` → `usage_update` → result `{stopReason}`. Debug with **ACP Inspector** (github.com/newioapp/acp-inspector).
- **SDKs:** TypeScript `@agentclientprotocol/sdk` 1.4.0 (`ndJsonStream`, `client({name}).onRequest(...).connectWith(stream, …)`), Rust crate (powers Zed). **No Swift SDK** — write the ~300-line client. `@zed-industries/*` packages are archived redirects.
---
## 3. Per-agent recipes
### 3.1a Claude via `@agentclientprotocol/claude-agent-acp` 0.73.0 (Node ≥ 22) **[docs]** repo README, `src/acp-agent.ts`
- Spawn `node …/claude-agent-acp/dist/index.js` (or `npx -y @agentclientprotocol/claude-agent-acp@0.73.0`), `stdio: pipe`. Flags: `--cli <args…>` forwards to the bundled `claude` (login), `--hide-claude-auth`. Env: **`CLAUDE_CODE_EXECUTABLE=<user's claude>`** so the user keeps ONE login (Emdash's pattern), `CLAUDE_AGENT_LOGS=<dir>`, `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN`, Bedrock/Vertex vars. All `console.*` go to stderr.
- It calls `query()` with `includePartialMessages:true`, `settingSources:["user","project","local"]`, `systemPrompt:{type:"preset",preset:"claude_code"}` (override via `_meta.systemPrompt`), `canUseTool`, `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1`.
- Capabilities it returns: `promptCapabilities{image, embeddedContext}` (**no audio**), `mcpCapabilities{http, sse}`, `loadSession`, `sessionCapabilities{additionalDirectories, close, delete, fork, list, resume, subagents}`, `_meta.claudeCode.promptQueueing`, `_meta.steering` (`_session/steering` injects into a running turn). `authMethods` (terminal type): `claude-ai-login` (`args:["--cli","auth","login","--claudeai"]`), `console-login`; `authenticate()` accepts only gateway ids — anything else "Method not implemented".
- Modes: `default` (Manual), `acceptEdits`, `plan`, `auto`, `bypassPermissions` (disabled as root). Config options: mode, model, effort, fast-mode.
- **`_meta.claudeCode.options`** on `session/new` forwards raw SDK `Options` (merges `hooks, mcpServers, disallowedTools`); **`_meta.claudeCode.emitRawSDKMessages: true | [filter]` streams every raw SDK message as `_claude/sdkMessage`** — the supported replacement for fazm's monkey-patch (cost, `rate_limit_event`, `compact_boundary`, tasks). `usage_update` from `modelUsage`; per-turn `PromptResponse._meta.quota` since 0.71.
- Extensions: `session/resume|close|delete`, `listSessions`, `unstable_forkSession`, `logout` (runs `claude auth logout`), `_session/async_task/stop`. Permission extension `_meta.permission{title,description}`, fixed ids `allow-once`, `allow-with-updates`, `exit-plan-*`, `reject`; opt-in session-failure extension surfaces usage-limit/auth failures instead of a silent `end_turn`.
- Block SDK tools you can't service or you get "silent end-of-turn dead-ends": `ScheduleWakeup, CronCreate/Delete/List, RemoteTrigger, Monitor, PushNotification` via `disallowedTools` (`index.ts:2948-2964`).
- Known issues: #421 "does not support using claude.ai subscriptions" (often `--hide-claude-auth`), #744 stale `ANTHROPIC_API_KEY` in settings, #880 `session/new` blocked ~100 s by `getContextUsage()` through a gateway, #630 `session/prompt` may never resolve → race it against an idle timer (fazm: 20 s idle, 180 s during compaction).
### 3.1b Claude Agent SDK directly (`@anthropic-ai/claude-agent-sdk` 0.3.258) **[docs]** code.claude.com/docs/en/agent-sdk/*
- `query({prompt, options}) → AsyncGenerator<SDKMessage>`; `tool()` + `createSdkMcpServer()` = **in-process tools** (no separate MCP process, no socket relay); `Query` controls `interrupt(), setPermissionMode(), setModel(), getContextUsage(), accountInfo(), rewindFiles(), streamInput(), close()`.
- Key options: `permissionMode default|acceptEdits|bypassPermissions|plan|dontAsk|auto` (bypass needs `allowDangerouslySkipPermissions:true`), **`canUseTool`** (`{behavior:'allow', updatedInput?, updatedPermissions?} | {behavior:'deny', message, interrupt?}`; order Hooks → deny → ask → mode → allow → callback; "can stay pending indefinitely"), `mcpServers` (stdio|sse|http|sdk), `resume/forkSession/resumeSessionAt`, `settingSources` (CLAUDE.md loads only via this), `systemPrompt {type:'preset', preset:'claude_code'}` (default is minimal!), `maxBudgetUsd`, `env` (**replaces** the environment — spread `process.env`), **`spawnClaudeCodeProcess`** (own the spawn from Swift), `pathToClaudeCodeExecutable`, `executable: 'bun'|'node'`.
- Messages: `system/init{session_id, apiKeySource, model, permissionMode, tools, mcp_servers}`, `assistant`, `user{tool_use_result}`, `stream_event`, `system/compact_boundary`, `rate_limit_event{five_hour|seven_day, utilization, resetsAt}`, `task_started/notification`, `result{subtype, total_cost_usd (running total per session), usage, modelUsage{…costUSD, contextWindow}, num_turns, permission_denials}`.
- Process model: "One agent session maps to one subprocess"; "1 GiB RAM, 5 GiB disk, 1 CPU per agent"; `npm ci --omit=optional` drops the bundled binary. **The SDK↔CLI wire** (`claude -p --input-format stream-json --output-format stream-json --permission-prompt-tool=stdio` + `control_request{can_use_tool|hook_callback|…}`/`control_response`) is what Vibe Kanban drives from Rust with no Node — a Swift app could too, but the framing has no docs page.
- Sessions: `~/.claude/projects/<cwd, non-alnum→'-'>/<id>.jsonl`; since CLI 2.1.223 `--resume` searches other projects too (retires fazm's cwd-migration machinery). One **`CLAUDE_CONFIG_DIR` per hosted session** or concurrent instances corrupt `~/.claude.json` (issues #28847 #28922 #3117…).
### 3.2 Codex **[docs]** learn.chatgpt.com/docs/app-server, codex-rs/app-server/README
| Surface | Approvals | Use |
|---|---|---|
| **`codex app-server`** (stdio; `--listen ws://` / `unix://`) | **yes** — `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval` | What OpenAI's own desktop + VS Code use. `initialize{clientInfo}` → `initialized` → `thread/start{model, cwd, approvalPolicy, sandbox}` → `turn/start{threadId, input[]}`; streams `item/*` deltas, `turn/diff/updated`, `turn/plan/updated`, `thread/tokenUsage/updated`. **`"jsonrpc":"2.0"` is omitted on the wire.** Auth: `account/login/start{type:"chatgpt"}` → `{authUrl}`; `account/rateLimits/read`. "experimental" per docs. |
| **`@agentclientprotocol/codex-acp` 1.8.0** | yes → ACP `session/request_permission` (`accept`→allow_once, `acceptForSession`→allow_always, decline→reject_once; unknown ids fail closed) | If the app speaks ACP for every agent. Env `CODEX_API_KEY`/`OPENAI_API_KEY`, `CODEX_PATH`, `CODEX_CONFIG` (JSON), `INITIAL_AGENT_MODE read-only|agent|agent-full-access`. Its real error text is **ANSI-coloured on stderr** ("Internal error" on the wire; `codex-provider.ts:82-86,259-274`). |
| `codex exec --json` / `@openai/codex-sdk` | **no** — approvals auto-rejected | batch only. Hangs at 0% CPU if stdin is an inherited-but-never-closed pipe → `< /dev/null` (#20919). |
`@zed-industries/codex-acp` is **archived**. **Any OpenAI-compatible provider works** via `config.toml` `[model_providers.<id>] base_url / env_key / wire_api = "responses"` (codex ≥ 0.152 rejects `"chat"`); `api.ts --provider openrouter` writes an isolated `CODEX_HOME` doing exactly that — verified live through OpenRouter when the ChatGPT quota was at 0. Auth file `~/.codex/auth.json` or keyring; a respawn is needed after login because the subprocess won't re-read it (`index.ts:1418-1428`). `thread/start` with a writable sandbox **marks the project trusted in `config.toml`**.
### 3.3 Gemini CLI 0.58.0 **[docs]** docs/cli/acp-mode.md, packages/cli/src/acp/*
- Spawn `gemini --acp` (`--experimental-acp` deprecated). Env: `GEMINI_API_KEY`, **`GEMINI_CLI_TRUST_WORKSPACE=true`** ("silently skips MCP server registration when the workspace isn't trusted"), `GEMINI_SANDBOX=false`, `NO_COLOR=1`, `GEMINI_TELEMETRY_ENABLED=false`.
- `initialize` → `authMethods` `oauth-personal | gemini-api-key | vertex-ai | gateway`; **you must `authenticate {methodId:"gemini-api-key"}` or `session/new` fails `-32000`**. OAuth-personal "requires an interactive browser flow that's hostile to a background subprocess" — refuse (`gemini-provider.ts:74-86`). Modes `default|auto_edit|yolo|plan`. Per-turn tokens in `_meta.quota.token_count`.
- Refuses to start with sandbox on and non-TTY stdin (#23959); once emitted `session/update` under a mismatched sessionId (`gemini-provider.ts:88-106`). Sessions `~/.gemini/tmp/<hash>/chats/`.
---
## 4. Sessions, cancel, lifecycle (the incidents behind the rules) **[src]**
- Bank the session id **before** the first prompt so a rate-limit on turn 1 doesn't orphan the conversation (`protocol.ts:560-580`).
- Resume was cwd-addressed: different cwd → "Resource not found"; fazm persisted `sessionId→cwd`, pre-checked the JSONL, and physically moved transcripts between project dirs (`index.ts:2000-2030, 3292-3387`). CLI ≥ 2.1.223 searches across projects — prefer that.
- After resume **re-send the model** or you get the SDK default ("possibly Haiku") (`index.ts:3524-3526`).
- A mid-thinking cancel leaves an unsigned thinking block → 400 on reuse (`index.ts:5076-5090`). Resuming a cancelled session replayed stale chunks until adapter 0.29.2 (ACP #442).
- `session/close` every warm session you abandon — "structural cause of the CPU regression 2026-05-14"; a cwd change left "an orphaned claude SDK process at 70–90% CPU forever" (`index.ts:3806-3818, 6940-6947`).
- Credit exhaustion **poisoned other sessions** on the same adapter process (0 ms `end_turn`) → restart the whole adapter, 30 s cooldown; classify by HTTP status first because the SDK tags 529 as `rate_limit` (`api-failure.ts:14-30`; incident 2026-05-14).
- `acpRequest` doesn't observe AbortSignal → race the prompt against abort + idle + TTFT watchdogs, or a Stop during a 77 s Terminal tool hangs forever (`index.ts:4634-4670`; "May 5 2026 incident").
- Never clear watchdogs on text chunks — text streams while tools are in flight (`index.ts:5625-5633`).
- Screenshots >2000 px hit Claude's image limit on Retina; resize to 1568 px (`index.ts:1830-1832`).
---
## 5. Auth policy — the part that decides shippability **[docs]** code.claude.com/docs/en/{authentication,legal-and-compliance,env-vars}, support.claude.com/…/15036540
- Precedence: Bedrock/Vertex env → `ANTHROPIC_AUTH_TOKEN` → **`ANTHROPIC_API_KEY` ("in `-p`/SDK mode the key is always used when present" — it silently bills the API instead of the subscription)** → `apiKeyHelper` → `CLAUDE_CODE_OAUTH_TOKEN` (`claude setup-token`, 1 year) → profile → subscription OAuth. fazm strips `ANTHROPIC_API_KEY` from the child env in personal mode (`ACPBridge.swift:2367-2369`).
- Storage: macOS Keychain item `Claude Code-credentials` (falls back to `~/.claude/.credentials.json` 0600 when the Keychain rejects the write, e.g. SSH); `CLAUDE_CONFIG_DIR` keys a separate Keychain entry. The item's partition list is `apple-tool:` — **a differently-signed Swift app reading it gets the Keychain prompt, and "Always Allow" resets on every token refresh** (TN3137; CodexBar #624). Rule: **let the `claude` binary own its credential; never read or write it from Swift.**
- **What Anthropic forbids:** "developers may not collect, store, or intermediate Claude.ai credentials or session tokens"; "Anthropic does not allow third party developers to offer claude.ai login… for their products". **What is allowed:** "an end user signing in to the unmodified Claude Code binary with their own Claude subscription, including where a platform hosts Claude Code" — binary unmodified, no auth method removed, each user their own credential, no reselling, "runs Claude Code" in plain text, no logo. **fazm's own PKCE flow against `claude.ai/oauth/authorize` writing the Keychain item (`oauth-flow.ts:24-31, 380-420`) is exactly the forbidden pattern.** The compliant road = the terminal-auth method: spawn `claude-agent-acp --cli auth login --claudeai` in a real PTY and wait for exit 0 (Zed, Emdash do this).
- The 2026-06-15 "Agent SDK credits" split (Pro $20 / Max 5x $100 / Max 20x $200) was **paused the same day**: "nothing has changed: Claude Agent SDK, `claude -p`, and third-party app usage still draw from your subscription's usage limits." Conductor stayed on the SDK; Piebald moved to driving the interactive TUI to be safe. Branding for SDK products: "Powered by Claude", not "Claude Code".
- Codex: `~/.codex/auth.json` ("treat like a password"), app-server `account/login/start` runs the browser flow for you — no hand-rolled OAuth needed. Gemini: API key; OAuth-personal is not headless-safe.
- Env hygiene: delete **`CLAUDECODE`** (nested-session guard) and `CLAUDE_CODE_CHILD_SESSION`; set `DISABLE_AUTOUPDATER=1`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, `DISABLE_TELEMETRY=1` (any non-empty value, even `0`, turns these ON), `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` (also skips the background title-model call), `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1` (strips creds from Bash/hook/MCP children; 2.1.251 also stripped `CLAUDE_CONFIG_DIR`).
---
## 6. What the other hosts learned **[docs]/[blog]**
| Host | Road | Lesson |
|---|---|---|
| **Zed** | ACP, Rust | The reference client. Spawns registry agents with its **own managed Node** (v24) into `~/Library/Application Support/Zed/external_agents/`; `setsid` in `pre_exec`, `killpg(SIGKILL)`; resolves PATH via `<shell> -l -i -c … --printenv` under `setsid`. Its bug tracker previews yours: 55 orphaned agent processes / 3.1 GB (#61303), archived sessions keeping MCP servers alive (#56747), usage-limit exit 143 dead session (#55501), permission prompts never rendering (#62788). |
| **Emdash** | ACP + node-pty, Electron | Closest OSS analogue. `CLAUDE_CODE_EXECUTABLE: <host claude>` so the user keeps one login; pre-seeds `~/.claude.json` `projects[<path>].hasTrustDialogAccepted=true` so the trust dialog never blocks; installs marker-tagged hooks; ships `app-sandbox=false`, `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`. |
| **Conductor** | Claude Agent SDK, Tauri+Rust | "Conductor uses native Claude Code… through the Claude Agent SDK"; each task = worktree + branch + terminal + diff. Per-agent auth is the CLI's own. |
| **Piebald** | interactive `claude` over PTY | "we now run Claude Code interactively in the background without relying on the Agent SDK or `claude -p`" — zero policy ambiguity, at the cost of screen-scraping; reimplements the hooks contract; persists pending approvals across reboots. |
| **Vibe Kanban** | SDK wire from Rust, no Node | `claude -p --permission-prompt-tool=stdio --input-format stream-json …` + `control_request/control_response` — proof a non-Node host can drive Claude's own protocol. |
| **CodeLayer/hld** | Go daemon | Approvals via `--permission-prompt-tool mcp__approvals__request_permission` — the pre-SDK way to get a callback out of `claude -p`. |
| **Happy Coder** | PTY + SDK | launchd agents outside the Aqua session **can't reach the Keychain → 401**; Ink leaves stdin `O_NONBLOCK`; `CLAUDE_CODE_ENTRYPOINT=sdk-ts` hides sessions from `--resume`. |
| **Claude Desktop** (Anthropic) | first-party | Worktree per session at `.claude/worktrees/`; "reads your shell profile… to extract PATH" — even Anthropic recovers PATH from the shell. |
Converged: nobody re-implements the agent loop; every desktop host isolates sessions with **git worktrees** (and hits "worktree lacks `.env`/`node_modules`"); approvals come from the protocol, never from scraping the TUI (except Piebald); the two auth postures are "the CLI logs itself in" (compliant) vs "we intermediate the token" (fazm).
---
## 7. Native Mac app traps (E.1–E.14)
1. **App Sandbox is off the table**: children inherit the sandbox and must be signed with exactly `app-sandbox`+`inherit`; the user's `claude` isn't. fazm and Emdash ship `app-sandbox=false` → **no Mac App Store**; Developer ID + notarization.
2. **Hardened runtime + entitlements**: every embedded Mach-O (node, `*.node`, dylibs, `rg`) needs `--options runtime --timestamp`; node needs `cs.allow-jit` (+ `allow-unsigned-executable-memory`); Python/addons `disable-library-validation`; keep `.pyc` out of the bundle (`PYTHONDONTWRITEBYTECODE=1`) or the seal breaks Sparkle; `rsync --delete` node_modules (a nested duplicate SDK once shadowed the top-level one); node must be a 16 K-page build or "macOS 26 will crash".
3. **macOS 26 Code Signing Monitor**: Sparkle can corrupt the bundled node's seal so it passes `codesign --verify` but gets SIGKILLed → copy node to `$TMPDIR/<bundle-scoped>` and probe `node --version` before every spawn (`NodeBinaryHelper.swift:1-95`; dev and prod once clobbered each other's copy).
4. **PATH**: GUI apps get `/usr/bin:/bin:/usr/sbin:/sbin`; either bundle everything by absolute path (fazm), resolve once via `$SHELL -ilc` (Claude Desktop, VS Code, Zed's `--printenv` trick), or a hard-coded ladder. LaunchServices may hand you `/private/var/folders/…` as cwd — pin to `$HOME`. The npm `claude` shim's `#!/usr/bin/env -S node …` shebang fails from a GUI; spawn the **native** binary (`~/.local/bin/claude` → Bun single-file, Anthropic-signed).
5. **Bundling**: claude-agent-acp needs **Node ≥ 22**; the SDK bundles a native `claude` (override with `CLAUDE_CODE_EXECUTABLE`); Codex is a Rust binary in `@openai/codex-darwin-arm64`; Gemini is pure Node. fazm ships Node 22.14 + full `node_modules` — hundreds of MB, every Mach-O signed.
6. **PTY vs pipes**: `-p`/SDK/ACP run on pipes; interactive `claude` needs a PTY ("Raw mode is not supported… Ink"); `codex app-server` is TTY-free; `codex exec` hangs on an open inherited stdin; Gemini `--acp` won't start with sandbox + non-TTY stdin. For a real terminal use SwiftTerm's `forkpty` (`LocalProcess.startProcess`) — its default env **omits PATH**; node-pty on macOS is `posix_spawn` + a `spawn-helper` that shipped without `+x`.
7. **ANSI**: protocol channels are clean; **stderr is not** (codex-acp errors are coloured). `NO_COLOR=1` for Gemini.
8. **Process trees**: `Process.terminate()` = SIGTERM to the direct child only; `kill(-pgid)` misses grandchildren (MCP servers, `claude`) that start their own groups → walk `pgrep -P` depth-first and SIGTERM bottom-up; PPID watchdog in children (poll every 5 s, exit when PPID flips to 1 — "20+ orphan bridges", 14 of 20 survived SIGTERM and needed SIGKILL); or `posix_spawn` with `POSIX_SPAWN_SETSID` + `killpg` (Zed), `kqueue EVFILT_PROC NOTE_EXIT` for orphan detection.
9. **Never SIGTERM mid-tool-call**: the `tool_use` never gets its `tool_result` and the API parks forever → drain (SIGHUP path), then exit. SDK: SIGTERM → exit 143 turn unfinished; `interrupt()`/SIGINT clean.
10. **Read the pipe before `waitUntilExit`** — >16 KB of output deadlocks child and actor ("caused 200%+ CPU and stuck bridge launch", `ACPBridge.swift:779-782`). Resume pending continuations in `deinit`; generation-count `terminationHandler`s.
11. **Trust dialogs block headless children**: Claude's folder-trust (pre-seed `hasTrustDialogAccepted`), Gemini's trusted folders, Codex's git-repo check.
12. **Concurrency corrupts `~/.claude.json`** — one `CLAUDE_CONFIG_DIR` (+ `CLAUDE_CODE_PROJECT_DIR_NAME`) per hosted session; two processes resuming one session interleave messages.
13. Node heap: cap the bridge (`--max-old-space-size=256`) and treat exit 133/134/5/6 as OOM.
---
## 8. The numbers (fazm, cited in references)
```
timeouts warmup 240 s (45 s custom endpoint) · per-tool MCP 300 s / Bash 900 s / Task 1800 s / interactive 1800 s
idle-finalize 20 s (check 3 s) · compaction ceiling 180 s · stall flag 15 s · approval gate 300 s
resume sessionId→cwd map ~/.fazm/acp-sessions.json · re-send model after resume
restart credit-exhaustion adapter restart, 30 s cooldown · SIGHUP drain ≤5 min @500 ms · PPID poll 5 s
process detached:true · --max-old-space-size=256 · OOM exit codes 133/134/5/6 · orphan sweep on every start
images flat {type,data,mimeType} · Playwright shots resized to 1920 px · Claude limit 2000 px (use 1568)
mcp env array of {name,value} · command absolute · fazm_tools dials back over $TMPDIR/fazm-tools-<pid>.sock
```
---
## 9. api.ts — a working ACP host (Node builtins only)
```bash
A=~/.claude/skills/snappy-agent-host/api.ts
npx tsx $A doctor # CLIs, versions, node≥22?, adapters, auth state, env hazards (CLAUDECODE, ANTHROPIC_API_KEY)
npx tsx $A spawn-spec --agent claude|codex|gemini # the exact command + env a Swift port must reproduce
npx tsx $A capabilities --agent claude # initialize only: agentCapabilities + authMethods
npx tsx $A prompt --agent claude "reply with exactly: hello" [--cwd DIR] [--permission allow-once|allow-always|reject] [--timeout 300] [--keep-api-key]
npx tsx $A prompt --agent codex --provider openrouter [--model openai/gpt-4.1-mini] "…" # Codex engine, any OpenAI-compatible model; ~/.codex untouched
npx tsx $A login --agent claude|codex|gemini # terminal-auth in THIS terminal (needs a TTY); never touches tokens
```
`prompt` = spawn (detached, CLAUDECODE deleted, ANTHROPIC_API_KEY removed unless `--keep-api-key`, `CLAUDE_CODE_EXECUTABLE` = your installed `claude`) → `initialize` → `session/new{cwd}` → `session/prompt` → stream `session/update` to stderr → answer `session/request_permission` by `kind` per policy → return `{stopReason, text, toolCalls, usage(_meta.quota), sessionId}`; on timeout `session/cancel`, then kill the tree (group + `pgrep -P`). It is the reference implementation for the Swift client, line for line.
---
## 10. Top 25 things AI gets wrong (each cited in references §F)
1. "Spawn `claude` and parse its output" — the TUI needs a PTY and emits Ink frames; use `-p --output-format stream-json`, the SDK, or ACP.
2. "`claude -p` is a different engine" — the SDK spawns the same `claude` binary.
3. "The ACP adapter wraps the raw CLI" — it wraps the SDK, which spawns the CLI: three processes.
4. "`session/prompt` returns tokens" — v1 returns only `stopReason`; usage is `_meta`/RFD.
5. "`session/set_model` is spec" — adapter extension; use `session/set_config_option`.
6. "MCP `env` is an object" — array of `{name,value}`; `command` absolute.
7. "Permission ids are `allow`/`deny`" — agent-defined ids; kinds `allow_once|allow_always|reject_once|reject_always`; codex-acp fails closed.
8. "`session/cancel` kills the session" — ends the turn; prompt must return `cancelled`.
9. "Cancel = reject" — distinct; answer pending permissions with `cancelled` on turn cancel.
10. "Images use Anthropic `source` blocks" — flat `{type:"image",data,mimeType}`; no PDF block.
11. "`codex exec` prompts for approvals" — forces `approval_policy: Never`; use app-server or codex-acp.
12. "app-server is normal JSON-RPC" — `"jsonrpc":"2.0"` omitted on the wire.
13. "Use `@zed-industries/*-acp`" — archived; `@agentclientprotocol/*`.
14. "`gemini --experimental-acp`" — deprecated → `--acp`; won't start with sandbox + non-TTY stdin.
15. "Gemini just uses my key" — must `authenticate{methodId:"gemini-api-key"}` first.
16. "MCP registers regardless of folder" — Gemini needs `GEMINI_CLI_TRUST_WORKSPACE`; Claude blocks on the trust dialog.
17. "`kill(-pgid)` cleans the tree" — grandchildren start their own groups; `pgrep -P` + PPID watchdog.
18. "SIGTERM is a clean stop" — orphaned `tool_use`, API parks forever; drain first.
19. "Wait, then read the pipe" — deadlock at 16 KB.
20. "GUI apps inherit PATH" — `/usr/bin:/bin:/usr/sbin:/sbin`.
21. "A sandboxed app can spawn the user's CLIs" — children inherit the sandbox; ship un-sandboxed Developer ID.
22. "Sign the .app and you're done" — every Mach-O, hardened runtime, timestamp, JIT entitlement.
23. "`codesign --verify` passing means it runs" — macOS 26 CSM SIGKILL; copy the runtime out.
24. "`ANTHROPIC_API_KEY` alongside the login is harmless" — it silently bills the API.
25. "We can run our own OAuth against claude.ai" — forbidden; the unmodified binary logs itself in.
## References
`references/extract-agent-host.md` — §A fazm (cited), §B ACP with message shapes, §C per-agent recipes, §D prior art, §E native-Mac gotchas, §F top 25, full source list.
## Related skills
`mcp-server-builder` (your own MCP tools for the hosted agent) · `snappy-ax` · `snappy-voice-control` · `macos-patterns` · `swift-concurrency` · `snappy-jcode` (lane workers on the Mini) · `snappy-dispatch`.
<!-- SNAPPY-NEAR-NEIGHBOURS-START -->
## Near neighbours
These hands share enough of this one's words that a model can pick the wrong
door. Each row says what the other one is for; open that one instead when its
job is the job.
| Hand | What it is for |
|---|---|
| `snappy-ai-models` | Direct-API interface to OpenAI, Anthropic, and Replicate for the Snappy system -- the three model providers... |
| `snappy-artifact-loop` | Build published Artifacts as I/O devices where the AGENT is the backend, not as static output documents |
| `snappy-ax` | Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools actually do it —... |
| `snappy-box` | Box server -- self-editing Express server on Mac Mini (Docker, Node 20, 180+ routes) exposing HTTP API for... |
| `snappy-browse` | THE DEFAULT for actually driving a browser on this machine -- Snappy stack and client sites via agent-brows... |
| `snappy-client-total` | Jordan Cameron's mortgage adviser CRM for New Zealand -- the largest and most active client engagement |
| `snappy-deploy` | Meta-deployment skill that orchestrates ALL Snappy project deployments across the four supported platforms... |
| `snappy-desktop` | macOS desktop automation primitive for the Snappy stack via Midscene vision AI (`npx @midscene/computer@1`) |
| `snappy-dom-cartographer` | Master DOM mapping agent for the Snappy swarm |
| `snappy-gateway` | Snappy Skills Gateway -- publish, gate, and distribute Claude Code skills via skills.snappy.ai (Cloudflare... |
| `snappy-github` | Centralized GitHub operations across all Snappy client repos via the `gh` CLI -- pull request creation, cod... |
| `snappy-gmail` | Gmail as the machine's own hands -- read the inbox, threads and one message straight from Gmail's REST API... |
| `snappy-image` | Centralized image generation, editing, and capture for Snappy: Nano Banana / Gemini, Grok / xAI edits, gpt... |
| `snappy-imessage` | iMessage on THIS Mac -- the one holding Messages.app -- through the hand's own verbs (`api.ts send/recent/c... |
| `snappy-jcode` | Dispatch GPT 5.6 (Luna/Sol) agents as sandboxed lane workers via the local jcode CLI, on this Mac or the Ma... |
| `snappy-maintenance` | Snappy project maintenance -- keeping all client and internal systems healthy across Vercel, Fly.io, Cloudf... |
| `snappy-nightshift` | The overnight orchestration operating system: one orchestrator drives a repo toward 100% all night with bui... |
| `snappy-ops` | The Snappy operator shell |
| `snappy-os-operator` | Operate SnappyOS like a pro through product doors only: governed connector reads, staged writes with approv... |
| `snappy-resident` | The non-stop user seat: drive the Snappy OS app as a real user through a real browser (agent-browser), with... |
| `snappy-session-close` | Close a working session in two verbs: RECONCILE the agent-facing docs of a repo set (CLAUDE.md, AGENTS.md... |
| `snappy-shell` | Kernel-loaded fallback runner |
| `snappy-swarm` | Orchestrate swarms of parallel AI agents for multi-wave quality passes across a project |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to send text, ph... |
| `snappy-tool-design` | Contract-first ergonomics lint for AI-operated skills, unlike snappy-artifact-loop which manages implementa... |
| `snappy-update` | Snappy Update -- dev updates to consulting clients |
| `snappy-voice-control` | Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Agent! by Agenti... |
| `snappy-walkthrough` | Recipe-driven capture and annotation of step-by-step tutorials |
| `snappy-watchtower` | Standing error monitors and the probes that lie: arm live typecheck, build, and test monitors at session st... |
| `snappy-xano-mcp` | THE EXISTING, DEPLOYED Snappy MCP server: the Cloudflare Worker exposing the Snappy Xano API (email, calend... |
<!-- SNAPPY-NEAR-NEIGHBOURS-END -->
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT, agentRunFace, agentRunState, tookWords } from "./api.ts";
/** THE JOIN THE HAND DECLARES ⟨lane family-reads, 2026-09-09⟩. The `agent`
* family had no read at all: the runner's name route is `snappy-<family>`
* and this hand is `snappy-agent-host`, while it is the only thing in the
* kernel that HAS an agent run to draw. */
const META = { runtime: "claude", cwd: "/Users/mara/Projects/harbourline-importer", status: "idle", createdAt: "2026-09-08T02:14:00.000Z", updatedAt: "2026-09-08T02:20:41.000Z", sessionId: "sess-207" };
const EVENTS = [
{ seq: 1, type: "turn_started", command_id: "c1", text: "Unbatch the Harbourline reads\n\nThe importer times out at 30s." },
{ seq: 2, type: "step", step: "tool_call", update: { toolCallId: "t1", title: "Read the failing run's ledger row", kind: "read", status: "pending" } },
{ seq: 3, type: "step", step: "tool_call_update", update: { toolCallId: "t1", title: "Read the failing run's ledger row", status: "completed", content: [{ content: { text: "run-207 · 3 sources · timed out at 30,000 ms" } }] } },
{ seq: 4, type: "step", step: "agent_thought_chunk", update: { content: { text: "thinking about the slow source" } } },
{ seq: 5, type: "step", step: "tool_call", update: { toolCallId: "t2", title: "Rewrote readSources to read one at a time", rawInput: { command: "edit importer/read-batch.ts" }, status: "completed", content: [{ content: { text: "+4 −18" } }] } },
{ seq: 6, type: "permission_ask", toolCall: { title: "Run the importer against the last failing batch" } },
{ seq: 7, type: "turn_done", command_id: "c1", stop_reason: "end_turn", answer: "Done." },
];
test("snappy-agent-host: status declares the agent-run face", () => {
assert.equal(HAND_CONTRACT.verbs.status.face, "agent-run");
});
test("snappy-agent-host: the fold prints the keys AgentRun binds", () => {
const run = agentRunFace(META, EVENTS);
assert.equal(run.title, "Unbatch the Harbourline reads");
assert.equal(run.agentName, "claude · harbourline-importer");
assert.equal(run.state, "done");
assert.equal(run.startedAt, "2026-09-08 02:14");
assert.equal(run.tookWords, "6m 41s");
assert.deepEqual(run.steps, [
{ words: "Read the failing run's ledger row", tool: "read", result: "run-207 · 3 sources · timed out at 30,000 ms", state: "done" },
{ words: "Rewrote readSources to read one at a time", tool: "edit importer/read-batch.ts", result: "+4 −18", state: "done" },
{ words: "Run the importer against the last failing batch", state: "waiting" },
]);
});
/** A TOOL CALL AND ITS UPDATES ARE ONE STEP. ACP repeats the same
* `toolCallId` as a call runs; a face that drew a row per update would draw
* the same work four times and call the finished call "pending". */
test("snappy-agent-host: a tool call's updates collapse onto the call, later wins", () => {
const run = agentRunFace(META, EVENTS);
assert.equal(run.steps.filter((step) => step.words.startsWith("Read the failing")).length, 1);
assert.equal(run.steps[0]!.state, "done");
});
/** CHUNK DELTAS ARE NOT STEPS. A run's thinking arrives as hundreds of them
* and a row per token is a face nobody can read. */
test("snappy-agent-host: thought and answer deltas draw no step", () => {
const run = agentRunFace(META, [
{ seq: 1, type: "step", step: "thought_delta", update: { content: { text: "hm" } } },
{ seq: 2, type: "step", step: "answer_delta", update: { content: { text: "Done." } } },
]);
assert.deepEqual(run.steps, []);
});
/** `idle` MEANS THE TURN FINISHED. Drawing the word "idle" over a finished run
* would make a person wait for something that already happened. */
test("snappy-agent-host: idle draws as done, waiting_permission as waiting", () => {
assert.equal(agentRunState("idle"), "done");
assert.equal(agentRunState("waiting_permission"), "waiting");
assert.equal(agentRunState("failed"), "failed");
assert.equal(agentRunState("running"), "running");
});
test("snappy-agent-host: elapsed is said the way a person says it", () => {
assert.equal(tookWords(412), "412 ms");
assert.equal(tookWords(6_000), "6s");
assert.equal(tookWords(401_000), "6m 41s");
});
/** A SESSION WITH NO TURN ON RECORD HAS NO TITLE OF ITS OWN, and says so with
* the folder it runs in rather than a sentence written here. */
test("snappy-agent-host: a run with no turn names its folder", () => {
const run = agentRunFace({ ...META, status: "starting" }, []);
assert.equal(run.title, "Session in /Users/mara/Projects/harbourline-importer");
assert.equal(run.state, "starting");
assert.deepEqual(run.steps, []);
});
/** A FAILURE IS A STEP THAT SAYS SO. A run that ended in an error and drew no
* row would be a black box again. */
test("snappy-agent-host: a failed turn draws its own step with the reason", () => {
const run = agentRunFace({ ...META, status: "failed" }, [
{ seq: 1, type: "turn_failed", command_id: "c1", error: "adapter exited: EPIPE" },
]);
assert.deepEqual(run.steps, [{ words: "The turn failed", state: "failed", result: "adapter exited: EPIPE" }]);
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT, agentRunFace, agentRunState, tookWords } from "./api.ts";
/** THE JOIN THE HAND DECLARES ⟨lane family-reads, 2026-09-09⟩. The `agent`
* family had no read at all: the runner's name route is `snappy-<family>`
* and this hand is `snappy-agent-host`, while it is the only thing in the
* kernel that HAS an agent run to draw. */
const META = { runtime: "claude", cwd: "/Users/mara/Projects/harbourline-importer", status: "idle", createdAt: "2026-09-08T02:14:00.000Z", updatedAt: "2026-09-08T02:20:41.000Z", sessionId: "sess-207" };
const EVENTS = [
{ seq: 1, type: "turn_started", command_id: "c1", text: "Unbatch the Harbourline reads\n\nThe importer times out at 30s." },
{ seq: 2, type: "step", step: "tool_call", update: { toolCallId: "t1", title: "Read the failing run's ledger row", kind: "read", status: "pending" } },
{ seq: 3, type: "step", step: "tool_call_update", update: { toolCallId: "t1", title: "Read the failing run's ledger row", status: "completed", content: [{ content: { text: "run-207 · 3 sources · timed out at 30,000 ms" } }] } },
{ seq: 4, type: "step", step: "agent_thought_chunk", update: { content: { text: "thinking about the slow source" } } },
{ seq: 5, type: "step", step: "tool_call", update: { toolCallId: "t2", title: "Rewrote readSources to read one at a time", rawInput: { command: "edit importer/read-batch.ts" }, status: "completed", content: [{ content: { text: "+4 −18" } }] } },
{ seq: 6, type: "permission_ask", toolCall: { title: "Run the importer against the last failing batch" } },
{ seq: 7, type: "turn_done", command_id: "c1", stop_reason: "end_turn", answer: "Done." },
];
test("snappy-agent-host: status declares the agent-run face", () => {
assert.equal(HAND_CONTRACT.verbs.status.face, "agent-run");
});
test("snappy-agent-host: the fold prints the keys AgentRun binds", () => {
const run = agentRunFace(META, EVENTS);
assert.equal(run.title, "Unbatch the Harbourline reads");
assert.equal(run.agentName, "claude · harbourline-importer");
assert.equal(run.state, "done");
assert.equal(run.startedAt, "2026-09-08 02:14");
assert.equal(run.tookWords, "6m 41s");
assert.deepEqual(run.steps, [
{ words: "Read the failing run's ledger row", tool: "read", result: "run-207 · 3 sources · timed out at 30,000 ms", state: "done" },
{ words: "Rewrote readSources to read one at a time", tool: "edit importer/read-batch.ts", result: "+4 −18", state: "done" },
{ words: "Run the importer against the last failing batch", state: "waiting" },
]);
});
/** A TOOL CALL AND ITS UPDATES ARE ONE STEP. ACP repeats the same
* `toolCallId` as a call runs; a face that drew a row per update would draw
* the same work four times and call the finished call "pending". */
test("snappy-agent-host: a tool call's updates collapse onto the call, later wins", () => {
const run = agentRunFace(META, EVENTS);
assert.equal(run.steps.filter((step) => step.words.startsWith("Read the failing")).length, 1);
assert.equal(run.steps[0]!.state, "done");
});
/** CHUNK DELTAS ARE NOT STEPS. A run's thinking arrives as hundreds of them
* and a row per token is a face nobody can read. */
test("snappy-agent-host: thought and answer deltas draw no step", () => {
const run = agentRunFace(META, [
{ seq: 1, type: "step", step: "thought_delta", update: { content: { text: "hm" } } },
{ seq: 2, type: "step", step: "answer_delta", update: { content: { text: "Done." } } },
]);
assert.deepEqual(run.steps, []);
});
/** `idle` MEANS THE TURN FINISHED. Drawing the word "idle" over a finished run
* would make a person wait for something that already happened. */
test("snappy-agent-host: idle draws as done, waiting_permission as waiting", () => {
assert.equal(agentRunState("idle"), "done");
assert.equal(agentRunState("waiting_permission"), "waiting");
assert.equal(agentRunState("failed"), "failed");
assert.equal(agentRunState("running"), "running");
});
test("snappy-agent-host: elapsed is said the way a person says it", () => {
assert.equal(tookWords(412), "412 ms");
assert.equal(tookWords(6_000), "6s");
assert.equal(tookWords(401_000), "6m 41s");
});
/** A SESSION WITH NO TURN ON RECORD HAS NO TITLE OF ITS OWN, and says so with
* the folder it runs in rather than a sentence written here. */
test("snappy-agent-host: a run with no turn names its folder", () => {
const run = agentRunFace({ ...META, status: "starting" }, []);
assert.equal(run.title, "Session in /Users/mara/Projects/harbourline-importer");
assert.equal(run.state, "starting");
assert.deepEqual(run.steps, []);
});
/** A FAILURE IS A STEP THAT SAYS SO. A run that ended in an error and drew no
* row would be a black box again. */
test("snappy-agent-host: a failed turn draws its own step with the reason", () => {
const run = agentRunFace({ ...META, status: "failed" }, [
{ seq: 1, type: "turn_failed", command_id: "c1", error: "adapter exited: EPIPE" },
]);
assert.deepEqual(run.steps, [{ words: "The turn failed", state: "failed", result: "adapter exited: EPIPE" }]);
});
import assert from "node:assert/strict";
import test from "node:test";
import {
agentHostCurrentValue,
agentHostModelChoice,
agentHostNewSessionRequest,
agentHostOptionFor,
agentHostResolveValue,
agentHostResumeId,
HAND_CONTRACT,
} from "./api.ts";
test("hosted repo session keeps cwd and loads project settings", () => {
const cwd = "/Users/example/project";
const request = agentHostNewSessionRequest(cwd);
assert.equal(request.cwd, cwd);
assert.deepEqual(request.mcpServers, []);
const meta = request._meta as {
claudeCode?: { options?: { settingSources?: string[] } };
};
assert.ok(
meta.claudeCode?.options?.settingSources?.includes("project"),
"settingSources must include project or the hosted agent runs blind to CLAUDE.md",
);
});
test("cancelled sessions never resume unfinished work", () => {
assert.equal(agentHostResumeId({ runtime: "codex", status: "cancelled", sessionId: "old" }, "codex"), null);
assert.equal(agentHostResumeId({ runtime: "codex", status: "idle", sessionId: "live" }, "codex"), "live");
assert.equal(agentHostResumeId({ runtime: "claude", status: "idle", sessionId: "other" }, "codex"), null);
});
test("start parses --model and --effort, and asks for nothing when they are absent", () => {
assert.deepEqual(agentHostModelChoice(["codex", "/tmp", "hi"]), {});
assert.deepEqual(
agentHostModelChoice(["codex", "/tmp", "hi", "--model", "gpt-5.6-sol", "--effort", "low"]),
{ model: "gpt-5.6-sol", effort: "low" },
);
assert.deepEqual(agentHostModelChoice(["codex", "/tmp", "hi", "--effort", "high"]), { effort: "high" });
assert.throws(() => agentHostModelChoice(["codex", "/tmp", "hi", "--model"]), /--model needs a value/);
});
test("the model selector is found by ACP category, never by a hardcoded id", () => {
const advertised = [
{ id: "mode", name: "Mode", category: "mode", type: "select", currentValue: "ask", options: [] },
{ id: "reasoning_effort", name: "Reasoning effort", category: "thought_level", type: "select", currentValue: "medium", options: [] },
{ id: "some_future_model_id", name: "Model", category: "model", type: "select", currentValue: "gpt-6-astra", options: [] },
];
assert.equal(agentHostOptionFor(advertised, "model")?.id, "some_future_model_id");
assert.equal(agentHostOptionFor(advertised, "thought_level")?.id, "reasoning_effort");
assert.equal(agentHostOptionFor(advertised, "model_config"), null);
assert.equal(agentHostCurrentValue(agentHostOptionFor(advertised, "model")), "gpt-6-astra");
assert.equal(agentHostCurrentValue(null), null);
});
test("a requested value resolves against what the runtime advertises, and names the list when it cannot", () => {
const option = {
id: "model", name: "Model", category: "model", type: "select", currentValue: "gpt-6-astra",
options: [
{ value: "gpt-6-astra", name: "GPT-6-Astra" },
{ value: "gpt-5.6-sol", name: "GPT-5.6-Sol" },
],
};
assert.equal(agentHostResolveValue(option, "gpt-5.6-sol"), "gpt-5.6-sol");
assert.equal(agentHostResolveValue(option, "GPT-5.6-SOL"), "gpt-5.6-sol");
assert.equal(agentHostResolveValue(option, "GPT-6-Astra"), "gpt-6-astra");
assert.throws(() => agentHostResolveValue(option, "gpt-4"), /gpt-6-astra, gpt-5\.6-sol/);
});
test("grouped select options flatten, so a grouped model picker still resolves", () => {
const option = {
id: "model", name: "Model", category: "model", type: "select", currentValue: "a",
options: [
{ group: "fast", name: "Fast", options: [{ value: "a", name: "A" }] },
{ group: "deep", name: "Deep", options: [{ value: "b", name: "B" }] },
],
};
assert.equal(agentHostResolveValue(option, "b"), "b");
});
test("the contract teaches the model flags and the models verb", () => {
assert.equal(HAND_CONTRACT.verbs.start.flags.model, "--model");
assert.equal(HAND_CONTRACT.verbs.start.flags.effort, "--effort");
assert.equal(HAND_CONTRACT.verbs.models.effect, "read");
assert.deepEqual([...HAND_CONTRACT.verbs.models.args], ["runtime"]);
});
import assert from "node:assert/strict";
import test from "node:test";
import {
agentHostCurrentValue,
agentHostModelChoice,
agentHostNewSessionRequest,
agentHostOptionFor,
agentHostResolveValue,
agentHostResumeId,
HAND_CONTRACT,
} from "./api.ts";
test("hosted repo session keeps cwd and loads project settings", () => {
const cwd = "/Users/example/project";
const request = agentHostNewSessionRequest(cwd);
assert.equal(request.cwd, cwd);
assert.deepEqual(request.mcpServers, []);
const meta = request._meta as {
claudeCode?: { options?: { settingSources?: string[] } };
};
assert.ok(
meta.claudeCode?.options?.settingSources?.includes("project"),
"settingSources must include project or the hosted agent runs blind to CLAUDE.md",
);
});
test("cancelled sessions never resume unfinished work", () => {
assert.equal(agentHostResumeId({ runtime: "codex", status: "cancelled", sessionId: "old" }, "codex"), null);
assert.equal(agentHostResumeId({ runtime: "codex", status: "idle", sessionId: "live" }, "codex"), "live");
assert.equal(agentHostResumeId({ runtime: "claude", status: "idle", sessionId: "other" }, "codex"), null);
});
test("start parses --model and --effort, and asks for nothing when they are absent", () => {
assert.deepEqual(agentHostModelChoice(["codex", "/tmp", "hi"]), {});
assert.deepEqual(
agentHostModelChoice(["codex", "/tmp", "hi", "--model", "gpt-5.6-sol", "--effort", "low"]),
{ model: "gpt-5.6-sol", effort: "low" },
);
assert.deepEqual(agentHostModelChoice(["codex", "/tmp", "hi", "--effort", "high"]), { effort: "high" });
assert.throws(() => agentHostModelChoice(["codex", "/tmp", "hi", "--model"]), /--model needs a value/);
});
test("the model selector is found by ACP category, never by a hardcoded id", () => {
const advertised = [
{ id: "mode", name: "Mode", category: "mode", type: "select", currentValue: "ask", options: [] },
{ id: "reasoning_effort", name: "Reasoning effort", category: "thought_level", type: "select", currentValue: "medium", options: [] },
{ id: "some_future_model_id", name: "Model", category: "model", type: "select", currentValue: "gpt-6-astra", options: [] },
];
assert.equal(agentHostOptionFor(advertised, "model")?.id, "some_future_model_id");
assert.equal(agentHostOptionFor(advertised, "thought_level")?.id, "reasoning_effort");
assert.equal(agentHostOptionFor(advertised, "model_config"), null);
assert.equal(agentHostCurrentValue(agentHostOptionFor(advertised, "model")), "gpt-6-astra");
assert.equal(agentHostCurrentValue(null), null);
});
test("a requested value resolves against what the runtime advertises, and names the list when it cannot", () => {
const option = {
id: "model", name: "Model", category: "model", type: "select", currentValue: "gpt-6-astra",
options: [
{ value: "gpt-6-astra", name: "GPT-6-Astra" },
{ value: "gpt-5.6-sol", name: "GPT-5.6-Sol" },
],
};
assert.equal(agentHostResolveValue(option, "gpt-5.6-sol"), "gpt-5.6-sol");
assert.equal(agentHostResolveValue(option, "GPT-5.6-SOL"), "gpt-5.6-sol");
assert.equal(agentHostResolveValue(option, "GPT-6-Astra"), "gpt-6-astra");
assert.throws(() => agentHostResolveValue(option, "gpt-4"), /gpt-6-astra, gpt-5\.6-sol/);
});
test("grouped select options flatten, so a grouped model picker still resolves", () => {
const option = {
id: "model", name: "Model", category: "model", type: "select", currentValue: "a",
options: [
{ group: "fast", name: "Fast", options: [{ value: "a", name: "A" }] },
{ group: "deep", name: "Deep", options: [{ value: "b", name: "B" }] },
],
};
assert.equal(agentHostResolveValue(option, "b"), "b");
});
test("the contract teaches the model flags and the models verb", () => {
assert.equal(HAND_CONTRACT.verbs.start.flags.model, "--model");
assert.equal(HAND_CONTRACT.verbs.start.flags.effort, "--effort");
assert.equal(HAND_CONTRACT.verbs.models.effect, "read");
assert.deepEqual([...HAND_CONTRACT.verbs.models.args], ["runtime"]);
});
#!/usr/bin/env node
/**
* snappy-agent-host: durable ACP sessions for Claude Code, Codex, and Gemini.
*
* The wire is owned by @agentclientprotocol/sdk@1.4.0 from the already-installed
* SnappyOS.app runtime. This file never implements JSON-RPC or ACP framing.
*/
import { env } from "../snappy-settings/load.ts";
import { createHash, randomUUID } from "node:crypto";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createConnection, createServer, type Socket } from "node:net";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { Readable, Writable } from "node:stream";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
export type Runtime = "claude" | "codex" | "gemini";
export type Permissions = "allow-all" | "ask";
/**
* What the caller asked the runtime for. An empty choice is the default and means
* "whatever the person's own runtime config already says" — this file pins no model.
*/
export type ModelChoice = { model?: string; effort?: string };
/** One `SessionConfigOption` exactly as the runtime advertised it over ACP. */
export type ConfigOption = {
id: string;
name?: string;
description?: string | null;
category?: string | null;
type?: string;
currentValue?: unknown;
options?: unknown;
};
/** How the runtime came to be running the model it reports. */
export type ConfigHow = "runtime-default" | "protocol" | "adapter-config" | "not-advertised";
type JsonObject = Record<string, unknown>;
type SessionRequest = {
cwd: string;
mcpServers: unknown[];
_meta: { claudeCode: { options: { settingSources: string[] } } };
};
type AcpContext = {
request<T = unknown>(method: string, params?: unknown, options?: { cancellationSignal?: AbortSignal }): Promise<T>;
notify(method: string, params?: unknown): Promise<void>;
};
type AcpConnection = {
agent: AcpContext;
close(error?: unknown): void;
closed: Promise<void>;
signal: AbortSignal;
};
type AcpClientApp = {
onRequest(method: string, handler: (context: { params: JsonObject }) => unknown): AcpClientApp;
onRequest(method: string, parser: (value: unknown) => JsonObject, handler: (context: { params: JsonObject }) => unknown): AcpClientApp;
onNotification(method: string, handler: (context: { params: JsonObject }) => unknown): AcpClientApp;
connect(stream: unknown): AcpConnection;
};
type AcpModule = {
PROTOCOL_VERSION: number;
methods: {
agent: {
initialize: string;
authenticate: string;
session: {
new: string;
load: string;
resume: string;
prompt: string;
cancel: string;
close: string;
setConfigOption: string;
};
};
client: { session: { requestPermission: string; update: string } };
};
ndJsonStream(output: WritableStream<Uint8Array>, input: ReadableStream<Uint8Array>): unknown;
client(options?: { name?: string }): AcpClientApp;
};
interface AdapterSpec {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
version: string;
name: string;
}
interface SessionMeta {
version: 1;
key: string;
runtime: Runtime;
cwd: string;
permissions: Permissions;
pid: number;
adapterPid: number | null;
socket: string;
sessionId: string | null;
sessionHow: "new" | "resumed" | "loaded" | null;
status: "starting" | "idle" | "running" | "waiting_permission" | "cancelling" | "cancelled" | "failed";
createdAt: string;
updatedAt: string;
nextSeq: number;
currentTurn: string | null;
error?: string;
/** What the caller asked for, or null when the person's own runtime config decides. */
asked?: ModelChoice;
/** What the runtime REPORTS it is running, read back from its own config options. */
model?: string | null;
effort?: string | null;
configHow?: ConfigHow | null;
configOptions?: ConfigOption[];
}
interface EventLine extends JsonObject {
seq: number;
ts: string;
session: string | null;
type: string;
}
interface WorkerCommand extends JsonObject {
command: "ping" | "prompt" | "answer" | "cancel" | "config";
text?: string;
answer?: string;
model?: string;
effort?: string;
}
const STATE_ROOT = process.env.SNAPPY_AGENT_HOST_STATE || join(homedir(), ".snappy-agent-host");
const SESSION_ROOT = join(STATE_ROOT, "sessions");
const PAGE_BYTES = 12_000;
const PAGE_EVENTS = 80;
const COMMAND_TIMEOUT_MS = 300_000;
const APP_MODULES = "/Applications/SnappyOS.app/Contents/Resources/SnappyOS_SnappyOS.bundle/Resources/runtime/node_modules";
const CLAUDE_ADAPTER_VERSION = "0.73.0";
const CODEX_ADAPTER_VERSION = "1.8.0";
const ACP_SDK_VERSION = "1.4.0";
// ACP reserves these category names for the model picker and the reasoning-level picker
// (spec: session-config-options / model-config-category). We match on the CATEGORY, never
// on an id, so a runtime is free to rename or add ids without this skill going stale.
const MODEL_CATEGORY = "model";
const EFFORT_CATEGORY = "thought_level";
const CODEX_MODELS_CACHE = join(homedir(), ".codex", "models_cache.json");
const CLIENT_INFO = { name: "snappy-agent-host", title: "Snappy Agent Host", version: "2.0.0" };
function now(): string { return new Date().toISOString(); }
function sleep(ms: number): Promise<void> { return new Promise((done) => setTimeout(done, ms)); }
function asObject(value: unknown): JsonObject { return typeof value === "object" && value !== null ? value as JsonObject : {}; }
function textOf(error: unknown): string { return error instanceof Error ? error.message : String(error); }
function isRuntime(value: string): value is Runtime { return value === "claude" || value === "codex" || value === "gemini"; }
function isPermissions(value: string): value is Permissions { return value === "allow-all" || value === "ask"; }
function ensureState(): void {
mkdirSync(SESSION_ROOT, { recursive: true, mode: 0o700 });
try { chmodSync(STATE_ROOT, 0o700); chmodSync(SESSION_ROOT, 0o700); } catch { /* best effort on existing roots */ }
}
function sessionKey(cwd: string): string {
return createHash("sha256").update(cwd).digest("hex").slice(0, 20);
}
function sessionDir(key: string): string { return join(SESSION_ROOT, key); }
function metaPath(key: string): string { return join(sessionDir(key), "session.json"); }
function eventsPath(key: string): string { return join(sessionDir(key), "steps.ndjson"); }
function socketPath(key: string): string { return join(sessionDir(key), "host.sock"); }
function writeJsonAtomic(path: string, value: unknown): void {
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, JSON.stringify(value, null, 2), { mode: 0o600 });
renameSync(temp, path);
}
function readMetaKey(key: string): SessionMeta | null {
try { return JSON.parse(readFileSync(metaPath(key), "utf8")) as SessionMeta; } catch { return null; }
}
function writeMeta(meta: SessionMeta): void {
meta.updatedAt = now();
writeJsonAtomic(metaPath(meta.key), meta);
}
function processAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
}
function readAllMetas(): SessionMeta[] {
ensureState();
return readdirSync(SESSION_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => readMetaKey(entry.name))
.filter((meta): meta is SessionMeta => meta !== null);
}
function findSession(id: string): SessionMeta | null {
return readAllMetas().find((meta) => meta.sessionId === id || meta.key === id) ?? null;
}
function appendEvent(meta: SessionMeta, type: string, fields: JsonObject = {}): EventLine {
const candidate = { seq: meta.nextSeq, ts: now(), session: meta.sessionId, type, ...fields };
const encoded = JSON.stringify(candidate);
const event: EventLine = encoded.length <= 9_000
? { ...candidate, seq: meta.nextSeq++ }
: {
seq: meta.nextSeq++, ts: candidate.ts, session: meta.sessionId, type,
truncated: true, bytes: Buffer.byteLength(encoded), preview: encoded.slice(0, 8_000),
};
appendFileSync(eventsPath(meta.key), `${JSON.stringify(event)}\n`, { mode: 0o600 });
writeMeta(meta);
return event;
}
function eventsFor(meta: SessionMeta): EventLine[] {
if (!existsSync(eventsPath(meta.key))) return [];
return readFileSync(eventsPath(meta.key), "utf8").split("\n").filter(Boolean).flatMap((line) => {
try { return [JSON.parse(line) as EventLine]; } catch { return []; }
});
}
function nextPersistedSeq(meta: SessionMeta | null, key: string): number {
let fromEvents = 1;
if (existsSync(eventsPath(key))) {
for (const line of readFileSync(eventsPath(key), "utf8").split("\n").filter(Boolean)) {
try { fromEvents = Math.max(fromEvents, Number((JSON.parse(line) as EventLine).seq) + 1); } catch { /* ignore a torn final line */ }
}
}
return Math.max(meta?.nextSeq ?? 1, fromEvents);
}
/** The proven hosted-repo request. cwd is identity and project settings stay visible. */
export function agentHostNewSessionRequest(cwd: string): SessionRequest {
if (!cwd.startsWith("/")) throw new Error("ACP session cwd must be absolute");
return {
cwd,
mcpServers: [],
_meta: { claudeCode: { options: { settingSources: ["user", "project", "local"] } } },
};
}
export function agentHostResumeId(
existing: Pick<SessionMeta, "runtime" | "status" | "sessionId"> | null,
runtime: Runtime,
): string | null {
return existing?.runtime === runtime && existing.status !== "cancelled" && existing.status !== "failed"
? existing.sessionId
: null;
}
/** Read `--model` / `--effort` off a verb's argv. Absent means: ask the runtime for nothing. */
export function agentHostModelChoice(args: string[]): ModelChoice {
const choice: ModelChoice = {};
for (const [name, key] of [["--model", "model"], ["--effort", "effort"]] as const) {
const index = args.indexOf(name);
if (index < 0) continue;
const value = args[index + 1];
if (value === undefined || value === "" || value.startsWith("--")) throw new Error(`${name} needs a value`);
choice[key] = value;
}
return choice;
}
/** The advertised option for an ACP category. Category, never id: ids are each runtime's own. */
export function agentHostOptionFor(options: ConfigOption[], category: string): ConfigOption | null {
return options.find((option) => option.category === category) ?? null;
}
/** What the runtime says it is currently running for that option. */
export function agentHostCurrentValue(option: ConfigOption | null): string | null {
const current = option?.currentValue;
return current === undefined || current === null ? null : String(current);
}
/** Every selectable value, flattening the grouped form of `SessionConfigSelectOptions`. */
export function agentHostOptionValues(option: ConfigOption): Array<{ value: string; name: string }> {
const entries = Array.isArray(option.options) ? option.options.map(asObject) : [];
const flat = entries.flatMap((entry) => (Array.isArray(entry.options) ? entry.options.map(asObject) : [entry]));
return flat
.map((entry) => ({ value: String(entry.value ?? ""), name: String(entry.name ?? "") }))
.filter((entry) => entry.value !== "");
}
/** Resolve what the caller asked for against what the runtime advertises. Never invent a value. */
export function agentHostResolveValue(option: ConfigOption, wanted: string): string {
const values = agentHostOptionValues(option);
const lower = wanted.toLowerCase();
const hit = values.find((entry) => entry.value === wanted)
?? values.find((entry) => entry.value.toLowerCase() === lower)
?? values.find((entry) => entry.name.toLowerCase() === lower);
if (hit !== undefined) return hit.value;
throw new Error(`${option.id} has no value ${wanted}; this runtime advertises: ${values.map((entry) => entry.value).join(", ")}`);
}
/**
* Record what the runtime REPORTS, never what we asked for. The current values come
* back inside the runtime's own config options, so the report is the artifact.
*/
function observeConfig(meta: SessionMeta, options: ConfigOption[], how?: ConfigHow): void {
meta.configOptions = options;
meta.model = agentHostCurrentValue(agentHostOptionFor(options, MODEL_CATEGORY));
meta.effort = agentHostCurrentValue(agentHostOptionFor(options, EFFORT_CATEGORY));
if (how !== undefined) meta.configHow = how;
}
/**
* Carry the caller's ask down the ACP road (`session/set_config_option`) and read back
* what the runtime then reports. With nothing asked, nothing is set and the person's own
* runtime config stands.
*/
async function applyChoice(
acp: AcpModule,
context: AcpContext,
meta: SessionMeta,
sessionId: string,
advertised: ConfigOption[],
choice: ModelChoice,
): Promise<void> {
const asked: Array<[string, string]> = [];
if (choice.model !== undefined) asked.push([MODEL_CATEGORY, choice.model]);
if (choice.effort !== undefined) asked.push([EFFORT_CATEGORY, choice.effort]);
if (asked.length === 0) { observeConfig(meta, advertised, "runtime-default"); return; }
let options = advertised;
// codex already received the ask as a CODEX_CONFIG seed at spawn; every other runtime has
// the protocol road or nothing. Either way the loop below upgrades this to "protocol".
let how: ConfigHow = meta.runtime === "codex" ? "adapter-config" : "not-advertised";
for (const [category, value] of asked) {
const option = agentHostOptionFor(options, category);
if (option === null) continue;
const response = await context.request<JsonObject>(acp.methods.agent.session.setConfigOption, {
sessionId,
configId: option.id,
value: agentHostResolveValue(option, value),
});
const next = Array.isArray(response.configOptions) ? response.configOptions.map(asObject) as ConfigOption[] : null;
if (next !== null) options = next;
how = "protocol";
}
observeConfig(meta, options, how);
}
/** The codex CLI's OWN model cache. Labelled as such: it is never this skill's list. */
function codexCliModelCache(): JsonObject | null {
try {
const parsed = JSON.parse(readFileSync(CODEX_MODELS_CACHE, "utf8")) as JsonObject;
const models = Array.isArray(parsed.models) ? parsed.models.map(asObject) : [];
return {
source: CODEX_MODELS_CACHE,
note: "the codex CLI's own cache file, not a list ACP advertised and not a list this skill keeps",
client_version: parsed.client_version ?? null,
fetched_at: parsed.fetched_at ?? null,
models: models.map((model) => ({
slug: model.slug ?? null,
display_name: model.display_name ?? null,
default_reasoning_level: model.default_reasoning_level ?? null,
supported_reasoning_levels: (Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [])
.map((level) => String(asObject(level).effort ?? "")).filter(Boolean),
})),
};
} catch { return null; }
}
function packageJson(modules: string, name: string): JsonObject {
return JSON.parse(readFileSync(join(modules, ...name.split("/"), "package.json"), "utf8")) as JsonObject;
}
function moduleRoots(): string[] {
const roots = [process.env.SNAPPY_AGENT_HOST_MODULES ?? "", APP_MODULES];
const cache = join(homedir(), "Projects/snappy-os-app/apps/snappy-os/.build/build-cache/native");
if (existsSync(cache)) {
const builds = readdirSync(cache).map((name) => join(cache, name, "SnappyOS_SnappyOS.bundle/Resources/runtime/node_modules"));
builds.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs; } catch { return 0; }
});
roots.push(...builds);
}
return roots.filter((root, index, all) => root !== "" && all.indexOf(root) === index && existsSync(root));
}
function modulesWith(name: string, version: string): string {
for (const root of moduleRoots()) {
try {
const pkg = packageJson(root, name);
if (pkg.version === version) return root;
} catch { /* try the next existing app runtime */ }
}
throw new Error(`${name}@${version} is not present in SnappyOS.app's runtime; no package was installed`);
}
async function loadAcp(): Promise<AcpModule> {
const modules = modulesWith("@agentclientprotocol/sdk", ACP_SDK_VERSION);
const entry = join(modules, "@agentclientprotocol/sdk/dist/acp.js");
return await import(pathToFileURL(entry).href) as AcpModule;
}
function which(binary: string): string | null {
const result = spawnSync("which", [binary], { encoding: "utf8" });
return result.status === 0 && result.stdout.trim() !== "" ? result.stdout.trim() : null;
}
function optionalEnv(name: string): string {
try { return process.env[name] || env(name, false) || ""; } catch { return process.env[name] || ""; }
}
function cleanEnv(): NodeJS.ProcessEnv {
const names = ["HOME", "PATH", "SHELL", "TMPDIR", "LANG", "LC_ALL", "USER", "LOGNAME"];
const output: NodeJS.ProcessEnv = {};
for (const name of names) if (process.env[name]) output[name] = process.env[name];
Object.assign(output, {
NODE_NO_WARNINGS: "1",
NO_COLOR: "1",
DISABLE_AUTOUPDATER: "1",
CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
GEMINI_CLI_TRUST_WORKSPACE: "true",
GEMINI_SANDBOX: "false",
GEMINI_TELEMETRY_ENABLED: "false",
PAGER: "",
GIT_PAGER: "cat",
});
delete output.CLAUDECODE;
delete output.CLAUDE_CODE_CHILD_SESSION;
delete output.ANTHROPIC_API_KEY;
delete output.OPENAI_API_KEY;
return output;
}
function adapterSpec(runtime: Runtime, permissions: Permissions, choice: ModelChoice = {}): AdapterSpec {
const e = cleanEnv();
if (runtime === "claude") {
const modules = modulesWith("@agentclientprotocol/claude-agent-acp", CLAUDE_ADAPTER_VERSION);
const cli = which("claude");
if (cli === null) throw new Error("claude CLI is not installed");
e.CLAUDE_CODE_EXECUTABLE = realpathSync(cli);
return {
command: process.execPath,
args: [join(modules, "@agentclientprotocol/claude-agent-acp/dist/index.js")],
env: e,
version: CLAUDE_ADAPTER_VERSION,
name: "@agentclientprotocol/claude-agent-acp",
};
}
if (runtime === "codex") {
const modules = modulesWith("@agentclientprotocol/codex-acp", CODEX_ADAPTER_VERSION);
const cli = which("codex");
if (cli === null) throw new Error("codex CLI is not installed");
e.CODEX_PATH = realpathSync(cli);
// CODEX_CONFIG is an OVERRIDE layer over the person's own ~/.codex/config.toml
// (codex-acp merges it into the thread config). A key that is absent here is a key
// codex reads from their file — so no model and no effort is named unless the caller
// asked for one. Anything named is only a seed; the protocol road below is the truth.
const codexConfig: JsonObject = {
approval_policy: permissions === "allow-all" ? "never" : "on-request",
sandbox_mode: permissions === "allow-all" ? "danger-full-access" : "workspace-write",
};
if (choice.model !== undefined) codexConfig.model = choice.model;
if (choice.effort !== undefined) codexConfig.model_reasoning_effort = choice.effort;
e.CODEX_CONFIG = JSON.stringify(codexConfig);
return {
command: process.execPath,
args: [join(modules, "@agentclientprotocol/codex-acp/dist/index.js")],
env: e,
version: CODEX_ADAPTER_VERSION,
name: "@agentclientprotocol/codex-acp",
};
}
const cli = which("gemini");
if (cli === null) throw new Error("gemini CLI is not installed");
const gemini = optionalEnv("GEMINI_API_KEY");
const google = optionalEnv("GOOGLE_API_KEY");
if (gemini) e.GEMINI_API_KEY = gemini;
if (google) e.GOOGLE_API_KEY = google;
return { command: realpathSync(cli), args: ["--acp"], env: e, version: "built-in", name: "gemini" };
}
function spawnAdapter(runtime: Runtime, cwd: string, permissions: Permissions, choice: ModelChoice): { process: ChildProcess; spec: AdapterSpec } {
const spec = adapterSpec(runtime, permissions, choice);
const child = spawn(spec.command, spec.args, { cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
return { process: child, spec };
}
function descendants(pid: number): number[] {
const result = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
const children = (result.stdout || "").split("\n").map(Number).filter(Boolean);
return children.flatMap((child) => [...descendants(child), child]);
}
function signalTree(pid: number, signal: NodeJS.Signals): void {
if (pid <= 0) return;
const tree = descendants(pid);
try { process.kill(-pid, signal); } catch { /* group already gone */ }
for (const child of tree.reverse()) try { process.kill(child, signal); } catch { /* child already gone */ }
try { process.kill(pid, signal); } catch { /* leader already gone */ }
}
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`${label}: no response in ${ms} ms`)), ms); }),
]);
} finally { if (timer !== undefined) clearTimeout(timer); }
}
function normalizeUpdate(params: JsonObject): JsonObject {
const update = asObject(params.update);
const kind = String(update.sessionUpdate ?? "session_update");
if (kind === "available_commands_update") {
const commands = Array.isArray(update.availableCommands) ? update.availableCommands.map(asObject) : [];
const names = commands.map((command) => String(command.name ?? "")).filter(Boolean);
return { step: kind, count: names.length, commands: names.slice(0, 25), omitted: Math.max(0, names.length - 25) };
}
if (kind === "agent_message_chunk") return { step: "answer_delta", update };
if (kind === "agent_thought_chunk") return { step: "thought_delta", update };
if (kind === "tool_call" || kind === "tool_call_update") return { step: kind, update };
if (kind === "plan") return { step: "plan", update };
if (kind.includes("usage")) return { step: "usage", update };
return { step: kind, update };
}
function choosePermission(options: JsonObject[], requested: string): string | null {
const exact = options.find((option) => option.optionId === requested || option.kind === requested);
if (exact) return String(exact.optionId);
if (requested === "allow" || requested === "allow-all") {
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
return allow ? String(allow.optionId) : null;
}
if (requested === "reject") {
const reject = options.find((option) => option.kind === "reject_once") ?? options.find((option) => option.kind === "reject_always");
return reject ? String(reject.optionId) : null;
}
return null;
}
async function worker(key: string): Promise<void> {
const meta = readMetaKey(key);
if (meta === null) throw new Error(`worker state ${key} is missing`);
let adapter: ChildProcess | null = null;
let acp: AcpModule | null = null;
let connection: AcpConnection | null = null;
let context: AcpContext | null = null;
let turnPromise: Promise<unknown> | null = null;
let turnText = "";
const queue: Array<{ id: string; text: string }> = [];
let draining = false;
let pendingPermission: { params: JsonObject; resolve: (value: unknown) => void } | null = null;
let promptMethod = "";
let cancelMethod = "";
let stopping = false;
let finishStop: (() => void) | null = null;
const stopDone = new Promise<void>((resolveStop) => { finishStop = resolveStop; });
const fail = (error: unknown): void => {
meta.status = "failed";
meta.error = textOf(error);
appendEvent(meta, "error", { error: meta.error });
};
const handleUpdate = (params: JsonObject): void => {
const normalized = normalizeUpdate(params);
const update = asObject(params.update);
if (update.sessionUpdate === "agent_message_chunk") {
const content = asObject(update.content);
if (content.type === "text") turnText += String(content.text ?? "");
}
if (update.sessionUpdate === "config_option_update") {
observeConfig(meta, Array.isArray(update.configOptions) ? update.configOptions.map(asObject) as ConfigOption[] : []);
}
appendEvent(meta, "step", normalized);
};
const requestPermission = async (params: JsonObject): Promise<unknown> => {
const options = Array.isArray(params.options) ? params.options.map(asObject) : [];
appendEvent(meta, "permission_ask", { toolCall: params.toolCall ?? null, options });
if (meta.permissions === "allow-all") {
const optionId = choosePermission(options, "allow");
const outcome = optionId === null ? { outcome: "cancelled" } : { outcome: "selected", optionId };
appendEvent(meta, "permission_answer", { answer: outcome, automatic: true });
return { outcome };
}
meta.status = "waiting_permission";
writeMeta(meta);
return await new Promise((resolvePermission) => { pendingPermission = { params, resolve: resolvePermission }; });
};
const answerPermission = (answer: string): JsonObject => {
if (pendingPermission === null) return { ok: false, error: "no permission ask is pending" };
const held = pendingPermission;
const options = Array.isArray(held.params.options) ? held.params.options.map(asObject) : [];
const optionId = choosePermission(options, answer);
const outcome = answer === "cancelled" || optionId === null
? { outcome: "cancelled" }
: { outcome: "selected", optionId };
pendingPermission = null;
meta.status = "running";
appendEvent(meta, "permission_answer", { answer: outcome, automatic: false });
held.resolve({ outcome });
return { ok: true, outcome };
};
const runTurn = async (command: { id: string; text: string }): Promise<void> => {
if (context === null || meta.sessionId === null) throw new Error("ACP session is not ready");
meta.status = "running";
meta.currentTurn = command.id;
turnText = "";
appendEvent(meta, "turn_started", { command_id: command.id, text: command.text });
const request = context.request<JsonObject>(promptMethod, {
sessionId: meta.sessionId,
prompt: [{ type: "text", text: command.text }],
});
turnPromise = request;
try {
const response = await request;
appendEvent(meta, "turn_done", {
command_id: command.id,
stop_reason: response.stopReason ?? "unknown",
answer: turnText,
response,
});
} catch (error) {
appendEvent(meta, "turn_failed", { command_id: command.id, error: textOf(error), answer: turnText });
} finally {
turnPromise = null;
meta.currentTurn = null;
meta.status = "idle";
writeMeta(meta);
}
};
const drain = async (): Promise<void> => {
if (draining) return;
draining = true;
try { while (queue.length > 0) await runTurn(queue.shift()!); }
finally { draining = false; }
};
const stop = async (): Promise<void> => {
const started = Date.now();
stopping = true;
meta.status = "cancelling";
appendEvent(meta, "cancel_requested", {});
if (pendingPermission !== null) {
const held = pendingPermission;
pendingPermission = null;
held.resolve({ outcome: { outcome: "cancelled" } });
}
if (context !== null && meta.sessionId !== null) {
try { await context.notify(cancelMethod, { sessionId: meta.sessionId }); } catch { /* adapter may already be gone */ }
}
if (turnPromise !== null) await Promise.race([turnPromise.catch(() => undefined), sleep(1_100)]);
if (adapter?.pid) signalTree(adapter.pid, "SIGTERM");
await sleep(150);
if (adapter?.pid && processAlive(adapter.pid)) signalTree(adapter.pid, "SIGKILL");
meta.status = "cancelled";
appendEvent(meta, "cancelled", { elapsed_ms: Date.now() - started });
finishStop?.();
};
const respond = (socket: Socket, value: unknown): void => {
socket.end(`${JSON.stringify(value)}\n`);
};
const commandServer = createServer((socket) => {
let input = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => { input += chunk; });
socket.on("end", () => {
void (async () => {
let command: WorkerCommand;
try { command = JSON.parse(input) as WorkerCommand; }
catch { respond(socket, { ok: false, error: "invalid worker command" }); return; }
if (command.command === "ping") { respond(socket, { ok: true, meta }); return; }
if (command.command === "answer") { respond(socket, answerPermission(String(command.answer ?? ""))); return; }
if (command.command === "prompt") {
const text = String(command.text ?? "").trim();
if (text === "") { respond(socket, { ok: false, error: "prompt text is required" }); return; }
const id = randomUUID();
queue.push({ id, text });
respond(socket, { ok: true, command_id: id, cursor: meta.nextSeq - 1 });
void drain();
return;
}
if (command.command === "config") {
if (acp === null || context === null || meta.sessionId === null) { respond(socket, { ok: false, error: "session is not ready" }); return; }
const choice: ModelChoice = {};
if (command.model !== undefined) choice.model = String(command.model);
if (command.effort !== undefined) choice.effort = String(command.effort);
meta.asked = { ...(meta.asked ?? {}), ...choice };
await applyChoice(acp, context, meta, meta.sessionId, meta.configOptions ?? [], choice);
appendEvent(meta, "config", { asked: meta.asked, model: meta.model ?? null, effort: meta.effort ?? null, config_from: meta.configHow ?? null });
respond(socket, { ok: true, model: meta.model ?? null, effort: meta.effort ?? null, config_from: meta.configHow ?? null });
return;
}
if (command.command === "cancel") {
await stop();
respond(socket, { ok: true, elapsed_ms: Date.now() - Date.parse(meta.updatedAt) });
commandServer.close();
setTimeout(() => process.exit(0), 10).unref();
return;
}
respond(socket, { ok: false, error: "unknown worker command" });
})().catch((error) => respond(socket, { ok: false, error: textOf(error) }));
});
});
try {
acp = await loadAcp();
promptMethod = acp.methods.agent.session.prompt;
cancelMethod = acp.methods.agent.session.cancel;
const launched = spawnAdapter(meta.runtime, meta.cwd, meta.permissions, meta.asked ?? {});
adapter = launched.process;
meta.adapterPid = adapter.pid ?? null;
writeMeta(meta);
adapter.stderr!.setEncoding("utf8");
let stderrBuffer = "";
adapter.stderr!.on("data", (chunk: string) => {
stderrBuffer += chunk;
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines.filter(Boolean)) appendEvent(meta, "runtime_log", { line: line.slice(0, 2_000) });
});
const exited = new Promise<never>((_, reject) => {
adapter!.once("exit", (code, signal) => reject(new Error(`adapter exited (${code ?? signal})`)));
adapter!.once("error", reject);
});
const output = Writable.toWeb(adapter.stdin!) as WritableStream<Uint8Array>;
const input = Readable.toWeb(adapter.stdout!) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(output, input);
const client = acp.client({ name: CLIENT_INFO.name })
.onRequest(acp.methods.client.session.requestPermission, (request) => requestPermission(request.params))
.onRequest(acp.methods.client.session.update, asObject, (request) => { handleUpdate(request.params); return null; })
.onNotification(acp.methods.client.session.update, (notification) => handleUpdate(notification.params));
connection = client.connect(stream);
context = connection.agent;
const initialize = context.request<JsonObject>(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
auth: { terminal: true },
},
clientInfo: CLIENT_INFO,
});
const initialized = await withTimeout(Promise.race([initialize, exited]), 120_000, "initialize");
appendEvent(meta, "initialized", {
protocol_version: initialized.protocolVersion,
agent: initialized.agentInfo ?? null,
capabilities: initialized.agentCapabilities ?? null,
sdk: `@agentclientprotocol/sdk@${ACP_SDK_VERSION}`,
});
if (meta.runtime === "gemini") {
const keyValue = optionalEnv("GEMINI_API_KEY") || optionalEnv("GOOGLE_API_KEY");
if (keyValue) await context.request(acp.methods.agent.authenticate, { methodId: "gemini-api-key", _meta: { "api-key": keyValue } });
}
const previousId = meta.sessionId;
const sessionRequest = agentHostNewSessionRequest(meta.cwd);
let opened: JsonObject | null = null;
let how: SessionMeta["sessionHow"] = null;
if (previousId !== null) {
try {
opened = await context.request<JsonObject>(acp.methods.agent.session.resume, { ...sessionRequest, sessionId: previousId });
how = "resumed";
} catch {
try {
opened = await context.request<JsonObject>(acp.methods.agent.session.load, { ...sessionRequest, sessionId: previousId });
how = "loaded";
} catch { /* a new session is the final supported road */ }
}
}
if (opened === null) {
opened = await context.request<JsonObject>(acp.methods.agent.session.new, sessionRequest);
how = "new";
}
meta.sessionId = String(opened.sessionId ?? previousId ?? "");
if (meta.sessionId === "") throw new Error("agent returned no sessionId");
meta.sessionHow = how;
const advertised = Array.isArray(opened.configOptions) ? opened.configOptions.map(asObject) as ConfigOption[] : [];
await applyChoice(acp, context, meta, meta.sessionId, advertised, meta.asked ?? {});
meta.status = "idle";
appendEvent(meta, "session", {
runtime: meta.runtime,
cwd: meta.cwd,
permissions: meta.permissions,
how,
adapter: { name: launched.spec.name, version: launched.spec.version },
setting_sources: sessionRequest._meta.claudeCode.options.settingSources,
asked: meta.asked ?? {},
model: meta.model ?? null,
effort: meta.effort ?? null,
config_from: meta.configHow ?? null,
config_options: (meta.configOptions ?? []).map((option) => ({ id: option.id, category: option.category ?? null, current: agentHostCurrentValue(option) })),
});
rmSync(meta.socket, { force: true });
await new Promise<void>((resolveListen, rejectListen) => {
commandServer.once("error", rejectListen);
commandServer.listen(meta.socket, () => { chmodSync(meta.socket, 0o600); resolveListen(); });
});
try { await Promise.race([connection.closed, exited]); }
catch (error) {
if (stopping) { await stopDone; await sleep(50); }
else throw error;
}
} catch (error) {
if (!stopping && meta.status !== "cancelled") fail(error);
} finally {
try { commandServer.close(); } catch { /* not listening */ }
rmSync(meta.socket, { force: true });
if (adapter?.pid && processAlive(adapter.pid)) signalTree(adapter.pid, "SIGKILL");
if (meta.status !== "cancelled" && meta.status !== "failed") fail("agent host worker stopped");
}
}
function sendCommand(meta: SessionMeta, command: WorkerCommand, timeoutMs = 5_000): Promise<JsonObject> {
return new Promise((resolveCommand, rejectCommand) => {
const socket = createConnection(meta.socket);
let output = "";
const timer = setTimeout(() => { socket.destroy(); rejectCommand(new Error(`session command timed out after ${timeoutMs} ms`)); }, timeoutMs);
socket.setEncoding("utf8");
socket.on("connect", () => socket.end(JSON.stringify(command)));
socket.on("data", (chunk) => { output += chunk; });
socket.on("error", (error) => { clearTimeout(timer); rejectCommand(error); });
socket.on("close", () => {
clearTimeout(timer);
try {
const answer = JSON.parse(output.trim()) as JsonObject;
if (answer.ok === false) rejectCommand(new Error(String(answer.error ?? "worker refused command")));
else resolveCommand(answer);
} catch (error) { rejectCommand(error); }
});
});
}
async function waitReady(key: string, timeoutMs = 120_000): Promise<SessionMeta> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const meta = readMetaKey(key);
if (meta?.status === "failed") throw new Error(meta.error ?? "agent host failed");
if (meta?.sessionId && existsSync(meta.socket) && processAlive(meta.pid)) return meta;
await sleep(50);
}
throw new Error(`agent host did not become ready in ${timeoutMs} ms`);
}
async function waitCommand(meta: SessionMeta, commandId: string, timeoutMs = COMMAND_TIMEOUT_MS): Promise<SessionMeta> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const current = readMetaKey(meta.key) ?? meta;
const done = eventsFor(current).some((event) =>
(event.type === "turn_done" || event.type === "turn_failed") && event.command_id === commandId);
if (done) return current;
if (current.status === "failed") throw new Error(current.error ?? "agent host failed");
if (!processAlive(current.pid)) throw new Error("agent host worker exited before the turn finished");
await sleep(100);
}
throw new Error(`prompt did not finish in ${timeoutMs} ms`);
}
/* ── THE FACE THIS READ DRAWS ⟨lane family-reads, 2026-09-09⟩ ──────────────
* The `agent` family draws `agent-run` — "the black box, opened" — and NO read
* reached it: the runner's name route is exactly `snappy-<family>` and this
* hand is `snappy-agent-host`, so the derivation could not find it, while this
* hand is the only thing in the kernel that HAS an agent run to draw.
*
* `status` is the read. Its ndjson page is unchanged — it is what a caller
* following a live run reads, one event at a time — and `--json` folds the
* SAME events into the one object the face binds to. Two spellings of one
* read, never two reads.
*
* WHAT IS A STEP IS DECIDED STRUCTURALLY, by the event's own type, never by an
* allowlist of tool names: `step` lines carrying an ACP `tool_call` are the
* work, permission asks are the waits, and a failure is a step that says so.
* Chunk deltas (`answer_delta`, `thought_delta`) are NOT steps — a run's
* thinking arrives as hundreds of them and a face drawing one row per token is
* a face nobody can read.
*/
export interface AgentRunStep {
readonly words: string;
readonly tool?: string;
readonly result?: string;
readonly state?: string;
readonly tookMs?: number;
}
export interface AgentRunAnswer {
readonly title: string;
readonly agentName: string;
readonly state: string;
readonly startedAt?: string;
readonly tookWords?: string;
readonly steps: readonly AgentRunStep[];
}
/** The session's own status word in the face's vocabulary. `idle` is the one
* that needs saying out loud: a session goes idle when its turn FINISHED, so
* the run is done — a face drawing "idle" over a finished run would make a
* person wait for something that already happened ⟨CLAUDE.md §10⟩. */
export function agentRunState(status: string): string {
if (status === "idle") return "done";
if (status === "waiting_permission") return "waiting";
if (status === "cancelling") return "running";
return status;
}
/** How long, in the words a person says it in. */
export function tookWords(ms: number): string {
if (ms < 1_000) return `${Math.round(ms)} ms`;
const seconds = Math.round(ms / 1_000);
if (seconds < 60) return `${seconds}s`;
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
function firstLine(text: unknown, limit = 160): string | undefined {
if (typeof text !== "string") return undefined;
const line = text.trim().split("\n").find((word) => word.trim() !== "");
if (line === undefined) return undefined;
return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
}
/** One ACP tool call as a step. The ACP `toolCall`/`update` object names the
* work in `title`, the tool in `kind` or `rawInput.command`, and how it went
* in `status`; a call that names none of them yields no step rather than a
* blank row. */
function toolStep(update: JsonObject): AgentRunStep | null {
const words = firstLine(update.title) ?? firstLine(asObject(update.rawInput).description);
if (words === undefined) return null;
const raw = asObject(update.rawInput);
const tool = firstLine(raw.command, 80) ?? (typeof update.kind === "string" ? update.kind : undefined);
const content = Array.isArray(update.content) ? update.content.map(asObject) : [];
const result = firstLine(content.map((part) => asObject(part.content).text ?? part.text).find((text) => typeof text === "string"));
const status = typeof update.status === "string" ? update.status : undefined;
return {
words,
...(tool === undefined ? {} : { tool }),
...(result === undefined ? {} : { result }),
...(status === undefined ? {} : { state: status === "completed" ? "done" : status === "failed" ? "failed" : "running" }),
};
}
/** THE ONE FOLD into the drawable kind ⟨snappy-faces/dist/build-report.json:
* `agent-run` → AgentRun{title, steps[], agentName?, state?, startedAt?,
* tookWords?}⟩. Over the session's own meta and the events it already wrote. */
export function agentRunFace(
meta: { runtime: string; cwd: string; status: string; createdAt: string; updatedAt: string; sessionId?: string | null },
events: readonly JsonObject[],
): AgentRunAnswer {
const steps: AgentRunStep[] = [];
// The run's TITLE is the person's own first sentence to it, never a summary
// written here. A session resumed with no turn on record has no title of its
// own, and says so with the folder it runs in.
let title: string | undefined;
const seen = new Map<string, number>();
for (const event of events) {
const type = String(event.type ?? "");
if (type === "turn_started" && title === undefined) title = firstLine(event.text, 120);
if (type === "step") {
const kind = String(event.step ?? "");
if (kind !== "tool_call" && kind !== "tool_call_update") continue;
const update = asObject(event.update);
const step = toolStep(update);
if (step === null) continue;
// A tool call and its updates are ONE step, not four: ACP repeats the
// same `toolCallId` as the call runs. The later line wins, because it is
// the one that knows how the call went.
const id = typeof update.toolCallId === "string" ? update.toolCallId : `#${steps.length}`;
const at = seen.get(id);
if (at === undefined) { seen.set(id, steps.length); steps.push(step); }
else steps[at] = { ...steps[at]!, ...step };
continue;
}
if (type === "permission_ask") {
const call = asObject(event.toolCall);
steps.push({ words: firstLine(call.title) ?? "Asked for permission", state: "waiting" });
continue;
}
if (type === "turn_failed" || type === "error") {
steps.push({ words: "The turn failed", state: "failed", ...(firstLine(event.error) === undefined ? {} : { result: firstLine(event.error)! }) });
continue;
}
if (type === "cancelled") steps.push({ words: "Cancelled", state: "cancelled" });
}
const started = Date.parse(meta.createdAt);
const ended = Date.parse(meta.updatedAt);
const elapsed = Number.isFinite(started) && Number.isFinite(ended) && ended >= started ? ended - started : null;
return {
title: title ?? `Session in ${meta.cwd}`,
agentName: `${meta.runtime} · ${meta.cwd.split("/").filter(Boolean).at(-1) ?? meta.cwd}`,
state: agentRunState(meta.status),
// The stored stamp, cut at the minute — never re-formatted through a
// locale, which would draw a different sentence on a different Mac.
...(meta.createdAt === "" ? {} : { startedAt: meta.createdAt.slice(0, 16).replace("T", " ") }),
...(elapsed === null ? {} : { tookWords: tookWords(elapsed) }),
steps,
};
}
function printPage(meta: SessionMeta, cursor = 0): void {
const all = eventsFor(meta).filter((event) => event.seq > cursor);
const page: EventLine[] = [];
let bytes = 0;
for (const event of all) {
const lineBytes = Buffer.byteLength(JSON.stringify(event)) + 1;
if (page.length > 0 && (page.length >= PAGE_EVENTS || bytes + lineBytes > PAGE_BYTES)) break;
page.push(event);
bytes += lineBytes;
}
for (const event of page) console.log(JSON.stringify(event));
const nextCursor = page.length === 0 ? null : page.at(-1)!.seq;
const more = all.length > page.length;
console.log(JSON.stringify({
type: "page",
session: meta.sessionId,
runtime: meta.runtime,
cwd: meta.cwd,
cursor,
next_cursor: more ? nextCursor : null,
status: meta.status,
events: page.length,
}));
}
async function launchWorker(meta: SessionMeta): Promise<void> {
const script = fileURLToPath(import.meta.url);
const child = spawn(process.execPath, ["--experimental-strip-types", script, "__worker", meta.key], {
cwd: dirname(script),
env: cleanEnv(),
stdio: "ignore",
detached: true,
});
if (!child.pid) throw new Error("agent host worker did not start");
meta.pid = child.pid;
writeMeta(meta);
child.unref();
}
/** Start or reuse the one durable ACP session for a folder. The one session road. */
async function openAgentHostSession(runtime: Runtime, cwdInput: string, permissions: Permissions, choice: ModelChoice): Promise<{ meta: SessionMeta; cursor: number }> {
ensureState();
const cwd = realpathSync(resolve(cwdInput));
if (!statSync(cwd).isDirectory()) throw new Error(`cwd is not a folder: ${cwd}`);
const key = sessionKey(cwd);
mkdirSync(sessionDir(key), { recursive: true, mode: 0o700 });
const existing = readMetaKey(key);
const persistedNextSeq = nextPersistedSeq(existing, key);
const cursor = persistedNextSeq - 1;
let meta: SessionMeta;
if (existing !== null && processAlive(existing.pid) && existsSync(existing.socket)) {
if (existing.runtime !== runtime) throw new Error(`${cwd} already has durable ${existing.runtime} session ${existing.sessionId}; cancel it before changing runtime`);
meta = existing;
// A live adapter cannot be re-spawned with a new seed, so the ask goes over the protocol.
if (choice.model !== undefined || choice.effort !== undefined) {
await sendCommand(meta, { command: "config", ...choice });
meta = readMetaKey(key) ?? meta;
}
} else {
const created = existing?.createdAt ?? now();
meta = {
version: 1,
key,
runtime,
cwd,
permissions,
pid: 0,
adapterPid: null,
socket: socketPath(key),
sessionId: agentHostResumeId(existing, runtime),
sessionHow: null,
status: "starting",
createdAt: created,
updatedAt: now(),
nextSeq: persistedNextSeq,
currentTurn: null,
asked: choice,
model: null,
effort: null,
configHow: null,
};
writeMeta(meta);
await launchWorker(meta);
meta = await waitReady(key);
}
return { meta, cursor };
}
/** Start or reuse the one durable ACP session for a folder and run its first prompt. */
export async function startAgentHostSession(runtime: Runtime, cwdInput: string, promptText: string, permissions: Permissions = "ask", choice: ModelChoice = {}): Promise<{ meta: SessionMeta; cursor: number; commandId: string }> {
const opened = await openAgentHostSession(runtime, cwdInput, permissions, choice);
const accepted = await sendCommand(opened.meta, { command: "prompt", text: promptText });
const commandId = String(accepted.command_id);
const meta = await waitCommand(opened.meta, commandId);
return { meta, cursor: opened.cursor, commandId };
}
/**
* What THE RUNTIME advertises it can be set to, read off a real session over ACP.
* This skill keeps no model list of its own, so this answer can never go stale.
*/
export async function agentHostModels(runtime: Runtime): Promise<JsonObject> {
ensureState();
const probe = join(STATE_ROOT, "probe", runtime);
mkdirSync(probe, { recursive: true, mode: 0o700 });
const { meta } = await openAgentHostSession(runtime, probe, "ask", {});
try {
const options = meta.configOptions ?? [];
const advertised = options.length > 0;
return {
type: "models",
runtime,
adapter: adapterSpec(runtime, "ask").name,
state: advertised ? "advertised" : "not-advertised",
current: { model: meta.model ?? null, effort: meta.effort ?? null },
model_option: agentHostOptionFor(options, MODEL_CATEGORY)?.id ?? null,
effort_option: agentHostOptionFor(options, EFFORT_CATEGORY)?.id ?? null,
options: options.map((option) => ({
id: option.id,
name: option.name ?? option.id,
category: option.category ?? null,
type: option.type ?? null,
current: agentHostCurrentValue(option),
values: agentHostOptionValues(option),
})),
...(advertised || runtime !== "codex" ? {} : { codex_cli_cache: codexCliModelCache() }),
};
} finally {
await cancelAgentHostSession(meta.key);
}
}
/** Prompt a durable session. answer resolves a pending permission before the text is queued. */
export async function promptAgentHostSession(session: string, text: string, answer?: string): Promise<{ meta: SessionMeta; cursor: number; commandId: string }> {
let meta = findSession(session);
if (meta === null) throw new Error(`unknown session: ${session}`);
if (!processAlive(meta.pid) || !existsSync(meta.socket)) throw new Error(`session ${session} is not running`);
if (answer !== undefined) await sendCommand(meta, { command: "answer", answer });
const accepted = await sendCommand(meta, { command: "prompt", text });
const cursor = Number(accepted.cursor ?? 0);
const commandId = String(accepted.command_id);
meta = await waitCommand(meta, commandId);
return { meta, cursor, commandId };
}
function failedTurn(meta: SessionMeta, commandId: string): EventLine | null {
return eventsFor(meta).find((event) => event.type === "turn_failed" && event.command_id === commandId) ?? null;
}
/** Stop the ACP turn and its complete local process tree within two seconds. */
export async function cancelAgentHostSession(session: string): Promise<{ meta: SessionMeta; elapsedMs: number }> {
const started = Date.now();
let meta = findSession(session);
if (meta === null) throw new Error(`unknown session: ${session}`);
try { await sendCommand(meta, { command: "cancel" }, 1_700); } catch { /* hard stop below is authoritative */ }
meta = readMetaKey(meta.key) ?? meta;
if (processAlive(meta.pid)) signalTree(meta.pid, "SIGKILL");
if (meta.adapterPid !== null && processAlive(meta.adapterPid)) signalTree(meta.adapterPid, "SIGKILL");
const deadline = started + 2_000;
while (processAlive(meta.pid) && Date.now() < deadline) await sleep(20);
meta.status = "cancelled";
writeMeta(meta);
return { meta, elapsedMs: Date.now() - started };
}
export function listAgentHostSessions(): SessionMeta[] {
return readAllMetas().map((meta) => ({
...meta,
status: processAlive(meta.pid) ? meta.status : (meta.status === "failed" ? "failed" : "cancelled"),
}));
}
function arg(args: string[], index: number, name: string): string {
const value = args[index];
if (value === undefined || value === "") throw new Error(`${name} is required`);
return value;
}
function flag(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
return index < 0 ? undefined : args[index + 1];
}
export const HAND_CONTRACT = {
skill: "snappy-agent-host",
description: "Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable per-folder sessions and every tool call, diff, plan, permission ask, answer, and cancellation printed as paged JSON lines. Also holds the full native SnappyOS.app ACP host research and proven launch constraints. Use for start/prompt/status/cancel/sessions, hosted coding agents, agent client protocol, or showing every agent step instead of a black box. NOT building an MCP server (see mcp-server-builder). Triggers on: ACP, agent client protocol, claude-agent-acp, codex-acp, app-server, host agent, embed CLI, durable agent session, agent steps.",
managed: true,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "invalid_argument"),
verbs: {
start: {
args: ["runtime", "cwd", "prompt"], effect: "write-reversible", flags: { permissions: "--permissions", model: "--model", effort: "--effort" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { runtime: { type: "string", description: "Which agent CLI to run over ACP", enum: ["claude", "codex", "gemini"] }, cwd: { type: "string", description: "Absolute path of the folder the session runs IN; the repo is the team" }, prompt: { type: "string", description: "The first turn's text, sent once the session is up" } } },
},
prompt: {
args: ["session", "text"], effect: "write-reversible", flags: { answer: "--answer" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { session: { type: "string", description: "Session id returned by start" }, text: { type: "string", description: "The turn to send to the running session" } } },
},
status: {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The family
// is `agent`; this hand is `snappy-agent-host`, so the runner's name
// route (`snappy-<family>`) could not reach it, while this hand is the
// only thing in the kernel that HAS an agent run to draw.
face: "agent-run",
args: ["session"], effect: "read", flags: { cursor: "--cursor", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { session: { type: "string", description: "Session id returned by start" } } },
},
cancel: {
args: ["session"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true, idempotent: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
inputSchema: { properties: { session: { type: "string", description: "Session id to stop; an already-stopped session answers the same" } } },
},
sessions: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
models: {
args: ["runtime"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { runtime: { type: "string", description: "Runtime whose own advertised model list is read", enum: ["claude", "codex", "gemini"] } } },
},
},
notes: {
model: "Omit --model/--effort and the runtime uses the person's own config (codex: ~/.codex/config.toml). This skill pins no model. `models <runtime>` lists what the runtime itself advertises; start/status/sessions report the model the runtime says it is running, not the one that was asked for.",
},
} as const;
const invokedDirectly = (() => {
try { return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
catch { return false; }
})();
if (invokedDirectly && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (invokedDirectly && process.argv[2] === "__worker") {
void worker(arg(process.argv.slice(2), 1, "session key")).catch((error) => {
console.error(textOf(error));
process.exit(1);
});
}
if (invokedDirectly && process.argv[2] !== "__worker" && process.argv[2] !== "contract") {
void (async () => {
const [verb, ...args] = process.argv.slice(2);
if (verb === "start") {
const runtime = arg(args, 0, "runtime");
const permissions = flag(args, "--permissions") ?? "ask";
if (!isRuntime(runtime)) throw new Error("runtime must be claude, codex, or gemini");
if (!isPermissions(permissions)) throw new Error("permissions must be allow-all or ask");
const result = await startAgentHostSession(runtime, arg(args, 1, "cwd"), arg(args, 2, "prompt"), permissions, agentHostModelChoice(args));
printPage(result.meta, result.cursor);
const failure = failedTurn(result.meta, result.commandId);
if (failure !== null) { console.error(`turn_failed: ${String(failure.error ?? "agent turn failed")}`); process.exitCode = 1; }
return;
}
if (verb === "prompt") {
const result = await promptAgentHostSession(arg(args, 0, "session"), arg(args, 1, "text"), flag(args, "--answer"));
printPage(result.meta, result.cursor);
const failure = failedTurn(result.meta, result.commandId);
if (failure !== null) { console.error(`turn_failed: ${String(failure.error ?? "agent turn failed")}`); process.exitCode = 1; }
return;
}
if (verb === "status") {
const meta = findSession(arg(args, 0, "session"));
if (meta === null) throw new Error(`unknown session: ${args[0]}`);
const cursor = Number(flag(args, "--cursor") ?? 0);
if (!Number.isInteger(cursor) || cursor < 0) throw new Error("cursor must be a non-negative integer");
// `--json` FOLDS THE SAME EVENTS INTO THE FACE. The ndjson page below is
// untouched: it is what a caller following a live run reads.
if (args.includes("--json")) {
console.log(JSON.stringify(agentRunFace(meta, eventsFor(meta)), null, 2));
return;
}
printPage(meta, cursor);
return;
}
if (verb === "cancel") {
const result = await cancelAgentHostSession(arg(args, 0, "session"));
console.log(JSON.stringify({ type: "cancelled", session: result.meta.sessionId, cwd: result.meta.cwd, elapsed_ms: result.elapsedMs, stopped_within_2s: result.elapsedMs <= 2_000 }));
return;
}
if (verb === "models") {
const runtime = arg(args, 0, "runtime");
if (!isRuntime(runtime)) throw new Error("runtime must be claude, codex, or gemini");
console.log(JSON.stringify(await agentHostModels(runtime), null, 2));
return;
}
if (verb === "sessions") {
for (const meta of listAgentHostSessions()) console.log(JSON.stringify({
type: "session",
session: meta.sessionId,
runtime: meta.runtime,
cwd: meta.cwd,
status: meta.status,
how: meta.sessionHow,
model: meta.model ?? null,
effort: meta.effort ?? null,
config_from: meta.configHow ?? null,
pid: processAlive(meta.pid) ? meta.pid : null,
updated_at: meta.updatedAt,
}));
return;
}
throw new Error("verbs: start, prompt, status, cancel, sessions, models");
})().catch((error) => {
console.error(textOf(error));
process.exit(1);
});
}
#!/usr/bin/env node
/**
* snappy-agent-host: durable ACP sessions for Claude Code, Codex, and Gemini.
*
* The wire is owned by @agentclientprotocol/sdk@1.4.0 from the already-installed
* SnappyOS.app runtime. This file never implements JSON-RPC or ACP framing.
*/
import { env } from "../snappy-settings/load.ts";
import { createHash, randomUUID } from "node:crypto";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import {
appendFileSync,
chmodSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { createConnection, createServer, type Socket } from "node:net";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import { Readable, Writable } from "node:stream";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
export type Runtime = "claude" | "codex" | "gemini";
export type Permissions = "allow-all" | "ask";
/**
* What the caller asked the runtime for. An empty choice is the default and means
* "whatever the person's own runtime config already says" — this file pins no model.
*/
export type ModelChoice = { model?: string; effort?: string };
/** One `SessionConfigOption` exactly as the runtime advertised it over ACP. */
export type ConfigOption = {
id: string;
name?: string;
description?: string | null;
category?: string | null;
type?: string;
currentValue?: unknown;
options?: unknown;
};
/** How the runtime came to be running the model it reports. */
export type ConfigHow = "runtime-default" | "protocol" | "adapter-config" | "not-advertised";
type JsonObject = Record<string, unknown>;
type SessionRequest = {
cwd: string;
mcpServers: unknown[];
_meta: { claudeCode: { options: { settingSources: string[] } } };
};
type AcpContext = {
request<T = unknown>(method: string, params?: unknown, options?: { cancellationSignal?: AbortSignal }): Promise<T>;
notify(method: string, params?: unknown): Promise<void>;
};
type AcpConnection = {
agent: AcpContext;
close(error?: unknown): void;
closed: Promise<void>;
signal: AbortSignal;
};
type AcpClientApp = {
onRequest(method: string, handler: (context: { params: JsonObject }) => unknown): AcpClientApp;
onRequest(method: string, parser: (value: unknown) => JsonObject, handler: (context: { params: JsonObject }) => unknown): AcpClientApp;
onNotification(method: string, handler: (context: { params: JsonObject }) => unknown): AcpClientApp;
connect(stream: unknown): AcpConnection;
};
type AcpModule = {
PROTOCOL_VERSION: number;
methods: {
agent: {
initialize: string;
authenticate: string;
session: {
new: string;
load: string;
resume: string;
prompt: string;
cancel: string;
close: string;
setConfigOption: string;
};
};
client: { session: { requestPermission: string; update: string } };
};
ndJsonStream(output: WritableStream<Uint8Array>, input: ReadableStream<Uint8Array>): unknown;
client(options?: { name?: string }): AcpClientApp;
};
interface AdapterSpec {
command: string;
args: string[];
env: NodeJS.ProcessEnv;
version: string;
name: string;
}
interface SessionMeta {
version: 1;
key: string;
runtime: Runtime;
cwd: string;
permissions: Permissions;
pid: number;
adapterPid: number | null;
socket: string;
sessionId: string | null;
sessionHow: "new" | "resumed" | "loaded" | null;
status: "starting" | "idle" | "running" | "waiting_permission" | "cancelling" | "cancelled" | "failed";
createdAt: string;
updatedAt: string;
nextSeq: number;
currentTurn: string | null;
error?: string;
/** What the caller asked for, or null when the person's own runtime config decides. */
asked?: ModelChoice;
/** What the runtime REPORTS it is running, read back from its own config options. */
model?: string | null;
effort?: string | null;
configHow?: ConfigHow | null;
configOptions?: ConfigOption[];
}
interface EventLine extends JsonObject {
seq: number;
ts: string;
session: string | null;
type: string;
}
interface WorkerCommand extends JsonObject {
command: "ping" | "prompt" | "answer" | "cancel" | "config";
text?: string;
answer?: string;
model?: string;
effort?: string;
}
const STATE_ROOT = process.env.SNAPPY_AGENT_HOST_STATE || join(homedir(), ".snappy-agent-host");
const SESSION_ROOT = join(STATE_ROOT, "sessions");
const PAGE_BYTES = 12_000;
const PAGE_EVENTS = 80;
const COMMAND_TIMEOUT_MS = 300_000;
const APP_MODULES = "/Applications/SnappyOS.app/Contents/Resources/SnappyOS_SnappyOS.bundle/Resources/runtime/node_modules";
const CLAUDE_ADAPTER_VERSION = "0.73.0";
const CODEX_ADAPTER_VERSION = "1.8.0";
const ACP_SDK_VERSION = "1.4.0";
// ACP reserves these category names for the model picker and the reasoning-level picker
// (spec: session-config-options / model-config-category). We match on the CATEGORY, never
// on an id, so a runtime is free to rename or add ids without this skill going stale.
const MODEL_CATEGORY = "model";
const EFFORT_CATEGORY = "thought_level";
const CODEX_MODELS_CACHE = join(homedir(), ".codex", "models_cache.json");
const CLIENT_INFO = { name: "snappy-agent-host", title: "Snappy Agent Host", version: "2.0.0" };
function now(): string { return new Date().toISOString(); }
function sleep(ms: number): Promise<void> { return new Promise((done) => setTimeout(done, ms)); }
function asObject(value: unknown): JsonObject { return typeof value === "object" && value !== null ? value as JsonObject : {}; }
function textOf(error: unknown): string { return error instanceof Error ? error.message : String(error); }
function isRuntime(value: string): value is Runtime { return value === "claude" || value === "codex" || value === "gemini"; }
function isPermissions(value: string): value is Permissions { return value === "allow-all" || value === "ask"; }
function ensureState(): void {
mkdirSync(SESSION_ROOT, { recursive: true, mode: 0o700 });
try { chmodSync(STATE_ROOT, 0o700); chmodSync(SESSION_ROOT, 0o700); } catch { /* best effort on existing roots */ }
}
function sessionKey(cwd: string): string {
return createHash("sha256").update(cwd).digest("hex").slice(0, 20);
}
function sessionDir(key: string): string { return join(SESSION_ROOT, key); }
function metaPath(key: string): string { return join(sessionDir(key), "session.json"); }
function eventsPath(key: string): string { return join(sessionDir(key), "steps.ndjson"); }
function socketPath(key: string): string { return join(sessionDir(key), "host.sock"); }
function writeJsonAtomic(path: string, value: unknown): void {
const temp = `${path}.${process.pid}.tmp`;
writeFileSync(temp, JSON.stringify(value, null, 2), { mode: 0o600 });
renameSync(temp, path);
}
function readMetaKey(key: string): SessionMeta | null {
try { return JSON.parse(readFileSync(metaPath(key), "utf8")) as SessionMeta; } catch { return null; }
}
function writeMeta(meta: SessionMeta): void {
meta.updatedAt = now();
writeJsonAtomic(metaPath(meta.key), meta);
}
function processAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
}
function readAllMetas(): SessionMeta[] {
ensureState();
return readdirSync(SESSION_ROOT, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => readMetaKey(entry.name))
.filter((meta): meta is SessionMeta => meta !== null);
}
function findSession(id: string): SessionMeta | null {
return readAllMetas().find((meta) => meta.sessionId === id || meta.key === id) ?? null;
}
function appendEvent(meta: SessionMeta, type: string, fields: JsonObject = {}): EventLine {
const candidate = { seq: meta.nextSeq, ts: now(), session: meta.sessionId, type, ...fields };
const encoded = JSON.stringify(candidate);
const event: EventLine = encoded.length <= 9_000
? { ...candidate, seq: meta.nextSeq++ }
: {
seq: meta.nextSeq++, ts: candidate.ts, session: meta.sessionId, type,
truncated: true, bytes: Buffer.byteLength(encoded), preview: encoded.slice(0, 8_000),
};
appendFileSync(eventsPath(meta.key), `${JSON.stringify(event)}\n`, { mode: 0o600 });
writeMeta(meta);
return event;
}
function eventsFor(meta: SessionMeta): EventLine[] {
if (!existsSync(eventsPath(meta.key))) return [];
return readFileSync(eventsPath(meta.key), "utf8").split("\n").filter(Boolean).flatMap((line) => {
try { return [JSON.parse(line) as EventLine]; } catch { return []; }
});
}
function nextPersistedSeq(meta: SessionMeta | null, key: string): number {
let fromEvents = 1;
if (existsSync(eventsPath(key))) {
for (const line of readFileSync(eventsPath(key), "utf8").split("\n").filter(Boolean)) {
try { fromEvents = Math.max(fromEvents, Number((JSON.parse(line) as EventLine).seq) + 1); } catch { /* ignore a torn final line */ }
}
}
return Math.max(meta?.nextSeq ?? 1, fromEvents);
}
/** The proven hosted-repo request. cwd is identity and project settings stay visible. */
export function agentHostNewSessionRequest(cwd: string): SessionRequest {
if (!cwd.startsWith("/")) throw new Error("ACP session cwd must be absolute");
return {
cwd,
mcpServers: [],
_meta: { claudeCode: { options: { settingSources: ["user", "project", "local"] } } },
};
}
export function agentHostResumeId(
existing: Pick<SessionMeta, "runtime" | "status" | "sessionId"> | null,
runtime: Runtime,
): string | null {
return existing?.runtime === runtime && existing.status !== "cancelled" && existing.status !== "failed"
? existing.sessionId
: null;
}
/** Read `--model` / `--effort` off a verb's argv. Absent means: ask the runtime for nothing. */
export function agentHostModelChoice(args: string[]): ModelChoice {
const choice: ModelChoice = {};
for (const [name, key] of [["--model", "model"], ["--effort", "effort"]] as const) {
const index = args.indexOf(name);
if (index < 0) continue;
const value = args[index + 1];
if (value === undefined || value === "" || value.startsWith("--")) throw new Error(`${name} needs a value`);
choice[key] = value;
}
return choice;
}
/** The advertised option for an ACP category. Category, never id: ids are each runtime's own. */
export function agentHostOptionFor(options: ConfigOption[], category: string): ConfigOption | null {
return options.find((option) => option.category === category) ?? null;
}
/** What the runtime says it is currently running for that option. */
export function agentHostCurrentValue(option: ConfigOption | null): string | null {
const current = option?.currentValue;
return current === undefined || current === null ? null : String(current);
}
/** Every selectable value, flattening the grouped form of `SessionConfigSelectOptions`. */
export function agentHostOptionValues(option: ConfigOption): Array<{ value: string; name: string }> {
const entries = Array.isArray(option.options) ? option.options.map(asObject) : [];
const flat = entries.flatMap((entry) => (Array.isArray(entry.options) ? entry.options.map(asObject) : [entry]));
return flat
.map((entry) => ({ value: String(entry.value ?? ""), name: String(entry.name ?? "") }))
.filter((entry) => entry.value !== "");
}
/** Resolve what the caller asked for against what the runtime advertises. Never invent a value. */
export function agentHostResolveValue(option: ConfigOption, wanted: string): string {
const values = agentHostOptionValues(option);
const lower = wanted.toLowerCase();
const hit = values.find((entry) => entry.value === wanted)
?? values.find((entry) => entry.value.toLowerCase() === lower)
?? values.find((entry) => entry.name.toLowerCase() === lower);
if (hit !== undefined) return hit.value;
throw new Error(`${option.id} has no value ${wanted}; this runtime advertises: ${values.map((entry) => entry.value).join(", ")}`);
}
/**
* Record what the runtime REPORTS, never what we asked for. The current values come
* back inside the runtime's own config options, so the report is the artifact.
*/
function observeConfig(meta: SessionMeta, options: ConfigOption[], how?: ConfigHow): void {
meta.configOptions = options;
meta.model = agentHostCurrentValue(agentHostOptionFor(options, MODEL_CATEGORY));
meta.effort = agentHostCurrentValue(agentHostOptionFor(options, EFFORT_CATEGORY));
if (how !== undefined) meta.configHow = how;
}
/**
* Carry the caller's ask down the ACP road (`session/set_config_option`) and read back
* what the runtime then reports. With nothing asked, nothing is set and the person's own
* runtime config stands.
*/
async function applyChoice(
acp: AcpModule,
context: AcpContext,
meta: SessionMeta,
sessionId: string,
advertised: ConfigOption[],
choice: ModelChoice,
): Promise<void> {
const asked: Array<[string, string]> = [];
if (choice.model !== undefined) asked.push([MODEL_CATEGORY, choice.model]);
if (choice.effort !== undefined) asked.push([EFFORT_CATEGORY, choice.effort]);
if (asked.length === 0) { observeConfig(meta, advertised, "runtime-default"); return; }
let options = advertised;
// codex already received the ask as a CODEX_CONFIG seed at spawn; every other runtime has
// the protocol road or nothing. Either way the loop below upgrades this to "protocol".
let how: ConfigHow = meta.runtime === "codex" ? "adapter-config" : "not-advertised";
for (const [category, value] of asked) {
const option = agentHostOptionFor(options, category);
if (option === null) continue;
const response = await context.request<JsonObject>(acp.methods.agent.session.setConfigOption, {
sessionId,
configId: option.id,
value: agentHostResolveValue(option, value),
});
const next = Array.isArray(response.configOptions) ? response.configOptions.map(asObject) as ConfigOption[] : null;
if (next !== null) options = next;
how = "protocol";
}
observeConfig(meta, options, how);
}
/** The codex CLI's OWN model cache. Labelled as such: it is never this skill's list. */
function codexCliModelCache(): JsonObject | null {
try {
const parsed = JSON.parse(readFileSync(CODEX_MODELS_CACHE, "utf8")) as JsonObject;
const models = Array.isArray(parsed.models) ? parsed.models.map(asObject) : [];
return {
source: CODEX_MODELS_CACHE,
note: "the codex CLI's own cache file, not a list ACP advertised and not a list this skill keeps",
client_version: parsed.client_version ?? null,
fetched_at: parsed.fetched_at ?? null,
models: models.map((model) => ({
slug: model.slug ?? null,
display_name: model.display_name ?? null,
default_reasoning_level: model.default_reasoning_level ?? null,
supported_reasoning_levels: (Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : [])
.map((level) => String(asObject(level).effort ?? "")).filter(Boolean),
})),
};
} catch { return null; }
}
function packageJson(modules: string, name: string): JsonObject {
return JSON.parse(readFileSync(join(modules, ...name.split("/"), "package.json"), "utf8")) as JsonObject;
}
function moduleRoots(): string[] {
const roots = [process.env.SNAPPY_AGENT_HOST_MODULES ?? "", APP_MODULES];
const cache = join(homedir(), "Projects/snappy-os-app/apps/snappy-os/.build/build-cache/native");
if (existsSync(cache)) {
const builds = readdirSync(cache).map((name) => join(cache, name, "SnappyOS_SnappyOS.bundle/Resources/runtime/node_modules"));
builds.sort((a, b) => {
try { return statSync(b).mtimeMs - statSync(a).mtimeMs; } catch { return 0; }
});
roots.push(...builds);
}
return roots.filter((root, index, all) => root !== "" && all.indexOf(root) === index && existsSync(root));
}
function modulesWith(name: string, version: string): string {
for (const root of moduleRoots()) {
try {
const pkg = packageJson(root, name);
if (pkg.version === version) return root;
} catch { /* try the next existing app runtime */ }
}
throw new Error(`${name}@${version} is not present in SnappyOS.app's runtime; no package was installed`);
}
async function loadAcp(): Promise<AcpModule> {
const modules = modulesWith("@agentclientprotocol/sdk", ACP_SDK_VERSION);
const entry = join(modules, "@agentclientprotocol/sdk/dist/acp.js");
return await import(pathToFileURL(entry).href) as AcpModule;
}
function which(binary: string): string | null {
const result = spawnSync("which", [binary], { encoding: "utf8" });
return result.status === 0 && result.stdout.trim() !== "" ? result.stdout.trim() : null;
}
function optionalEnv(name: string): string {
try { return process.env[name] || env(name, false) || ""; } catch { return process.env[name] || ""; }
}
function cleanEnv(): NodeJS.ProcessEnv {
const names = ["HOME", "PATH", "SHELL", "TMPDIR", "LANG", "LC_ALL", "USER", "LOGNAME"];
const output: NodeJS.ProcessEnv = {};
for (const name of names) if (process.env[name]) output[name] = process.env[name];
Object.assign(output, {
NODE_NO_WARNINGS: "1",
NO_COLOR: "1",
DISABLE_AUTOUPDATER: "1",
CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
GEMINI_CLI_TRUST_WORKSPACE: "true",
GEMINI_SANDBOX: "false",
GEMINI_TELEMETRY_ENABLED: "false",
PAGER: "",
GIT_PAGER: "cat",
});
delete output.CLAUDECODE;
delete output.CLAUDE_CODE_CHILD_SESSION;
delete output.ANTHROPIC_API_KEY;
delete output.OPENAI_API_KEY;
return output;
}
function adapterSpec(runtime: Runtime, permissions: Permissions, choice: ModelChoice = {}): AdapterSpec {
const e = cleanEnv();
if (runtime === "claude") {
const modules = modulesWith("@agentclientprotocol/claude-agent-acp", CLAUDE_ADAPTER_VERSION);
const cli = which("claude");
if (cli === null) throw new Error("claude CLI is not installed");
e.CLAUDE_CODE_EXECUTABLE = realpathSync(cli);
return {
command: process.execPath,
args: [join(modules, "@agentclientprotocol/claude-agent-acp/dist/index.js")],
env: e,
version: CLAUDE_ADAPTER_VERSION,
name: "@agentclientprotocol/claude-agent-acp",
};
}
if (runtime === "codex") {
const modules = modulesWith("@agentclientprotocol/codex-acp", CODEX_ADAPTER_VERSION);
const cli = which("codex");
if (cli === null) throw new Error("codex CLI is not installed");
e.CODEX_PATH = realpathSync(cli);
// CODEX_CONFIG is an OVERRIDE layer over the person's own ~/.codex/config.toml
// (codex-acp merges it into the thread config). A key that is absent here is a key
// codex reads from their file — so no model and no effort is named unless the caller
// asked for one. Anything named is only a seed; the protocol road below is the truth.
const codexConfig: JsonObject = {
approval_policy: permissions === "allow-all" ? "never" : "on-request",
sandbox_mode: permissions === "allow-all" ? "danger-full-access" : "workspace-write",
};
if (choice.model !== undefined) codexConfig.model = choice.model;
if (choice.effort !== undefined) codexConfig.model_reasoning_effort = choice.effort;
e.CODEX_CONFIG = JSON.stringify(codexConfig);
return {
command: process.execPath,
args: [join(modules, "@agentclientprotocol/codex-acp/dist/index.js")],
env: e,
version: CODEX_ADAPTER_VERSION,
name: "@agentclientprotocol/codex-acp",
};
}
const cli = which("gemini");
if (cli === null) throw new Error("gemini CLI is not installed");
const gemini = optionalEnv("GEMINI_API_KEY");
const google = optionalEnv("GOOGLE_API_KEY");
if (gemini) e.GEMINI_API_KEY = gemini;
if (google) e.GOOGLE_API_KEY = google;
return { command: realpathSync(cli), args: ["--acp"], env: e, version: "built-in", name: "gemini" };
}
function spawnAdapter(runtime: Runtime, cwd: string, permissions: Permissions, choice: ModelChoice): { process: ChildProcess; spec: AdapterSpec } {
const spec = adapterSpec(runtime, permissions, choice);
const child = spawn(spec.command, spec.args, { cwd, env: spec.env, stdio: ["pipe", "pipe", "pipe"], detached: true });
return { process: child, spec };
}
function descendants(pid: number): number[] {
const result = spawnSync("pgrep", ["-P", String(pid)], { encoding: "utf8" });
const children = (result.stdout || "").split("\n").map(Number).filter(Boolean);
return children.flatMap((child) => [...descendants(child), child]);
}
function signalTree(pid: number, signal: NodeJS.Signals): void {
if (pid <= 0) return;
const tree = descendants(pid);
try { process.kill(-pid, signal); } catch { /* group already gone */ }
for (const child of tree.reverse()) try { process.kill(child, signal); } catch { /* child already gone */ }
try { process.kill(pid, signal); } catch { /* leader already gone */ }
}
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`${label}: no response in ${ms} ms`)), ms); }),
]);
} finally { if (timer !== undefined) clearTimeout(timer); }
}
function normalizeUpdate(params: JsonObject): JsonObject {
const update = asObject(params.update);
const kind = String(update.sessionUpdate ?? "session_update");
if (kind === "available_commands_update") {
const commands = Array.isArray(update.availableCommands) ? update.availableCommands.map(asObject) : [];
const names = commands.map((command) => String(command.name ?? "")).filter(Boolean);
return { step: kind, count: names.length, commands: names.slice(0, 25), omitted: Math.max(0, names.length - 25) };
}
if (kind === "agent_message_chunk") return { step: "answer_delta", update };
if (kind === "agent_thought_chunk") return { step: "thought_delta", update };
if (kind === "tool_call" || kind === "tool_call_update") return { step: kind, update };
if (kind === "plan") return { step: "plan", update };
if (kind.includes("usage")) return { step: "usage", update };
return { step: kind, update };
}
function choosePermission(options: JsonObject[], requested: string): string | null {
const exact = options.find((option) => option.optionId === requested || option.kind === requested);
if (exact) return String(exact.optionId);
if (requested === "allow" || requested === "allow-all") {
const allow = options.find((option) => option.kind === "allow_once") ?? options.find((option) => option.kind === "allow_always");
return allow ? String(allow.optionId) : null;
}
if (requested === "reject") {
const reject = options.find((option) => option.kind === "reject_once") ?? options.find((option) => option.kind === "reject_always");
return reject ? String(reject.optionId) : null;
}
return null;
}
async function worker(key: string): Promise<void> {
const meta = readMetaKey(key);
if (meta === null) throw new Error(`worker state ${key} is missing`);
let adapter: ChildProcess | null = null;
let acp: AcpModule | null = null;
let connection: AcpConnection | null = null;
let context: AcpContext | null = null;
let turnPromise: Promise<unknown> | null = null;
let turnText = "";
const queue: Array<{ id: string; text: string }> = [];
let draining = false;
let pendingPermission: { params: JsonObject; resolve: (value: unknown) => void } | null = null;
let promptMethod = "";
let cancelMethod = "";
let stopping = false;
let finishStop: (() => void) | null = null;
const stopDone = new Promise<void>((resolveStop) => { finishStop = resolveStop; });
const fail = (error: unknown): void => {
meta.status = "failed";
meta.error = textOf(error);
appendEvent(meta, "error", { error: meta.error });
};
const handleUpdate = (params: JsonObject): void => {
const normalized = normalizeUpdate(params);
const update = asObject(params.update);
if (update.sessionUpdate === "agent_message_chunk") {
const content = asObject(update.content);
if (content.type === "text") turnText += String(content.text ?? "");
}
if (update.sessionUpdate === "config_option_update") {
observeConfig(meta, Array.isArray(update.configOptions) ? update.configOptions.map(asObject) as ConfigOption[] : []);
}
appendEvent(meta, "step", normalized);
};
const requestPermission = async (params: JsonObject): Promise<unknown> => {
const options = Array.isArray(params.options) ? params.options.map(asObject) : [];
appendEvent(meta, "permission_ask", { toolCall: params.toolCall ?? null, options });
if (meta.permissions === "allow-all") {
const optionId = choosePermission(options, "allow");
const outcome = optionId === null ? { outcome: "cancelled" } : { outcome: "selected", optionId };
appendEvent(meta, "permission_answer", { answer: outcome, automatic: true });
return { outcome };
}
meta.status = "waiting_permission";
writeMeta(meta);
return await new Promise((resolvePermission) => { pendingPermission = { params, resolve: resolvePermission }; });
};
const answerPermission = (answer: string): JsonObject => {
if (pendingPermission === null) return { ok: false, error: "no permission ask is pending" };
const held = pendingPermission;
const options = Array.isArray(held.params.options) ? held.params.options.map(asObject) : [];
const optionId = choosePermission(options, answer);
const outcome = answer === "cancelled" || optionId === null
? { outcome: "cancelled" }
: { outcome: "selected", optionId };
pendingPermission = null;
meta.status = "running";
appendEvent(meta, "permission_answer", { answer: outcome, automatic: false });
held.resolve({ outcome });
return { ok: true, outcome };
};
const runTurn = async (command: { id: string; text: string }): Promise<void> => {
if (context === null || meta.sessionId === null) throw new Error("ACP session is not ready");
meta.status = "running";
meta.currentTurn = command.id;
turnText = "";
appendEvent(meta, "turn_started", { command_id: command.id, text: command.text });
const request = context.request<JsonObject>(promptMethod, {
sessionId: meta.sessionId,
prompt: [{ type: "text", text: command.text }],
});
turnPromise = request;
try {
const response = await request;
appendEvent(meta, "turn_done", {
command_id: command.id,
stop_reason: response.stopReason ?? "unknown",
answer: turnText,
response,
});
} catch (error) {
appendEvent(meta, "turn_failed", { command_id: command.id, error: textOf(error), answer: turnText });
} finally {
turnPromise = null;
meta.currentTurn = null;
meta.status = "idle";
writeMeta(meta);
}
};
const drain = async (): Promise<void> => {
if (draining) return;
draining = true;
try { while (queue.length > 0) await runTurn(queue.shift()!); }
finally { draining = false; }
};
const stop = async (): Promise<void> => {
const started = Date.now();
stopping = true;
meta.status = "cancelling";
appendEvent(meta, "cancel_requested", {});
if (pendingPermission !== null) {
const held = pendingPermission;
pendingPermission = null;
held.resolve({ outcome: { outcome: "cancelled" } });
}
if (context !== null && meta.sessionId !== null) {
try { await context.notify(cancelMethod, { sessionId: meta.sessionId }); } catch { /* adapter may already be gone */ }
}
if (turnPromise !== null) await Promise.race([turnPromise.catch(() => undefined), sleep(1_100)]);
if (adapter?.pid) signalTree(adapter.pid, "SIGTERM");
await sleep(150);
if (adapter?.pid && processAlive(adapter.pid)) signalTree(adapter.pid, "SIGKILL");
meta.status = "cancelled";
appendEvent(meta, "cancelled", { elapsed_ms: Date.now() - started });
finishStop?.();
};
const respond = (socket: Socket, value: unknown): void => {
socket.end(`${JSON.stringify(value)}\n`);
};
const commandServer = createServer((socket) => {
let input = "";
socket.setEncoding("utf8");
socket.on("data", (chunk) => { input += chunk; });
socket.on("end", () => {
void (async () => {
let command: WorkerCommand;
try { command = JSON.parse(input) as WorkerCommand; }
catch { respond(socket, { ok: false, error: "invalid worker command" }); return; }
if (command.command === "ping") { respond(socket, { ok: true, meta }); return; }
if (command.command === "answer") { respond(socket, answerPermission(String(command.answer ?? ""))); return; }
if (command.command === "prompt") {
const text = String(command.text ?? "").trim();
if (text === "") { respond(socket, { ok: false, error: "prompt text is required" }); return; }
const id = randomUUID();
queue.push({ id, text });
respond(socket, { ok: true, command_id: id, cursor: meta.nextSeq - 1 });
void drain();
return;
}
if (command.command === "config") {
if (acp === null || context === null || meta.sessionId === null) { respond(socket, { ok: false, error: "session is not ready" }); return; }
const choice: ModelChoice = {};
if (command.model !== undefined) choice.model = String(command.model);
if (command.effort !== undefined) choice.effort = String(command.effort);
meta.asked = { ...(meta.asked ?? {}), ...choice };
await applyChoice(acp, context, meta, meta.sessionId, meta.configOptions ?? [], choice);
appendEvent(meta, "config", { asked: meta.asked, model: meta.model ?? null, effort: meta.effort ?? null, config_from: meta.configHow ?? null });
respond(socket, { ok: true, model: meta.model ?? null, effort: meta.effort ?? null, config_from: meta.configHow ?? null });
return;
}
if (command.command === "cancel") {
await stop();
respond(socket, { ok: true, elapsed_ms: Date.now() - Date.parse(meta.updatedAt) });
commandServer.close();
setTimeout(() => process.exit(0), 10).unref();
return;
}
respond(socket, { ok: false, error: "unknown worker command" });
})().catch((error) => respond(socket, { ok: false, error: textOf(error) }));
});
});
try {
acp = await loadAcp();
promptMethod = acp.methods.agent.session.prompt;
cancelMethod = acp.methods.agent.session.cancel;
const launched = spawnAdapter(meta.runtime, meta.cwd, meta.permissions, meta.asked ?? {});
adapter = launched.process;
meta.adapterPid = adapter.pid ?? null;
writeMeta(meta);
adapter.stderr!.setEncoding("utf8");
let stderrBuffer = "";
adapter.stderr!.on("data", (chunk: string) => {
stderrBuffer += chunk;
const lines = stderrBuffer.split("\n");
stderrBuffer = lines.pop() ?? "";
for (const line of lines.filter(Boolean)) appendEvent(meta, "runtime_log", { line: line.slice(0, 2_000) });
});
const exited = new Promise<never>((_, reject) => {
adapter!.once("exit", (code, signal) => reject(new Error(`adapter exited (${code ?? signal})`)));
adapter!.once("error", reject);
});
const output = Writable.toWeb(adapter.stdin!) as WritableStream<Uint8Array>;
const input = Readable.toWeb(adapter.stdout!) as ReadableStream<Uint8Array>;
const stream = acp.ndJsonStream(output, input);
const client = acp.client({ name: CLIENT_INFO.name })
.onRequest(acp.methods.client.session.requestPermission, (request) => requestPermission(request.params))
.onRequest(acp.methods.client.session.update, asObject, (request) => { handleUpdate(request.params); return null; })
.onNotification(acp.methods.client.session.update, (notification) => handleUpdate(notification.params));
connection = client.connect(stream);
context = connection.agent;
const initialize = context.request<JsonObject>(acp.methods.agent.initialize, {
protocolVersion: acp.PROTOCOL_VERSION,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
auth: { terminal: true },
},
clientInfo: CLIENT_INFO,
});
const initialized = await withTimeout(Promise.race([initialize, exited]), 120_000, "initialize");
appendEvent(meta, "initialized", {
protocol_version: initialized.protocolVersion,
agent: initialized.agentInfo ?? null,
capabilities: initialized.agentCapabilities ?? null,
sdk: `@agentclientprotocol/sdk@${ACP_SDK_VERSION}`,
});
if (meta.runtime === "gemini") {
const keyValue = optionalEnv("GEMINI_API_KEY") || optionalEnv("GOOGLE_API_KEY");
if (keyValue) await context.request(acp.methods.agent.authenticate, { methodId: "gemini-api-key", _meta: { "api-key": keyValue } });
}
const previousId = meta.sessionId;
const sessionRequest = agentHostNewSessionRequest(meta.cwd);
let opened: JsonObject | null = null;
let how: SessionMeta["sessionHow"] = null;
if (previousId !== null) {
try {
opened = await context.request<JsonObject>(acp.methods.agent.session.resume, { ...sessionRequest, sessionId: previousId });
how = "resumed";
} catch {
try {
opened = await context.request<JsonObject>(acp.methods.agent.session.load, { ...sessionRequest, sessionId: previousId });
how = "loaded";
} catch { /* a new session is the final supported road */ }
}
}
if (opened === null) {
opened = await context.request<JsonObject>(acp.methods.agent.session.new, sessionRequest);
how = "new";
}
meta.sessionId = String(opened.sessionId ?? previousId ?? "");
if (meta.sessionId === "") throw new Error("agent returned no sessionId");
meta.sessionHow = how;
const advertised = Array.isArray(opened.configOptions) ? opened.configOptions.map(asObject) as ConfigOption[] : [];
await applyChoice(acp, context, meta, meta.sessionId, advertised, meta.asked ?? {});
meta.status = "idle";
appendEvent(meta, "session", {
runtime: meta.runtime,
cwd: meta.cwd,
permissions: meta.permissions,
how,
adapter: { name: launched.spec.name, version: launched.spec.version },
setting_sources: sessionRequest._meta.claudeCode.options.settingSources,
asked: meta.asked ?? {},
model: meta.model ?? null,
effort: meta.effort ?? null,
config_from: meta.configHow ?? null,
config_options: (meta.configOptions ?? []).map((option) => ({ id: option.id, category: option.category ?? null, current: agentHostCurrentValue(option) })),
});
rmSync(meta.socket, { force: true });
await new Promise<void>((resolveListen, rejectListen) => {
commandServer.once("error", rejectListen);
commandServer.listen(meta.socket, () => { chmodSync(meta.socket, 0o600); resolveListen(); });
});
try { await Promise.race([connection.closed, exited]); }
catch (error) {
if (stopping) { await stopDone; await sleep(50); }
else throw error;
}
} catch (error) {
if (!stopping && meta.status !== "cancelled") fail(error);
} finally {
try { commandServer.close(); } catch { /* not listening */ }
rmSync(meta.socket, { force: true });
if (adapter?.pid && processAlive(adapter.pid)) signalTree(adapter.pid, "SIGKILL");
if (meta.status !== "cancelled" && meta.status !== "failed") fail("agent host worker stopped");
}
}
function sendCommand(meta: SessionMeta, command: WorkerCommand, timeoutMs = 5_000): Promise<JsonObject> {
return new Promise((resolveCommand, rejectCommand) => {
const socket = createConnection(meta.socket);
let output = "";
const timer = setTimeout(() => { socket.destroy(); rejectCommand(new Error(`session command timed out after ${timeoutMs} ms`)); }, timeoutMs);
socket.setEncoding("utf8");
socket.on("connect", () => socket.end(JSON.stringify(command)));
socket.on("data", (chunk) => { output += chunk; });
socket.on("error", (error) => { clearTimeout(timer); rejectCommand(error); });
socket.on("close", () => {
clearTimeout(timer);
try {
const answer = JSON.parse(output.trim()) as JsonObject;
if (answer.ok === false) rejectCommand(new Error(String(answer.error ?? "worker refused command")));
else resolveCommand(answer);
} catch (error) { rejectCommand(error); }
});
});
}
async function waitReady(key: string, timeoutMs = 120_000): Promise<SessionMeta> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const meta = readMetaKey(key);
if (meta?.status === "failed") throw new Error(meta.error ?? "agent host failed");
if (meta?.sessionId && existsSync(meta.socket) && processAlive(meta.pid)) return meta;
await sleep(50);
}
throw new Error(`agent host did not become ready in ${timeoutMs} ms`);
}
async function waitCommand(meta: SessionMeta, commandId: string, timeoutMs = COMMAND_TIMEOUT_MS): Promise<SessionMeta> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const current = readMetaKey(meta.key) ?? meta;
const done = eventsFor(current).some((event) =>
(event.type === "turn_done" || event.type === "turn_failed") && event.command_id === commandId);
if (done) return current;
if (current.status === "failed") throw new Error(current.error ?? "agent host failed");
if (!processAlive(current.pid)) throw new Error("agent host worker exited before the turn finished");
await sleep(100);
}
throw new Error(`prompt did not finish in ${timeoutMs} ms`);
}
/* ── THE FACE THIS READ DRAWS ⟨lane family-reads, 2026-09-09⟩ ──────────────
* The `agent` family draws `agent-run` — "the black box, opened" — and NO read
* reached it: the runner's name route is exactly `snappy-<family>` and this
* hand is `snappy-agent-host`, so the derivation could not find it, while this
* hand is the only thing in the kernel that HAS an agent run to draw.
*
* `status` is the read. Its ndjson page is unchanged — it is what a caller
* following a live run reads, one event at a time — and `--json` folds the
* SAME events into the one object the face binds to. Two spellings of one
* read, never two reads.
*
* WHAT IS A STEP IS DECIDED STRUCTURALLY, by the event's own type, never by an
* allowlist of tool names: `step` lines carrying an ACP `tool_call` are the
* work, permission asks are the waits, and a failure is a step that says so.
* Chunk deltas (`answer_delta`, `thought_delta`) are NOT steps — a run's
* thinking arrives as hundreds of them and a face drawing one row per token is
* a face nobody can read.
*/
export interface AgentRunStep {
readonly words: string;
readonly tool?: string;
readonly result?: string;
readonly state?: string;
readonly tookMs?: number;
}
export interface AgentRunAnswer {
readonly title: string;
readonly agentName: string;
readonly state: string;
readonly startedAt?: string;
readonly tookWords?: string;
readonly steps: readonly AgentRunStep[];
}
/** The session's own status word in the face's vocabulary. `idle` is the one
* that needs saying out loud: a session goes idle when its turn FINISHED, so
* the run is done — a face drawing "idle" over a finished run would make a
* person wait for something that already happened ⟨CLAUDE.md §10⟩. */
export function agentRunState(status: string): string {
if (status === "idle") return "done";
if (status === "waiting_permission") return "waiting";
if (status === "cancelling") return "running";
return status;
}
/** How long, in the words a person says it in. */
export function tookWords(ms: number): string {
if (ms < 1_000) return `${Math.round(ms)} ms`;
const seconds = Math.round(ms / 1_000);
if (seconds < 60) return `${seconds}s`;
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
}
function firstLine(text: unknown, limit = 160): string | undefined {
if (typeof text !== "string") return undefined;
const line = text.trim().split("\n").find((word) => word.trim() !== "");
if (line === undefined) return undefined;
return line.length > limit ? `${line.slice(0, limit - 1)}…` : line;
}
/** One ACP tool call as a step. The ACP `toolCall`/`update` object names the
* work in `title`, the tool in `kind` or `rawInput.command`, and how it went
* in `status`; a call that names none of them yields no step rather than a
* blank row. */
function toolStep(update: JsonObject): AgentRunStep | null {
const words = firstLine(update.title) ?? firstLine(asObject(update.rawInput).description);
if (words === undefined) return null;
const raw = asObject(update.rawInput);
const tool = firstLine(raw.command, 80) ?? (typeof update.kind === "string" ? update.kind : undefined);
const content = Array.isArray(update.content) ? update.content.map(asObject) : [];
const result = firstLine(content.map((part) => asObject(part.content).text ?? part.text).find((text) => typeof text === "string"));
const status = typeof update.status === "string" ? update.status : undefined;
return {
words,
...(tool === undefined ? {} : { tool }),
...(result === undefined ? {} : { result }),
...(status === undefined ? {} : { state: status === "completed" ? "done" : status === "failed" ? "failed" : "running" }),
};
}
/** THE ONE FOLD into the drawable kind ⟨snappy-faces/dist/build-report.json:
* `agent-run` → AgentRun{title, steps[], agentName?, state?, startedAt?,
* tookWords?}⟩. Over the session's own meta and the events it already wrote. */
export function agentRunFace(
meta: { runtime: string; cwd: string; status: string; createdAt: string; updatedAt: string; sessionId?: string | null },
events: readonly JsonObject[],
): AgentRunAnswer {
const steps: AgentRunStep[] = [];
// The run's TITLE is the person's own first sentence to it, never a summary
// written here. A session resumed with no turn on record has no title of its
// own, and says so with the folder it runs in.
let title: string | undefined;
const seen = new Map<string, number>();
for (const event of events) {
const type = String(event.type ?? "");
if (type === "turn_started" && title === undefined) title = firstLine(event.text, 120);
if (type === "step") {
const kind = String(event.step ?? "");
if (kind !== "tool_call" && kind !== "tool_call_update") continue;
const update = asObject(event.update);
const step = toolStep(update);
if (step === null) continue;
// A tool call and its updates are ONE step, not four: ACP repeats the
// same `toolCallId` as the call runs. The later line wins, because it is
// the one that knows how the call went.
const id = typeof update.toolCallId === "string" ? update.toolCallId : `#${steps.length}`;
const at = seen.get(id);
if (at === undefined) { seen.set(id, steps.length); steps.push(step); }
else steps[at] = { ...steps[at]!, ...step };
continue;
}
if (type === "permission_ask") {
const call = asObject(event.toolCall);
steps.push({ words: firstLine(call.title) ?? "Asked for permission", state: "waiting" });
continue;
}
if (type === "turn_failed" || type === "error") {
steps.push({ words: "The turn failed", state: "failed", ...(firstLine(event.error) === undefined ? {} : { result: firstLine(event.error)! }) });
continue;
}
if (type === "cancelled") steps.push({ words: "Cancelled", state: "cancelled" });
}
const started = Date.parse(meta.createdAt);
const ended = Date.parse(meta.updatedAt);
const elapsed = Number.isFinite(started) && Number.isFinite(ended) && ended >= started ? ended - started : null;
return {
title: title ?? `Session in ${meta.cwd}`,
agentName: `${meta.runtime} · ${meta.cwd.split("/").filter(Boolean).at(-1) ?? meta.cwd}`,
state: agentRunState(meta.status),
// The stored stamp, cut at the minute — never re-formatted through a
// locale, which would draw a different sentence on a different Mac.
...(meta.createdAt === "" ? {} : { startedAt: meta.createdAt.slice(0, 16).replace("T", " ") }),
...(elapsed === null ? {} : { tookWords: tookWords(elapsed) }),
steps,
};
}
function printPage(meta: SessionMeta, cursor = 0): void {
const all = eventsFor(meta).filter((event) => event.seq > cursor);
const page: EventLine[] = [];
let bytes = 0;
for (const event of all) {
const lineBytes = Buffer.byteLength(JSON.stringify(event)) + 1;
if (page.length > 0 && (page.length >= PAGE_EVENTS || bytes + lineBytes > PAGE_BYTES)) break;
page.push(event);
bytes += lineBytes;
}
for (const event of page) console.log(JSON.stringify(event));
const nextCursor = page.length === 0 ? null : page.at(-1)!.seq;
const more = all.length > page.length;
console.log(JSON.stringify({
type: "page",
session: meta.sessionId,
runtime: meta.runtime,
cwd: meta.cwd,
cursor,
next_cursor: more ? nextCursor : null,
status: meta.status,
events: page.length,
}));
}
async function launchWorker(meta: SessionMeta): Promise<void> {
const script = fileURLToPath(import.meta.url);
const child = spawn(process.execPath, ["--experimental-strip-types", script, "__worker", meta.key], {
cwd: dirname(script),
env: cleanEnv(),
stdio: "ignore",
detached: true,
});
if (!child.pid) throw new Error("agent host worker did not start");
meta.pid = child.pid;
writeMeta(meta);
child.unref();
}
/** Start or reuse the one durable ACP session for a folder. The one session road. */
async function openAgentHostSession(runtime: Runtime, cwdInput: string, permissions: Permissions, choice: ModelChoice): Promise<{ meta: SessionMeta; cursor: number }> {
ensureState();
const cwd = realpathSync(resolve(cwdInput));
if (!statSync(cwd).isDirectory()) throw new Error(`cwd is not a folder: ${cwd}`);
const key = sessionKey(cwd);
mkdirSync(sessionDir(key), { recursive: true, mode: 0o700 });
const existing = readMetaKey(key);
const persistedNextSeq = nextPersistedSeq(existing, key);
const cursor = persistedNextSeq - 1;
let meta: SessionMeta;
if (existing !== null && processAlive(existing.pid) && existsSync(existing.socket)) {
if (existing.runtime !== runtime) throw new Error(`${cwd} already has durable ${existing.runtime} session ${existing.sessionId}; cancel it before changing runtime`);
meta = existing;
// A live adapter cannot be re-spawned with a new seed, so the ask goes over the protocol.
if (choice.model !== undefined || choice.effort !== undefined) {
await sendCommand(meta, { command: "config", ...choice });
meta = readMetaKey(key) ?? meta;
}
} else {
const created = existing?.createdAt ?? now();
meta = {
version: 1,
key,
runtime,
cwd,
permissions,
pid: 0,
adapterPid: null,
socket: socketPath(key),
sessionId: agentHostResumeId(existing, runtime),
sessionHow: null,
status: "starting",
createdAt: created,
updatedAt: now(),
nextSeq: persistedNextSeq,
currentTurn: null,
asked: choice,
model: null,
effort: null,
configHow: null,
};
writeMeta(meta);
await launchWorker(meta);
meta = await waitReady(key);
}
return { meta, cursor };
}
/** Start or reuse the one durable ACP session for a folder and run its first prompt. */
export async function startAgentHostSession(runtime: Runtime, cwdInput: string, promptText: string, permissions: Permissions = "ask", choice: ModelChoice = {}): Promise<{ meta: SessionMeta; cursor: number; commandId: string }> {
const opened = await openAgentHostSession(runtime, cwdInput, permissions, choice);
const accepted = await sendCommand(opened.meta, { command: "prompt", text: promptText });
const commandId = String(accepted.command_id);
const meta = await waitCommand(opened.meta, commandId);
return { meta, cursor: opened.cursor, commandId };
}
/**
* What THE RUNTIME advertises it can be set to, read off a real session over ACP.
* This skill keeps no model list of its own, so this answer can never go stale.
*/
export async function agentHostModels(runtime: Runtime): Promise<JsonObject> {
ensureState();
const probe = join(STATE_ROOT, "probe", runtime);
mkdirSync(probe, { recursive: true, mode: 0o700 });
const { meta } = await openAgentHostSession(runtime, probe, "ask", {});
try {
const options = meta.configOptions ?? [];
const advertised = options.length > 0;
return {
type: "models",
runtime,
adapter: adapterSpec(runtime, "ask").name,
state: advertised ? "advertised" : "not-advertised",
current: { model: meta.model ?? null, effort: meta.effort ?? null },
model_option: agentHostOptionFor(options, MODEL_CATEGORY)?.id ?? null,
effort_option: agentHostOptionFor(options, EFFORT_CATEGORY)?.id ?? null,
options: options.map((option) => ({
id: option.id,
name: option.name ?? option.id,
category: option.category ?? null,
type: option.type ?? null,
current: agentHostCurrentValue(option),
values: agentHostOptionValues(option),
})),
...(advertised || runtime !== "codex" ? {} : { codex_cli_cache: codexCliModelCache() }),
};
} finally {
await cancelAgentHostSession(meta.key);
}
}
/** Prompt a durable session. answer resolves a pending permission before the text is queued. */
export async function promptAgentHostSession(session: string, text: string, answer?: string): Promise<{ meta: SessionMeta; cursor: number; commandId: string }> {
let meta = findSession(session);
if (meta === null) throw new Error(`unknown session: ${session}`);
if (!processAlive(meta.pid) || !existsSync(meta.socket)) throw new Error(`session ${session} is not running`);
if (answer !== undefined) await sendCommand(meta, { command: "answer", answer });
const accepted = await sendCommand(meta, { command: "prompt", text });
const cursor = Number(accepted.cursor ?? 0);
const commandId = String(accepted.command_id);
meta = await waitCommand(meta, commandId);
return { meta, cursor, commandId };
}
function failedTurn(meta: SessionMeta, commandId: string): EventLine | null {
return eventsFor(meta).find((event) => event.type === "turn_failed" && event.command_id === commandId) ?? null;
}
/** Stop the ACP turn and its complete local process tree within two seconds. */
export async function cancelAgentHostSession(session: string): Promise<{ meta: SessionMeta; elapsedMs: number }> {
const started = Date.now();
let meta = findSession(session);
if (meta === null) throw new Error(`unknown session: ${session}`);
try { await sendCommand(meta, { command: "cancel" }, 1_700); } catch { /* hard stop below is authoritative */ }
meta = readMetaKey(meta.key) ?? meta;
if (processAlive(meta.pid)) signalTree(meta.pid, "SIGKILL");
if (meta.adapterPid !== null && processAlive(meta.adapterPid)) signalTree(meta.adapterPid, "SIGKILL");
const deadline = started + 2_000;
while (processAlive(meta.pid) && Date.now() < deadline) await sleep(20);
meta.status = "cancelled";
writeMeta(meta);
return { meta, elapsedMs: Date.now() - started };
}
export function listAgentHostSessions(): SessionMeta[] {
return readAllMetas().map((meta) => ({
...meta,
status: processAlive(meta.pid) ? meta.status : (meta.status === "failed" ? "failed" : "cancelled"),
}));
}
function arg(args: string[], index: number, name: string): string {
const value = args[index];
if (value === undefined || value === "") throw new Error(`${name} is required`);
return value;
}
function flag(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
return index < 0 ? undefined : args[index + 1];
}
export const HAND_CONTRACT = {
skill: "snappy-agent-host",
description: "Run the REAL Claude Code, Codex, and Gemini CLIs through ACP via the skills MCP, with durable per-folder sessions and every tool call, diff, plan, permission ask, answer, and cancellation printed as paged JSON lines. Also holds the full native SnappyOS.app ACP host research and proven launch constraints. Use for start/prompt/status/cancel/sessions, hosted coding agents, agent client protocol, or showing every agent step instead of a black box. NOT building an MCP server (see mcp-server-builder). Triggers on: ACP, agent client protocol, claude-agent-acp, codex-acp, app-server, host agent, embed CLI, durable agent session, agent steps.",
managed: true,
requires: [] as string[],
refusals: refusalTable("unknown_verb", "missing_argument", "not_found", "invalid_argument"),
verbs: {
start: {
args: ["runtime", "cwd", "prompt"], effect: "write-reversible", flags: { permissions: "--permissions", model: "--model", effort: "--effort" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { runtime: { type: "string", description: "Which agent CLI to run over ACP", enum: ["claude", "codex", "gemini"] }, cwd: { type: "string", description: "Absolute path of the folder the session runs IN; the repo is the team" }, prompt: { type: "string", description: "The first turn's text, sent once the session is up" } } },
},
prompt: {
args: ["session", "text"], effect: "write-reversible", flags: { answer: "--answer" },
class: "additive-write", execution: "call", openWorld: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { session: { type: "string", description: "Session id returned by start" }, text: { type: "string", description: "The turn to send to the running session" } } },
},
status: {
// THE FACE THIS READ DRAWS, NAMED BY THE HAND ⟨2026-09-09⟩. The family
// is `agent`; this hand is `snappy-agent-host`, so the runner's name
// route (`snappy-<family>`) could not reach it, while this hand is the
// only thing in the kernel that HAS an agent run to draw.
face: "agent-run",
args: ["session"], effect: "read", flags: { cursor: "--cursor", json: "--json" },
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { session: { type: "string", description: "Session id returned by start" } } },
},
cancel: {
args: ["session"], effect: "write-reversible",
class: "additive-write", execution: "call", openWorld: true, idempotent: true,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
inputSchema: { properties: { session: { type: "string", description: "Session id to stop; an already-stopped session answers the same" } } },
},
sessions: {
args: [], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
},
models: {
args: ["runtime"], effect: "read",
class: "read", execution: "call", openWorld: true,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
inputSchema: { properties: { runtime: { type: "string", description: "Runtime whose own advertised model list is read", enum: ["claude", "codex", "gemini"] } } },
},
},
notes: {
model: "Omit --model/--effort and the runtime uses the person's own config (codex: ~/.codex/config.toml). This skill pins no model. `models <runtime>` lists what the runtime itself advertises; start/status/sessions report the model the runtime says it is running, not the one that was asked for.",
},
} as const;
const invokedDirectly = (() => {
try { return process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); }
catch { return false; }
})();
if (invokedDirectly && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (invokedDirectly && process.argv[2] === "__worker") {
void worker(arg(process.argv.slice(2), 1, "session key")).catch((error) => {
console.error(textOf(error));
process.exit(1);
});
}
if (invokedDirectly && process.argv[2] !== "__worker" && process.argv[2] !== "contract") {
void (async () => {
const [verb, ...args] = process.argv.slice(2);
if (verb === "start") {
const runtime = arg(args, 0, "runtime");
const permissions = flag(args, "--permissions") ?? "ask";
if (!isRuntime(runtime)) throw new Error("runtime must be claude, codex, or gemini");
if (!isPermissions(permissions)) throw new Error("permissions must be allow-all or ask");
const result = await startAgentHostSession(runtime, arg(args, 1, "cwd"), arg(args, 2, "prompt"), permissions, agentHostModelChoice(args));
printPage(result.meta, result.cursor);
const failure = failedTurn(result.meta, result.commandId);
if (failure !== null) { console.error(`turn_failed: ${String(failure.error ?? "agent turn failed")}`); process.exitCode = 1; }
return;
}
if (verb === "prompt") {
const result = await promptAgentHostSession(arg(args, 0, "session"), arg(args, 1, "text"), flag(args, "--answer"));
printPage(result.meta, result.cursor);
const failure = failedTurn(result.meta, result.commandId);
if (failure !== null) { console.error(`turn_failed: ${String(failure.error ?? "agent turn failed")}`); process.exitCode = 1; }
return;
}
if (verb === "status") {
const meta = findSession(arg(args, 0, "session"));
if (meta === null) throw new Error(`unknown session: ${args[0]}`);
const cursor = Number(flag(args, "--cursor") ?? 0);
if (!Number.isInteger(cursor) || cursor < 0) throw new Error("cursor must be a non-negative integer");
// `--json` FOLDS THE SAME EVENTS INTO THE FACE. The ndjson page below is
// untouched: it is what a caller following a live run reads.
if (args.includes("--json")) {
console.log(JSON.stringify(agentRunFace(meta, eventsFor(meta)), null, 2));
return;
}
printPage(meta, cursor);
return;
}
if (verb === "cancel") {
const result = await cancelAgentHostSession(arg(args, 0, "session"));
console.log(JSON.stringify({ type: "cancelled", session: result.meta.sessionId, cwd: result.meta.cwd, elapsed_ms: result.elapsedMs, stopped_within_2s: result.elapsedMs <= 2_000 }));
return;
}
if (verb === "models") {
const runtime = arg(args, 0, "runtime");
if (!isRuntime(runtime)) throw new Error("runtime must be claude, codex, or gemini");
console.log(JSON.stringify(await agentHostModels(runtime), null, 2));
return;
}
if (verb === "sessions") {
for (const meta of listAgentHostSessions()) console.log(JSON.stringify({
type: "session",
session: meta.sessionId,
runtime: meta.runtime,
cwd: meta.cwd,
status: meta.status,
how: meta.sessionHow,
model: meta.model ?? null,
effort: meta.effort ?? null,
config_from: meta.configHow ?? null,
pid: processAlive(meta.pid) ? meta.pid : null,
updated_at: meta.updatedAt,
}));
return;
}
throw new Error("verbs: start, prompt, status, cancel, sessions, models");
})().catch((error) => {
console.error(textOf(error));
process.exit(1);
});
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"not_found",
"invalid_argument",
] as const satisfies readonly RefusalCode[];
test("snappy-agent-host: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-agent-host: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
import { test } from "node:test";
import assert from "node:assert/strict";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES, type RefusalCode } from "../snappy-settings/refusal-codes.ts";
/** RULE 33 COVERAGE, AGAINST THE ONE CLOSED TABLE ⟨lane r30, 2026-09-09⟩.
* This file used to restate a refusal row's own properties — that it has a
* `contract_slice`, that it has a `fix`, that it leaks no token — once per
* hand, 48 times, over rows that all come from the SAME object. Forty-eight
* copies of one check is the duplicate road the closed table exists to end:
* `snappy-settings/refusal-codes.test.ts` runs those checks ONCE over every
* row, and the second test below proves this hand carries THE ROW ITSELF and
* not a copy — an identity a drifted duplicate cannot fake. Before this, each
* hand hand-wrote its own row, and the wording had already drifted: the
* inline `unknown_verb` said "Call one of the verbs named in
* HAND_CONTRACT.verbs" while the closed table says "Call one of the verbs the
* contract declares; the refusal lists them."
*
* DECLARED stays a literal list, deliberately. It is this hand's coverage
* manifest and it is what rule 33's lint reads out of the test SOURCE to ask
* "did a person look at this code" — deriving it from Object.keys would make
* the test pass for a hand with no refusals at all. `satisfies readonly
* RefusalCode[]` makes the compiler refuse a name the one table does not
* have. It NAMES codes; it no longer DEFINES them. */
const DECLARED = [
"unknown_verb",
"missing_argument",
"not_found",
"invalid_argument",
] as const satisfies readonly RefusalCode[];
test("snappy-agent-host: the refusal table declares exactly the codes this test names", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals ?? {}).sort(), [...DECLARED].sort());
});
test("snappy-agent-host: every declared refusal is the one closed table's own row, never a copy", () => {
const declared = Object.entries(HAND_CONTRACT.refusals ?? {});
assert.ok(declared.length > 0, "HAND_CONTRACT.refusals is empty");
for (const [code, row] of declared) {
assert.ok(code in REFUSAL_CODES, `${code} is not a row of snappy-settings/refusal-codes.ts`);
assert.equal(row, REFUSAL_CODES[code as RefusalCode], `${code} is a copy of the closed table's row, not the row itself`);
}
});
codex-acp 2026-09-02)#Companion to extract-agmente-rendering.md. Repo rebornix/Agmente @ 87f224e (MIT), local clone /Users/robertboulos/projects/cloned-repos/Agmente; paths relative to that root. Read this if the codex-acp adapter ever proves insufficient (real Codex approvals, plan mode Q&A, thread hydration) and you drive codex app-server directly.
Two parallel Codex implementations exist; the app uses the second.
AppServerClient/ SPM package |
Agmente/CodexServerViewModel.swift (live) |
|
|---|---|---|
| Transport | own NativeWebSocketConnection (raw NWConnection + RFC6455) |
ACPService from ACPClient |
| Method names | AppServerMethods constants |
inline string literals |
| Payloads | typed AppServer*Payload.params() |
inline [String: JSONValue] |
| Events | AppServerEventParser → AppServerEvent |
handleCodexMessage switch |
| Items | AppServerThreadItem (type + raw dict) |
CodexThreadResumeResult.Item typed enum |
AppServerService, AppServerClient (class), AppServerEventParser, AppServerResponseParser, AppServerMethods, URLSessionWebSocketProvider and every AppServer*Payload have zero references outside the package + its tests. The app imports only four data types (CodexServerViewModel.swift:5-8, CodexSessionDetailView.swift:3): AppServerModel, AppServerReasoningEffortOption, AppServerSkill, AppServerSkillScope. Where they disagree, the ViewModel is the wire truth. Live path: CodexServerViewModel.callCodex → ACPService.callJSONRPC (ACPClient/Sources/ACPClient/Codex/ACPService+CodexJSONRPC.swift:11-17).
Outbound via callCodex(method:params:) CodexServerViewModel.swift:2614-2620 (awaits ensureInitializedAck() first):
| Method | fn | Params | Result read | |
|---|---|---|---|---|
initialize |
ACPClientManager |
— | {userAgent} |
|
notifications/initialized (notif) |
:490-502 |
null | — | |
thread/start |
:2622-2647 |
{approvalPolicy, persistExtendedHistory:true, cwd?} |
result.thread.id |
|
thread/resume |
:2726-2742 |
{threadId, persistExtendedHistory:true} |
thread{id,preview,cwd,createdAt,turns[]} |
|
thread/read |
:2744-2757 |
{threadId, includeTurns:true} |
same | |
thread/loaded/list |
:2759-2789 |
`{limit, cursor | null}` paged | data[] of id strings |
addConversationListener |
:2796-2804 |
{conversationId, experimentalRawEvents:false} |
ignored | |
thread/list |
:2812-2821 |
{cursor:null, limit} |
data[] |
|
thread/archive |
:2823-2828 |
{threadId} |
— | |
turn/start |
:2649-2695 |
{threadId, input[], model?, effort?, skills?[], approvalPolicy?, sandboxPolicy?, collaborationMode?} |
result.turn.id |
|
turn/interrupt |
:2716-2724 |
{threadId, turnId} |
— | |
model/list, skills/list |
:2830-2850 |
{cursor,limit} / {cwds?[], forceReload?} |
data[] |
turn/start input is always text-only [{type:"text",text}] (:2659-2664); images dropped with a notice. collaborationMode (:2697-2714): {"mode":"plan"|"default","settings":{"model":id,"reasoning_effort":null,"developer_instructions":null}} (snake_case inside; reasoning_effort hardcoded null while effort is a sibling top-level param). approvalPolicy on the wire is kebab-case "on-request"/"never" (:139-146); the dead package says camelCase onRequest (AppServerPayloads.swift:47-51). sandboxPolicy {"type":"workspaceWrite"} / {"type":"dangerFullAccess"} (:148-155).
Package-only, never called: review/start, command/exec, mcpServer/oauth/login, mcpServerStatus/list, feedback/upload, config/read, config/value/write, config/batchWrite (AppServerMethods.swift:1-27). The three methods the live hydration depends on (thread/read, thread/loaded/list, addConversationListener) are absent from that table.
Inbound notifications (handleCodexMessage :2947-3099, routed from AppViewModel.swift:2316/2329):
| Method | Handler |
|---|---|
turn/started |
:3060-3078 sets activeTurnId, interruptible=true, binds streaming row |
turn/completed |
:3029-3059 clears turn/stream/recency/saved-turn state, emits stop reason turn_completed |
item/agentMessage/delta |
:2986-2996 → appendAssistantText(.message) |
item/plan/delta |
:2997-2999 → :3154-3169 → .plan delta |
item/started / item/completed |
:3000-3025 → handleCodexItemEvent(status: in_progress/completed) |
turn/plan/updated |
:3026-3028 → flatten steps → completePlanItem |
turn/diff/updated, codex/event/turn_diff |
:2974-2985 → synthetic tool call turn_diff:<turnId> kind edit, output = unified diff |
error |
:3079-3080 → willRetry inline "⚠️ … (retrying…)" else terminal system error |
No handler for thread/started or thread/tokenUsage/updated (the dead parser maps them; usage is silently dropped). Notifications for a non-active thread are dropped except turn/completed (:2956-2971).
Inbound requests: item/commandExecution/requestApproval, item/fileChange/requestApproval → AppViewModel.swift:2330-2337 → handleApprovalRequest :3636-3677; item/tool/requestUserInput → :2885-2924 (plan-mode Q&A sheet).
CodexThreadResumeResult.Item (:333-342): userMessage(id,text), agentMessage, plan, reasoning, commandExecution(id,command?,output?), fileChange(id,path?,changeType?,diff?), toolCall(id,title,kind?,status?,output?), unknown(type). No dedicated MCP/web-search/todo/error item cases: MCP and web search fall into .toolCall, todo lists arrive as plan, errors are the error notification.
parseThreadItem (:3836-3906) normalises type (_ stripped, lowercased): usermessage; message (role user → user else agent); agentmessage|assistantmessage; plan; reasoning|thought|analysis → extractReasoningText (text → content[] → summary[] joined \n\n); commandexecution|command|exec|shell (reads only scalar command/output — non-scalar output LOST, unlike the substring fallback branch at :3891-3894 which uses extractToolOutput); filechange|file|diff|patch → extractFileChangeDetails (changes[0] path/kind/diff else top-level); toolcall|tool|functioncall|function → parseGenericToolCall (title from title→name→toolName→command→path→tool.name→type); substring fallbacks; else .unknown. Extractors: extractTextContent :4112-4176 (content[] with type ∈ {nil,text,input_text,output_text,message} reading text→delta→text.value/text.text), extractToolOutput :3955-4014 (output[]/output{}/result/response/stdout+stderr), extractDiffText :4016-4046 (diff→patch→changes[].diff→content[type:diff|patch]).
handleCodexItemEvent (:3240-3367) — no generic merge, per-type paths: .reasoning dedup via reasoningCache[itemId] (identical text skipped; longer re-sends append the whole body again — partial-duplication risk); .commandExecution → upsertToolCallFromAppServer(kind:"execute", output only when completed); .fileChange → kind edit, output = diff, title "<changeType>: <path>"; .toolCall → item's own status wins over the envelope; .plan → applyPlanItem :3427-3460; .agentMessage → applyAgentMessageItem :3369-3425 (extracts <proposed_plan>…</proposed_plan> via regex first; skips if existing equals/has-suffix/contains incoming (raw or whitespace-normalised); if incoming has existing as prefix, appends only the suffix delta; else appends whole). Tool-call field merge is ACPSessionViewModel.applyToolCallUpdate :717-773: scalars overwrite nil-coalescing to old; output overwrites when non-nil, never appends.
The common model is the view-model layer, not the protocol layer: ChatMessage AppViewModel.swift:2865-2934, AssistantSegment :2996-3015, ToolCallDisplay :3017-3032 carrying two parallel approval channels (ACP permissionOptions/acpPermissionRequestId; Codex approvalRequestId/Kind/Reason/Command/Cwd) — the most visible leak. Both protocols share ACPSessionViewModel. ServerViewModelProtocol.swift:7-114 papers over gaps with documented no-ops: archiveSession (Codex only), sendLoadSession (ACP only; Codex redirects to openSession), availableModes (Codex []), isPendingSession (Codex false). Rendering is two sibling views (SessionDetailView vs CodexSessionDetailView, ContentView.swift:196-219) over the same components. Codex-only UI: model + effort picker (CodexSessionDetailView.swift:632-735), skills picker by scope (:737-823), permissions preset (:825-868), plan-mode toggle + implementPlan() sending literal "Implement the plan." (:619-627,870-887), tri-state Send/Stop/Reset button (:564-593; Reset exists because resume can leave the client believing a turn is in flight), Archive, Share Session Logs, Approve/Decline pair (:1316-1367) distinct from ACP's option buttons (:1370-1385), codex* accessibility ids.
Server sends a JSON-RPC request. handleApprovalRequest :3636-3677 reads itemId, reason, command, cwd; kind by substring on the method name (commandExecution → title via commandExecutionDisplayTitle :352-488 which recursively strips /usr/bin/env, zsh|bash|sh -lc, pwsh -Command, cmd /c with a quote-aware tokenizer; fileChange → "File change"; else "Approval required"). Surfacing: ACPSessionViewModel.updateToolCallWithApproval :538-606 (records pendingApprovalRequests[requestId] = toolCallId, mutates the matching segment across ALL messages, else the streaming row, status awaiting_permission). Response respondToApprovalRequest :2852-2867: a JSON-RPC result {"decision":"accept"|"decline","acceptSettings":{"forSession":bool}} (acceptSettings only when non-nil). Only accept/decline literals. item/tool/requestUserInput params questions[]{id, header, question|text, multiSelect, options[]{label, description, isOther, isSecret}}; response {"answers":{"<qid>":{"answers":["<label>",…]}}} (:2926-2942).
Specs: AppServerClient/codex-thread-hydration.md, Agmente/specs/codex-load-resume-merge.md; fixtures AgmenteTests/Fixtures/CodexThreadReadMerge/*.json (8).
attachLoadedThreadAndRead :2806-2810: thread/loaded/list → if loaded → addConversationListener → thread/read(includeTurns:true); else callers fall back to thread/resume. Guardrail: do not reintroduce unconditional thread/resume on reconnect. An active turn learned from resume is non-interruptible (interruptible: !usedResumeBasedHydration) → the Reset button.:721-736: if locally "likely in flight" (isLikelyInFlightStreamingState :2336-2345: streaming event within 15 s AND (activeTurnId or streaming row)) and the snapshot lacks the active turn → skip merge, keep local. result.activeTurnId is derived: first turn whose status normalises to inprogress|running|pending|started (:3827-3834).shouldPreserveLocalChatState :2113-2141: true if resumed items == 0, or local tool calls > resumed, or local assistant msgs > resumed with tool calls ≥ → preferLocalRichness: true.mergeChatFromThreadHistory :1292-1557: (1) build one ChatMessage per item with key turn:<turnId>:<kind>:<itemId> or turn:<turnId>:idx:<i>:<kind> (original index preserved even though user items are reordered first within a turn); (2) sessionMessageKeys[threadId]: [UUID: String] inverted; (3) positional bootstrap only when the key index is empty AND not carrying forward (roles + text/segment-kinds must match); (4) richness verdict localRicherThanResume → shouldCarryForwardUnmatchedExisting = preferLocalRichness || (activeTurnId != nil && localRicher); (5) key hit → reuse the existing UUID (row identity/scroll stable) merging payload via mergeResumeMessagePayload :1096-1158 (keep existing if incoming not renderable, or incoming is a prefix snapshot, or incoming dropped tool rows/output; else overwrite content/segments/images/flags keeping the id); key miss → drop the node if a local tool-rich assistant row already represents it (isAssistantResumeNodeRepresentedByLocalToolRichMessage :1240-1254: exact/normalised equality, containment when local has tool calls, bidirectional thought-segment containment :1210-1220 for partially streamed thoughts, tool-call-id subset), else insert; (6) carry-forward :1453-1507: resume ordering is the backbone; each unrepresented local row is re-inserted next to its old neighbour (walk back for a represented predecessor → insert after; else forward → insert before; else tail), logged carry_forward_insert; (7) commit keys (carried-forward locals get local:<uuid>).applyStreamingStateFromResume :2296-2316 / bindStreamingMessageForTurn :2363-2403: memory → current streaming row → last assistant message keyed turn:<id>: → fresh row; guarded by hadStreamingBeforeResume so live deltas are never re-bound.:2143-2242: budget 1–3 rounds of thread/resume 2 s apart (3 when local is richer or resume came back empty during streaming); exits on activeTurnId != nil or item count stable; cancelled by prompt/turn-started/delete/open/removeAll.persistExtendedHistory: true on thread/start and thread/resume, not on thread/read.Agmente performs no Codex auth: guidance text "run codex login before connecting" (ServerManagementViews.swift:451). Protocol sniff InitializeParsing.swift:36-88: ACP markers (protocolVersion|agentCapabilities|agentInfo|agent) → ACP; else userAgent → Codex, fabricating a profile (name: "codex-app-server", version from "codex/1.0.0", loadSession=false, resumeSession=false, listSessions=true). authMethods parsed but never consulted. Error surfaces: error notification (willRetry inline vs terminal system error that wipes streaming state for ALL threads :3115-3117), formatPromptError :2443-2448, everything else to the developer log pane.
.agents/skills/codex-local-cli-e2e/: one XCUITest testCodexDirectWebSocketConnectInitializeAndSessionFlow against a real codex app-server; env contract AGMENTE_E2E_CODEX_ENABLED, _ENDPOINT (ws://127.0.0.1:8788), _HOST, _PROMPT, _CONFIG_PATH; XCTSkip when disabled; run_codex_local_e2e.sh (244 ln): trap cleanup EXIT INT TERM, nc -z readiness (90×1 s managed / 3 attached), boot sim, uninstall before running for a deterministic first run, xcodebuild -only-testing:, grep failures, unexpected skip = failure. .github/skills/run-agmente-codex-e2e/: UI-driven via XcodeBuildMCP, stdio-to-ws bridge on port 9000 (start_codex.sh pid/log files, pkill -9 -f "stdio-to-ws.*<port>"), describe_ui before every tap, mandatory cleanup.sh. Asserted RPC sequence: initialize → initialized → thread/list → thread/start|resume → turn/start → turn/started → turn/completed + item/* streaming. (The app actually sends notifications/initialized, not bare initialized — a strict log assertion would fail.)
AppServerClient is dead weight (~1900/2365 lines, zero call sites) and lacks thread/read, thread/loaded/list, addConversationListener. 2. Approval-policy casing: live "on-request" (:139-146) vs package "onRequest" (AppServerPayloads.swift:47-51). 3. ServerViewModelProtocol.swift:118-138 default-arg extension methods have the same signature as the requirements → infinite recursion for any conformer that omits them. 4. AppServerService.swift:108-113 inverted-looking guard. 5. command_execution non-scalar output dropped (:3867-3870). 6. "Tool call" sentinel (:4094-4096 / :4059). 7. "characters)" magic-string branch (ACPSessionViewModel.swift:821). 8. Terminal error wipes state for all threads (:3115-3117); 9. same over-broad clear in removeSessionViewModel (:305). 10. rememberSession always returns false (:2450-2481). 11. reasoning_effort hardcoded null (:2710). 12. Unused service bindings (:525,551,2052,1810,1904). 13. thread/read omits persistExtendedHistory. 14. isThreadLoaded pages the entire loaded list on every open (:2791-2794). 15. e2e docs assert bare initialized; app sends notifications/initialized (:494). 16. Two e2e skills with incompatible server assumptions (ws listen on 8788 vs stdio bridge on 9000). 17. ensureInitializedAck retries forever without backoff (:490-502). 18. JSONRPCID.init(ACP.ID) maps .null → .int(0) (JSONRPC+ACPBridge.swift:11-12). 19. troubleshootingLoggingEnabled = false (:193); rich diagnostics live in CodexSessionLogger behind Agmente.codexSessionLoggingEnabled. 20. CodexSessionLogger.init clamps maxFiles to ≥10 (:102). 21. Package tests cover one event; real coverage is the 8 app-side fixtures.# Agmente — the Codex app-server side (fallback road; Robert chose `codex-acp` 2026-09-02)
Companion to `extract-agmente-rendering.md`. Repo `rebornix/Agmente` @ 87f224e (MIT), local clone `/Users/robertboulos/projects/cloned-repos/Agmente`; paths relative to that root. Read this if the `codex-acp` adapter ever proves insufficient (real Codex approvals, plan mode Q&A, thread hydration) and you drive `codex app-server` directly.
## 0. Headline fact
Two parallel Codex implementations exist; the app uses the second.
| | `AppServerClient/` SPM package | `Agmente/CodexServerViewModel.swift` (live) |
|---|---|---|
| Transport | own `NativeWebSocketConnection` (raw `NWConnection` + RFC6455) | `ACPService` from `ACPClient` |
| Method names | `AppServerMethods` constants | inline string literals |
| Payloads | typed `AppServer*Payload.params()` | inline `[String: JSONValue]` |
| Events | `AppServerEventParser` → `AppServerEvent` | `handleCodexMessage` switch |
| Items | `AppServerThreadItem` (type + raw dict) | `CodexThreadResumeResult.Item` typed enum |
`AppServerService`, `AppServerClient` (class), `AppServerEventParser`, `AppServerResponseParser`, `AppServerMethods`, `URLSessionWebSocketProvider` and every `AppServer*Payload` have zero references outside the package + its tests. The app imports only four data types (`CodexServerViewModel.swift:5-8`, `CodexSessionDetailView.swift:3`): `AppServerModel`, `AppServerReasoningEffortOption`, `AppServerSkill`, `AppServerSkillScope`. Where they disagree, the ViewModel is the wire truth. Live path: `CodexServerViewModel.callCodex` → `ACPService.callJSONRPC` (`ACPClient/Sources/ACPClient/Codex/ACPService+CodexJSONRPC.swift:11-17`).
## 1. Method / event map (live)
Outbound via `callCodex(method:params:)` `CodexServerViewModel.swift:2614-2620` (awaits `ensureInitializedAck()` first):
| Method | fn | Params | Result read |
|---|---|---|---|
| `initialize` | `ACPClientManager` | — | `{userAgent}` |
| `notifications/initialized` (notif) | `:490-502` | null | — |
| `thread/start` | `:2622-2647` | `{approvalPolicy, persistExtendedHistory:true, cwd?}` | `result.thread.id` |
| `thread/resume` | `:2726-2742` | `{threadId, persistExtendedHistory:true}` | `thread{id,preview,cwd,createdAt,turns[]}` |
| `thread/read` | `:2744-2757` | `{threadId, includeTurns:true}` | same |
| `thread/loaded/list` | `:2759-2789` | `{limit, cursor|null}` paged | `data[]` of id strings |
| `addConversationListener` | `:2796-2804` | `{conversationId, experimentalRawEvents:false}` | ignored |
| `thread/list` | `:2812-2821` | `{cursor:null, limit}` | `data[]` |
| `thread/archive` | `:2823-2828` | `{threadId}` | — |
| `turn/start` | `:2649-2695` | `{threadId, input[], model?, effort?, skills?[], approvalPolicy?, sandboxPolicy?, collaborationMode?}` | `result.turn.id` |
| `turn/interrupt` | `:2716-2724` | `{threadId, turnId}` | — |
| `model/list`, `skills/list` | `:2830-2850` | `{cursor,limit}` / `{cwds?[], forceReload?}` | `data[]` |
`turn/start` input is always text-only `[{type:"text",text}]` (`:2659-2664`); images dropped with a notice. `collaborationMode` (`:2697-2714`): `{"mode":"plan"|"default","settings":{"model":id,"reasoning_effort":null,"developer_instructions":null}}` (snake_case inside; `reasoning_effort` hardcoded null while `effort` is a sibling top-level param). **`approvalPolicy` on the wire is kebab-case `"on-request"`/`"never"`** (`:139-146`); the dead package says camelCase `onRequest` (`AppServerPayloads.swift:47-51`). `sandboxPolicy` `{"type":"workspaceWrite"}` / `{"type":"dangerFullAccess"}` (`:148-155`).
Package-only, never called: `review/start`, `command/exec`, `mcpServer/oauth/login`, `mcpServerStatus/list`, `feedback/upload`, `config/read`, `config/value/write`, `config/batchWrite` (`AppServerMethods.swift:1-27`). The three methods the live hydration depends on (`thread/read`, `thread/loaded/list`, `addConversationListener`) are absent from that table.
Inbound notifications (`handleCodexMessage` `:2947-3099`, routed from `AppViewModel.swift:2316/2329`):
| Method | Handler |
|---|---|
| `turn/started` | `:3060-3078` sets `activeTurnId`, interruptible=true, binds streaming row |
| `turn/completed` | `:3029-3059` clears turn/stream/recency/saved-turn state, emits stop reason `turn_completed` |
| `item/agentMessage/delta` | `:2986-2996` → `appendAssistantText(.message)` |
| `item/plan/delta` | `:2997-2999` → `:3154-3169` → `.plan` delta |
| `item/started` / `item/completed` | `:3000-3025` → `handleCodexItemEvent(status: in_progress/completed)` |
| `turn/plan/updated` | `:3026-3028` → flatten steps → `completePlanItem` |
| `turn/diff/updated`, `codex/event/turn_diff` | `:2974-2985` → synthetic tool call `turn_diff:<turnId>` kind `edit`, output = unified diff |
| `error` | `:3079-3080` → `willRetry` inline "⚠️ … (retrying…)" else terminal system error |
No handler for `thread/started` or `thread/tokenUsage/updated` (the dead parser maps them; usage is silently dropped). Notifications for a non-active thread are dropped except `turn/completed` (`:2956-2971`).
Inbound requests: `item/commandExecution/requestApproval`, `item/fileChange/requestApproval` → `AppViewModel.swift:2330-2337` → `handleApprovalRequest` `:3636-3677`; `item/tool/requestUserInput` → `:2885-2924` (plan-mode Q&A sheet).
## 2. Item model and merge
`CodexThreadResumeResult.Item` (`:333-342`): `userMessage(id,text)`, `agentMessage`, `plan`, `reasoning`, `commandExecution(id,command?,output?)`, `fileChange(id,path?,changeType?,diff?)`, `toolCall(id,title,kind?,status?,output?)`, `unknown(type)`. No dedicated MCP/web-search/todo/error item cases: MCP and web search fall into `.toolCall`, todo lists arrive as `plan`, errors are the `error` notification.
`parseThreadItem` (`:3836-3906`) normalises type (`_` stripped, lowercased): `usermessage`; `message` (role user → user else agent); `agentmessage|assistantmessage`; `plan`; `reasoning|thought|analysis` → `extractReasoningText` (`text` → `content[]` → `summary[]` joined `\n\n`); `commandexecution|command|exec|shell` (reads only scalar `command`/`output` — non-scalar output LOST, unlike the substring fallback branch at `:3891-3894` which uses `extractToolOutput`); `filechange|file|diff|patch` → `extractFileChangeDetails` (`changes[0]` path/kind/diff else top-level); `toolcall|tool|functioncall|function` → `parseGenericToolCall` (title from `title→name→toolName→command→path→tool.name→type`); substring fallbacks; else `.unknown`. Extractors: `extractTextContent` `:4112-4176` (content[] with `type ∈ {nil,text,input_text,output_text,message}` reading `text→delta→text.value/text.text`), `extractToolOutput` `:3955-4014` (`output[]`/`output{}`/`result`/`response`/`stdout+stderr`), `extractDiffText` `:4016-4046` (`diff→patch→changes[].diff→content[type:diff|patch]`).
`handleCodexItemEvent` (`:3240-3367`) — no generic merge, per-type paths: `.reasoning` dedup via `reasoningCache[itemId]` (identical text skipped; longer re-sends append the whole body again — partial-duplication risk); `.commandExecution` → `upsertToolCallFromAppServer(kind:"execute", output only when completed)`; `.fileChange` → kind `edit`, output = diff, title `"<changeType>: <path>"`; `.toolCall` → item's own status wins over the envelope; `.plan` → `applyPlanItem` `:3427-3460`; `.agentMessage` → `applyAgentMessageItem` `:3369-3425` (extracts `<proposed_plan>…</proposed_plan>` via regex first; skips if existing equals/has-suffix/contains incoming (raw or whitespace-normalised); if incoming has existing as prefix, appends only the suffix delta; else appends whole). Tool-call field merge is `ACPSessionViewModel.applyToolCallUpdate` `:717-773`: scalars overwrite nil-coalescing to old; `output` overwrites when non-nil, never appends.
## 3. One chat UI, two protocols
The common model is the view-model layer, not the protocol layer: `ChatMessage` `AppViewModel.swift:2865-2934`, `AssistantSegment` `:2996-3015`, `ToolCallDisplay` `:3017-3032` carrying two parallel approval channels (ACP `permissionOptions`/`acpPermissionRequestId`; Codex `approvalRequestId/Kind/Reason/Command/Cwd`) — the most visible leak. Both protocols share `ACPSessionViewModel`. `ServerViewModelProtocol.swift:7-114` papers over gaps with documented no-ops: `archiveSession` (Codex only), `sendLoadSession` (ACP only; Codex redirects to `openSession`), `availableModes` (Codex `[]`), `isPendingSession` (Codex false). Rendering is two sibling views (`SessionDetailView` vs `CodexSessionDetailView`, `ContentView.swift:196-219`) over the same components. Codex-only UI: model + effort picker (`CodexSessionDetailView.swift:632-735`), skills picker by scope (`:737-823`), permissions preset (`:825-868`), plan-mode toggle + `implementPlan()` sending literal `"Implement the plan."` (`:619-627,870-887`), tri-state Send/Stop/**Reset** button (`:564-593`; Reset exists because resume can leave the client believing a turn is in flight), Archive, Share Session Logs, Approve/Decline pair (`:1316-1367`) distinct from ACP's option buttons (`:1370-1385`), `codex*` accessibility ids.
## 4. Codex approvals
Server sends a JSON-RPC request. `handleApprovalRequest` `:3636-3677` reads `itemId`, `reason`, `command`, `cwd`; kind by substring on the method name (`commandExecution` → title via `commandExecutionDisplayTitle` `:352-488` which recursively strips `/usr/bin/env`, `zsh|bash|sh -lc`, `pwsh -Command`, `cmd /c` with a quote-aware tokenizer; `fileChange` → "File change"; else "Approval required"). Surfacing: `ACPSessionViewModel.updateToolCallWithApproval` `:538-606` (records `pendingApprovalRequests[requestId] = toolCallId`, mutates the matching segment across ALL messages, else the streaming row, status `awaiting_permission`). Response `respondToApprovalRequest` `:2852-2867`: a JSON-RPC **result** `{"decision":"accept"|"decline","acceptSettings":{"forSession":bool}}` (`acceptSettings` only when non-nil). Only `accept`/`decline` literals. `item/tool/requestUserInput` params `questions[]{id, header, question|text, multiSelect, options[]{label, description, isOther, isSecret}}`; response `{"answers":{"<qid>":{"answers":["<label>",…]}}}` (`:2926-2942`).
## 5. Thread hydration and the merge algorithm (the battle-tested part)
Specs: `AppServerClient/codex-thread-hydration.md`, `Agmente/specs/codex-load-resume-merge.md`; fixtures `AgmenteTests/Fixtures/CodexThreadReadMerge/*.json` (8).
- **Source selection** `attachLoadedThreadAndRead` `:2806-2810`: `thread/loaded/list` → if loaded → `addConversationListener` → `thread/read(includeTurns:true)`; else callers fall back to `thread/resume`. Guardrail: do not reintroduce unconditional `thread/resume` on reconnect. An active turn learned from resume is **non-interruptible** (`interruptible: !usedResumeBasedHydration`) → the Reset button.
- **Stale-snapshot short-circuit** `:721-736`: if locally "likely in flight" (`isLikelyInFlightStreamingState` `:2336-2345`: streaming event within 15 s AND (activeTurnId or streaming row)) and the snapshot lacks the active turn → skip merge, keep local. `result.activeTurnId` is derived: first turn whose status normalises to `inprogress|running|pending|started` (`:3827-3834`).
- **Merge mode** `shouldPreserveLocalChatState` `:2113-2141`: true if resumed items == 0, or local tool calls > resumed, or local assistant msgs > resumed with tool calls ≥ → `preferLocalRichness: true`.
- **Merge** `mergeChatFromThreadHistory` `:1292-1557`: (1) build one `ChatMessage` per item with key `turn:<turnId>:<kind>:<itemId>` or `turn:<turnId>:idx:<i>:<kind>` (original index preserved even though user items are reordered first within a turn); (2) `sessionMessageKeys[threadId]: [UUID: String]` inverted; (3) positional bootstrap only when the key index is empty AND not carrying forward (roles + text/segment-kinds must match); (4) richness verdict `localRicherThanResume` → `shouldCarryForwardUnmatchedExisting = preferLocalRichness || (activeTurnId != nil && localRicher)`; (5) key hit → reuse the existing UUID (row identity/scroll stable) merging payload via `mergeResumeMessagePayload` `:1096-1158` (keep existing if incoming not renderable, or incoming is a prefix snapshot, or incoming dropped tool rows/output; else overwrite content/segments/images/flags keeping the id); key miss → drop the node if a local tool-rich assistant row already represents it (`isAssistantResumeNodeRepresentedByLocalToolRichMessage` `:1240-1254`: exact/normalised equality, containment when local has tool calls, **bidirectional thought-segment containment** `:1210-1220` for partially streamed thoughts, tool-call-id subset), else insert; (6) carry-forward `:1453-1507`: resume ordering is the backbone; each unrepresented local row is re-inserted next to its old neighbour (walk back for a represented predecessor → insert after; else forward → insert before; else tail), logged `carry_forward_insert`; (7) commit keys (carried-forward locals get `local:<uuid>`).
- **Streaming rebind** `applyStreamingStateFromResume` `:2296-2316` / `bindStreamingMessageForTurn` `:2363-2403`: memory → current streaming row → last assistant message keyed `turn:<id>:` → fresh row; guarded by `hadStreamingBeforeResume` so live deltas are never re-bound.
- **Refresh loop** `:2143-2242`: budget 1–3 rounds of `thread/resume` 2 s apart (3 when local is richer or resume came back empty during streaming); exits on `activeTurnId != nil` or item count stable; cancelled by prompt/turn-started/delete/open/removeAll.
- `persistExtendedHistory: true` on `thread/start` and `thread/resume`, not on `thread/read`.
## 6. Auth
Agmente performs no Codex auth: guidance text "run `codex login` before connecting" (`ServerManagementViews.swift:451`). Protocol sniff `InitializeParsing.swift:36-88`: ACP markers (`protocolVersion|agentCapabilities|agentInfo|agent`) → ACP; else `userAgent` → Codex, fabricating a profile (`name: "codex-app-server"`, version from `"codex/1.0.0"`, `loadSession=false, resumeSession=false, listSessions=true`). `authMethods` parsed but never consulted. Error surfaces: `error` notification (`willRetry` inline vs terminal system error that wipes streaming state for ALL threads `:3115-3117`), `formatPromptError` `:2443-2448`, everything else to the developer log pane.
## 7. e2e contract (verification practice worth copying)
`.agents/skills/codex-local-cli-e2e/`: one XCUITest `testCodexDirectWebSocketConnectInitializeAndSessionFlow` against a real `codex app-server`; env contract `AGMENTE_E2E_CODEX_ENABLED`, `_ENDPOINT` (`ws://127.0.0.1:8788`), `_HOST`, `_PROMPT`, `_CONFIG_PATH`; `XCTSkip` when disabled; `run_codex_local_e2e.sh` (244 ln): `trap cleanup EXIT INT TERM`, `nc -z` readiness (90×1 s managed / 3 attached), boot sim, **uninstall before running** for a deterministic first run, `xcodebuild -only-testing:`, grep failures, **unexpected skip = failure**. `.github/skills/run-agmente-codex-e2e/`: UI-driven via XcodeBuildMCP, `stdio-to-ws` bridge on port 9000 (`start_codex.sh` pid/log files, `pkill -9 -f "stdio-to-ws.*<port>"`), `describe_ui` before every tap, mandatory `cleanup.sh`. Asserted RPC sequence: `initialize → initialized → thread/list → thread/start|resume → turn/start → turn/started → turn/completed` + `item/*` streaming. (The app actually sends `notifications/initialized`, not bare `initialized` — a strict log assertion would fail.)
## 8. Bugs / oddities (file:line)
1. `AppServerClient` is dead weight (~1900/2365 lines, zero call sites) and lacks `thread/read`, `thread/loaded/list`, `addConversationListener`. 2. Approval-policy casing: live `"on-request"` (`:139-146`) vs package `"onRequest"` (`AppServerPayloads.swift:47-51`). 3. `ServerViewModelProtocol.swift:118-138` default-arg extension methods have the same signature as the requirements → infinite recursion for any conformer that omits them. 4. `AppServerService.swift:108-113` inverted-looking guard. 5. `command_execution` non-scalar output dropped (`:3867-3870`). 6. `"Tool call"` sentinel (`:4094-4096` / `:4059`). 7. `"characters)"` magic-string branch (`ACPSessionViewModel.swift:821`). 8. Terminal `error` wipes state for all threads (`:3115-3117`); 9. same over-broad clear in `removeSessionViewModel` (`:305`). 10. `rememberSession` always returns false (`:2450-2481`). 11. `reasoning_effort` hardcoded null (`:2710`). 12. Unused `service` bindings (`:525,551,2052,1810,1904`). 13. `thread/read` omits `persistExtendedHistory`. 14. `isThreadLoaded` pages the entire loaded list on every open (`:2791-2794`). 15. e2e docs assert bare `initialized`; app sends `notifications/initialized` (`:494`). 16. Two e2e skills with incompatible server assumptions (ws listen on 8788 vs stdio bridge on 9000). 17. `ensureInitializedAck` retries forever without backoff (`:490-502`). 18. `JSONRPCID.init(ACP.ID)` maps `.null` → `.int(0)` (`JSONRPC+ACPBridge.swift:11-12`). 19. `troubleshootingLoggingEnabled = false` (`:193`); rich diagnostics live in `CodexSessionLogger` behind `Agmente.codexSessionLoggingEnabled`. 20. `CodexSessionLogger.init` clamps `maxFiles` to ≥10 (`:102`). 21. Package tests cover one event; real coverage is the 8 app-side fixtures.
Companion to extract-agmente-rendering.md. Every test in rebornix/Agmente (MIT), one line each on the behaviour it pins, followed by the 18 invariants a reimplementation silently breaks, and the mocks/fixtures worth copying verbatim. Paths relative to /Users/robertboulos/projects/cloned-repos/Agmente. Extracted 2026-09-02.
Frameworks: XCTest everywhere except 4 files on swift-testing (import Testing, @Test/#expect): AgmenteTests/AgmenteTests.swift, AgmenteTests/ChatRenderingTests.swift, ACPClientTests/ACPClientTests.swift, ACPClientTests/ACPServiceTests.swift. All AgmenteTests/* view-model suites are @MainActor final class … : XCTestCase. ~208 test functions across 30 files.
Ubiquitous setup idiom: every view-model suite builds a throwaway UserDefaults(suiteName: "<Suite>.\(UUID())") + SessionStorage.inMemory() + AppViewModel(shouldStartNetworkMonitoring: false, shouldConnectOnStartup: false). A port needs equivalent injection points (storage:, defaults:, serviceFactory:, setServiceForTesting, shouldStartNetworkMonitoring:) or none of these tests can be ported.
AgmenteTests/AgentViewModelTests.swift:488 testMultipleToolCallMessagesWithSameIdUpdatesSingleSegment — repeated tool_call (not just tool_call_update) with the same toolCallId must mutate ONE segment (Claude Code sends a placeholder title "Terminal" first, then the real title). Final title = latest; status/output from later updates.CodexServerViewModelTests.swift:436 ItemDeltaRealignsStaleActiveTurn — a delta for an unknown turn id must be applied and re-point the active turn, not be dropped. Naive "drop if turnId ≠ activeTurnId" breaks reconnect.CodexServerViewModelTests.swift:598 ResumeDerivedActiveTurnUsesResetUntilLiveEventConfirmsIt — a turn derived from a resume payload blocks send but must NOT be interruptible (turn/interrupt would hang); only a live turn/started promotes it.CodexServerViewModelTests.swift:499/531 — in-flight detection is time-windowed (last streaming event ≤ ~15 s → in flight; 120 s ago → not), independent of whether a streaming row exists.CodexServerViewModelTests.swift:948–1265 savedTurnByThread family — switching threads saves the departing thread's turn; a background turn/completed must still be processed (cleanup) while background streaming deltas must be dropped. Two rules for the same background thread.AgmenteTests/Fixtures/CodexThreadReadMerge/*.json fixtures — the entire thread_read ↔ local merge/dedup algorithm (prefix growth, shrunken re-reads keep the richer local row, partial reasoning overlap, per-turn reasoning scoping, item order where agent precedes user, preferLocalRichness). The hardest thing in the repo exists only as data.ServerViewModelTests.swift:804 — session/load must precede session/prompt for a non-materialized session (ordering asserted by index, not presence).ServerViewModelTests.swift:242/488 — placeholder → resolved session migration must delete the placeholder from storage and re-key the cache; the prompt uses the resolved id; session/new carries cwd.SessionResponseParsingTests.swift:93 — a configOptions array with a category:"mode" select synthesizes modes.availableModes + currentModeId even with no modes key.ACPClientManagerRaceTests.swift:32 — disconnect() immediately followed by connect(): the new service wins and a late state change from the OLD service must be ignored (delegate callbacks identity-filtered).ACPSessionViewModelTests.swift:470 — mode-cache migration placeholder → resolved must NOT overwrite an existing destination entry.ViewModelSyncTests.swift:323 — updating a session's cwd must not bump updatedAt (list ordering preserved).AgentViewModelTests.swift:1020/1041 — preview truncation: assistant = 60 chars + … (61 total); user = "You: " + 50 + … (56 total); system → nil; whitespace-only → nil.PromptBuilderTests.swift:100 — unsupported attachments (image/audio/context without capability) are still SENT; only a warning is added.InitializeParsingTests.swift:68 / AgentViewModelTests.swift:215 / ViewModelSyncTests.swift:137 — ACP markers (protocolVersion, agentInfo) beat userAgent for protocol detection, including the VM-switch decision.SessionUpdateHandlerTests.swift:370 — empty-string content produces zero events.CodexServerViewModelTests.swift:783/843 — raw codex/event/plan_delta must be ignored once structured item/plan/delta for the same itemId arrives; plan deltas concatenate byte-exactly with zero trimming.AppServerClientTests/ResponseParsingTests.swift:140 — AppServerSkillScope.allCases declaration order IS the sort order (user < repo < system < admin).ACPClient/Sources/ACPClientMocks/MockWebSocket.swift — MockWebSocketConnection (:4, records sentTexts/pingCount/receivedHeaders, enqueue(_:) + polling receive()), MockWebSocketProvider (:44), CapturingDelegate (:56). Ships in Sources/ (a product-target module) so app tests can import it. Twin at AppServerClient/Sources/AppServerClientMocks/MockWebSocket.swift.AgmenteTests/AgentViewModelTests.swift:67 RecordingWebSocketConnection — NSLock-guarded, autoCloseOnReceive, sentTextsSnapshot(); waitForSentResponse (:139) polls decoded wire messages for a matching response id. Variant at ServerViewModelTests.swift:133 adds waitForSentRequest(method:) (:200), enqueueResponse/enqueueError (:214/:221). The pattern — read the real outbound request id off the transport and reply to it — is what makes these integration tests deterministic. For a spawned-process host, the same trick works on the stdin pipe.ACPClientManagerTests.swift:10 extractRequestId, :19 waitForSentText — JSONSerialization-based variant.ACPSessionViewModelTests.swift:12 MockCacheDelegate — most complete ACPSessionCacheDelegate fake (records save/load/clear/migrate/persist calls, separate storedMessages "disk" layer for cache→storage fallback tests). ServerViewModelTests.swift:66 StorageBackedCacheDelegate round-trips through SessionStorage, filtering out isStreaming rows (:127).ACPClientManagerTests.swift:342 MockClientManagerDelegate; ACPServiceTests.swift:10 CapturingServiceDelegate (records willSend — how method names are asserted).AgmenteTests/Fixtures/CodexThreadReadMerge/*.json + CodexThreadReadMergeFixtureTests.swift:249–378 (CodexThreadReadMergeFixture, FixtureValue recursive JSON decoder) — data-driven harness: schema initialMessages, initialKeys, steps[{kind: thread_read|update}], expectations{messageCount, orderedMessages, containsCounts}; assertOrderedMessages subsequence matcher (:91). Each fixture runs in its own XCTContext.runActivity.AgmenteUITests/AgmenteUITests.swift:162–286 — replaceText, waitForEnabled (NSPredicate exists && enabled), waitForAny, mergedCodexE2EEnvironment (env file /tmp/agmente_codex_e2e_config.env merged under process env, process wins), parseEndpoint.AgmenteTests/#ChatRenderingTests.swift (swift-testing) — entryMapperGeneratesExpectedKinds (user + streaming assistant with message+thought → kinds .userText, .assistantMarkdown, .assistantThought, .streamingIndicator; the indicator is a separate entry); entryMapperExtractsFileChangeSummary (kind:"edit" tool call with unified-diff output → extra .fileChanges entry; detection by output, not title); renderDiffDetectsInsertUpdateRemove (diff keyed on entry identity: same id changed text → updated; new id → inserted; vanished → removed); scroll policy: no animation before first render; none for bulk hydration (0→3); animate for a single tail append (2→3); none for 4 appends at once; animate for streaming content growth of the same id; none when the head id changed (session switch).
SessionStorageTests.swift — whitespace-only workingDirectory persists as "" not "/", and never becomes a "used directory".
ACPSessionViewModelTests.swift (MockCacheDelegate :12, MockEventDelegate :95) — addUserMessage auto-persists via cache delegate keyed by (serverId, sessionId); setStopReason writes through; no session context → ZERO cache writes; loadChatState(canLoadFromStorage:false) restores messages + stopReason from cache; cache miss + storage → loads, re-caches, logs "Restored 1 message"; both empty → stopReason == ""; resetChatState clears; setChatMessages replaces wholesale; handleStopReason notifies delegate AND sets stopReason; handleSessionLoadCompleted forwards ids; current_mode_update sets currentModeId and fires sessionModeDidChange exactly once; new user message isStreaming == false; startNewStreamingResponse appends empty assistant row isStreaming == true; system error row .system + isError; mode cache round-trips per (server, session); migration copies; migration does NOT overwrite destination; load→append→reset leaves empty; reset clears memory not cache, reload restores; one VM across two session keys keeps isolated transcripts; isStreaming:true survives persistence; commands update; resetCommands then restoreAvailableCommands(isNew:false) repopulates; command cache migrates with mode cache.
AgentViewModelTests.swift (RecordingWebSocketConnection :67, makeConnectedService :121) — before initialize, capability flags are nil (tri-state); ACP initialize → summary exactly "test-agent v0.1.0 (initialized)"; Codex userAgent:"codex/1.0.0" → name codex-app-server, loadSession == false, sessionListSupport stays true; ACP markers beat userAgent; qwen-code: title-preferred display name, sessionListRequiresCwd inferred, 4 modes, initialize-declared currentModeId applied to new session; missing promptCapabilities → all false; session/list populates summaries; success flips list support true; methodNotFound flips it false; items[] uses prompt as title (empty → nil) sorted by mtime desc; available_commands_update replaces list AND clears a stale selectedCommandName; two chunks concatenate into ONE segment; tool_call + tool_call_update attach to the same message and content gains a "Tool call:" line; same toolCallId → single segment (invariant 1); prompt response stopReason ends streaming even when the id was never registered via willSend; log line "session/update [sess-summary] message: Hello"; thought summary contains session id and agent_thought_chunk; thought chunk → one .thought segment; inbound session/request_permission synthesizes a tool-call segment awaiting_permission, acpPermissionRequestId == .int(0), option order preserved; end-to-end permission: options cleared locally AND {outcome:{outcome:"selected",optionId}} written to the socket, then content-array tool_call_update sets output, stopReason finishes; Gemini path: authMethods + all prompt caps true, loadSession:false, list → methodNotFound → false, 3 chunks → one message; resumeConnectionIfNeeded after init with listSessions:true emits session/list within 1 s; tool call + permission e2e: pending → awaiting → grant → in_progress → completed(rawOutput) → follow-up chunk → stopReason, final message contains both texts; preview truncation (invariant 13); session/set_mode RESPONSE logs "Mode changed to: code".
CodexServerViewModelTests.swift — userAgent-only initialize REPLACES the ACP VM with a Codex VM; ACP-shaped keeps ACP; Codex VM gets agentInfo with description == "codex/1.0.0"; isPendingSession hard-false; default preset "Default permissions" / "on-request" / {"type":"workspaceWrite"}; full access "never" / dangerFullAccess; commandExecutionDisplayTitle strips /bin/zsh -lc, /usr/bin/env zsh -lc, pwsh -NoProfile -Command, cmd /C; keeps direct scripts; unknown PowerShell flag sequences NOT stripped; type-erased accessor works for both; ACP-era summaries DISCARDED on Codex switch; setActiveSession sets both ids; Codex openSession never sets pendingSessionLoad; turn/started → streaming row, turn/completed → both false; unbinding the streaming row keeps canInterruptActiveTurn true (interruptibility derives from the turn); stale-turn realignment (invariant 2); time-windowed in-flight (invariant 4); clearLikelyInFlightState keeps partial text as a finished message; resume-derived turn not interruptible (invariant 3); item/plan/delta creates .plan segment, turn/plan/updated REPLACES it with "Implementation plan\n\n- Step one (completed)\n- Step two: Run migration (in_progress)"; <proposed_plan> wrapper → .plan with tags stripped; raw vs structured plan delta dedupe (invariant 17); collaborationMode default: reasoning_effort/developer_instructions explicit .null; plan mode "plan"; savedTurnByThread family (invariant 5); removeAllSessionViewModels clears saved turns; full session-switch streaming isolation composite. Test-only hooks: seedLastStreamingEventAtForTesting, isLikelyInFlightStreamingStateForTesting, applyResumeMergeForTesting, beginOpenSessionRequestForTesting, savedTurnByThreadForTesting, seedSavedTurnForTesting.
ServerViewModelTests.swift (TestCacheDelegate :8, StorageBackedCacheDelegate :66) — pending cwd flows into session/new and the prompt uses the server-returned id, no session/load, cache re-keyed; freshly created empty session never triggers session/load on reopen; failed session/new → sessionId == "", no prompt for the dead placeholder; resolved id replaces placeholder in Core Data (exactly ["copilot-session-1"]) and transcript persisted under resolved id only; offline (getService: { nil }) still lists and reopens from storage; stored-but-not-materialized + loadSession:true → session/load sent; loadSession:false → never sent, cached messages still render; preflight ordering (invariant 7).
SessionIsolationTests.swift — distinct ACPSessionViewModel per session id; same instance (===) on return (pending permission stays with its session, issue #16); transcripts don't bleed; placeholder VM survives switch; deleteSession → fresh VM; currentModeId per session; streaming state per session; composer draft per session; VM creation lazy; 3 sessions coexist with stable identity; ServerViewModel.isStreaming derives from the current session.
ViewModelSyncTests.swift — agentInfo synced on addServer / ACP init / Codex init (a result with userAgent AND agentInfo stays ACP, name "codex-agent"); methodNotFound on list propagates to serverViewModel.agentInfo.capabilities.listSessions; isPendingSession false with no server; absent listSessions defaults TRUE; explicit false respected; setActiveSession(id, cwd:) rewrites summary cwd; cwd update preserves updatedAt (invariant 12); selecting another server doesn't overwrite the first server's host/cwd.
CodexThreadReadMergeFixtureTests.swift — one test enumerates every fixture (sorted, non-empty), seeds via seedMergeStateForTesting(threadId:messages:keySeeds:activeTurnId:), replays thread_read/update steps, asserts messageCount, ordered subsequence, per-role contains counts. Fixtures: thread_read_after_background_turn_completion (preferLocalRichness:false, no duplication trusting server), thread_read_combined_reasoning_dedup (server splits reasoning r1/r2, local has all inline → count 2, each header once), thread_read_markdown_growth_then_updates (second read grows the turn, then live delta → count 3, no prefix re-emit), thread_read_new_turn_reasoning_not_suppressed (turn-2 reasoning not suppressed by turn-1 dedup), thread_read_overlaps_inflight_streaming (initialActiveTurnId:"turn-2", order preserved, user prompts once each), thread_read_partial_thought_dedup (local has r1,r2 not r3 → dedupe + append → count 4), thread_read_same_prefix_then_updates (shrunken second read keeps richer local row, then delta → count 3), thread_read_then_updates (server order agent-before-user → merged puts user first, count 2).
AgmenteTests.swift — empty placeholder. AgmenteUITests.swift — testCodexDirectWebSocketConnectInitializeAndSessionFlow (opt-in via AGMENTE_E2E_CODEX_ENABLED) pins the accessibility-id contract (emptyStateAddServerButton, ServerNameField, ServerTypeCodex/ServerTypePicker, ProtocolPicker, HostField, saveToolbarButton/SaveServerButton, serverSummaryConfirmButton, newSessionButton, codexPromptEditor, codexSendButton, one of codexAssistantBubble/codexThinkingBubble/codexSystemBubble) and the flow timings (new session enabled ≤45 s, echo ≤20 s, server progress ≤45 s).
ACPClient/Tests/ACPClientTests/#PermissionRequestParsingTests — sessionId top-level, id/title/kind from nested toolCall, allow_always → .allowAlways, order preserved; missing title → "Unknown operation", missing options → empty array.
ServiceModelTests — session/load params {sessionId, cwd, mcpServers: []} (wire key cwd; mcpServers always present); resume same; session/new {cwd, mcpServers: []} with agent omitted when nil; list default is an EMPTY object; list with limit/cursor/cwd; set_mode {sessionId, modeId}; cancel {sessionId}; initialize protocolVersion == 1 + clientInfo{name,version}; clientCapabilities verbatim incl. terminal:false.
ACPServiceTests (swift-testing, CapturingServiceDelegate :10) — request ids start at 1; a response is never delivered as a notification; RPC error throws ACPServiceError.rpc and is NOT double-reported to didEncounterError; method names exactly session/load, session/set_config_option.
SessionUpdateParsingTests — log summaries: "session/update [sess-1] message: Hello", "… tool_call [execute] Shell: git status", "… tool_call_update: completed", "… mode -> plan", "… available commands updated"; text extraction walks content[] → .content → .text; rawOutput wins over content.text; user text from content.text.
ACPClientTests (swift-testing) — wire round-trip for request (string id) / response (int id) / notification without a jsonrpc discriminator; .closed after a message doesn't drop it; Authorization: Bearer from token provider + merged headers; pingInterval: 0.01 → ≥2 pings in 30 ms; setWithoutEscapingSlashesEnabled(true) → "method":"session/list" not session\/list (codex-acp rejects escaped slashes).
ACPMessageBuilderTests — {outcome:{outcome:"selected", optionId}} (doubly nested); cancelled has NO optionId key; initialized notification params null (not {}); error carries code/message.
AgentInfoParsingTests — title preferred for display; sessionCapabilities.resume present (even {}) → resumeSession == true; sessionListRequiresCwd inferred; explicit listSessions:false respected; input.hint → inputHint.
SessionListParsingTests — prompt → title; mtime is MILLISECONDS; ISO updatedAt parsed; newest first; cwd transform hook applied.
InitializeParsingTests — ACP: 2 modes, currentModeId, 2 authMethods (null description tolerated), promptCapabilitiesDeclared == true; Codex userAgent-only: version split on /, description = raw userAgent, 0 authMethods; markers beat userAgent; codex-1.0.0 (no slash) → version nil, name "Codex app-server"; codex/ → version nil; app-server/3.2.1 still Codex naming.
ACPClientManagerRaceTests — invariant 10.
ACPClientManagerTests — client id persisted under "ACPClientManager.clientId", reused, explicit arg overrides; initial state .disconnected, isNetworkAvailable == true before monitoring; lastConnectedAt restored; config defaults pingInterval == 15, requiresUnescapedSlashes == false; reconnect defaults maxReconnectAttempts == 3, reconnectBaseDelay == 1.0, healthCheckTimeout == 8.0; disconnect nils service + clears isConnecting + emits .disconnected; connectAndWait writes timestamp; verifyConnectionHealth pings; initializeAndWait reads the real outbound id and replies → isInitialized; resetSessionState clears materialized + resuming sets; .failed clears isConnecting.
PromptBuilderTests — text-only valid; empty/whitespace invalid; " Hello " sent verbatim (trim only for the check); empty text + image valid; order text → images → audio → contexts; unsupported attachments still sent with warning ("does not support image prompts" / audio / "does not support embedded context"); JSON {type:"text",text}, {type:"image",mimeType,data} (base64 key data), {type:"audio",…}, {type:"context",text,source} (nil source omitted); debug description redacts base64 as data:"<6 chars>"; validate error exactly "Cannot send empty prompt"; invalid result → nil payload; 3 unsupported types → exactly 3 warnings.
ResponseDispatcherTests — session/new → .sessionActivated + .sessionMaterialized; session/create same; session/load adds .sessionLoadCompleted; resume same as new; placeholder ≠ returned id → .sessionMigrated; equal → none; set_mode via currentModeId or modeId; configOptions → .configOptionsChanged AND derived .modeChanged from the mode-category currentValue; .initialized for initialize and inferred from an agent object with nil method; .stopReason; session/list → .sessionListReceived + .capabilityConfirmed(.listSessions); items alias; cwd transform; -32601 on load/resume/list → .rpcError + .capabilityDisabled(...); -32600 → .rpcError only; modes ride inside .sessionActivated; a session key on a session/prompt response activates but must NOT materialize; {} on cancel → no actions; nil result on new → no activation; missing cwd → context.pendingCwd.
SessionResponseParsingTests — id aliases sessionId/session/id; cwd aliases cwd/workingDirectory; mode description optional; configOptions synthesize modes (invariant 9); fallbacks; nil handling; load: history/messages aliases, roles mapped, ISO timestamps, nil result → requestedSessionId + empty history; set_mode currentModeId/modeId, {success:true} → nil; selectedChoiceName resolves display name; parseModes: order preserved, only-current → empty available, only-available → nil current, modes:{} → NIL, no key → nil, invalid modes skipped; Equatable conformances.
SessionUpdateHandlerTests (keys off update.type) — content bare string or {text}; thought; user; tool_call full; minimal → status defaults "pending", id/kind nil; rawOutput → output; title/kind updatable post hoc; content-array output when no rawOutput; mode change; missing modeId → ZERO events; commands with/without input.hint; session filter mismatch → 0, match → 1, nil filter → pass; unknown type with text → .agentMessage, without → 0; nil params / empty update / empty text → 0 events; sessionId(from:).
AppServerClient/Tests/AppServerClientTests/#EventParserTests — item/agentMessage/delta → one .agentMessageDelta(threadId, turnId, delta). JSONRPCTests — request MISSING jsonrpc still decodes (Codex app-server omits it); header is opt-in per request. ResponseParsingTests — skills at data[0].skills sort by scope user < repo < system < admin, then name; Comparable matches; display names User/Repository/System/Admin; allCases order load-bearing (invariant 18).
# Agmente test oracle (commit 87f224e) — what a Swift ACP host must get right
Companion to `extract-agmente-rendering.md`. Every test in `rebornix/Agmente` (MIT), one line each on the behaviour it pins, followed by the 18 invariants a reimplementation silently breaks, and the mocks/fixtures worth copying verbatim. Paths relative to `/Users/robertboulos/projects/cloned-repos/Agmente`. Extracted 2026-09-02.
**Frameworks:** XCTest everywhere except 4 files on swift-testing (`import Testing`, `@Test`/`#expect`): `AgmenteTests/AgmenteTests.swift`, `AgmenteTests/ChatRenderingTests.swift`, `ACPClientTests/ACPClientTests.swift`, `ACPClientTests/ACPServiceTests.swift`. All `AgmenteTests/*` view-model suites are `@MainActor final class … : XCTestCase`. ~208 test functions across 30 files.
**Ubiquitous setup idiom:** every view-model suite builds a throwaway `UserDefaults(suiteName: "<Suite>.\(UUID())")` + `SessionStorage.inMemory()` + `AppViewModel(shouldStartNetworkMonitoring: false, shouldConnectOnStartup: false)`. A port needs equivalent injection points (`storage:`, `defaults:`, `serviceFactory:`, `setServiceForTesting`, `shouldStartNetworkMonitoring:`) or none of these tests can be ported.
---
## (1) The 18 non-obvious invariants
1. **`AgmenteTests/AgentViewModelTests.swift:488` `testMultipleToolCallMessagesWithSameIdUpdatesSingleSegment`** — repeated `tool_call` (not just `tool_call_update`) with the same `toolCallId` must mutate ONE segment (Claude Code sends a placeholder title "Terminal" first, then the real title). Final title = latest; status/output from later updates.
2. **`CodexServerViewModelTests.swift:436` `ItemDeltaRealignsStaleActiveTurn`** — a delta for an unknown turn id must be applied and re-point the active turn, not be dropped. Naive "drop if turnId ≠ activeTurnId" breaks reconnect.
3. **`CodexServerViewModelTests.swift:598` `ResumeDerivedActiveTurnUsesResetUntilLiveEventConfirmsIt`** — a turn derived from a resume payload blocks send but must NOT be interruptible (`turn/interrupt` would hang); only a live `turn/started` promotes it.
4. **`CodexServerViewModelTests.swift:499/531`** — in-flight detection is time-windowed (last streaming event ≤ ~15 s → in flight; 120 s ago → not), independent of whether a streaming row exists.
5. **`CodexServerViewModelTests.swift:948–1265` `savedTurnByThread` family** — switching threads saves the departing thread's turn; a background `turn/completed` must still be processed (cleanup) while background streaming deltas must be dropped. Two rules for the same background thread.
6. **The 8 `AgmenteTests/Fixtures/CodexThreadReadMerge/*.json` fixtures** — the entire thread_read ↔ local merge/dedup algorithm (prefix growth, shrunken re-reads keep the richer local row, partial reasoning overlap, per-turn reasoning scoping, item order where agent precedes user, `preferLocalRichness`). The hardest thing in the repo exists only as data.
7. **`ServerViewModelTests.swift:804`** — `session/load` must precede `session/prompt` for a non-materialized session (ordering asserted by index, not presence).
8. **`ServerViewModelTests.swift:242/488`** — placeholder → resolved session migration must delete the placeholder from storage and re-key the cache; the prompt uses the resolved id; `session/new` carries `cwd`.
9. **`SessionResponseParsingTests.swift:93`** — a `configOptions` array with a `category:"mode"` select synthesizes `modes.availableModes` + `currentModeId` even with no `modes` key.
10. **`ACPClientManagerRaceTests.swift:32`** — `disconnect()` immediately followed by `connect()`: the new service wins and a late state change from the OLD service must be ignored (delegate callbacks identity-filtered).
11. **`ACPSessionViewModelTests.swift:470`** — mode-cache migration placeholder → resolved must NOT overwrite an existing destination entry.
12. **`ViewModelSyncTests.swift:323`** — updating a session's cwd must not bump `updatedAt` (list ordering preserved).
13. **`AgentViewModelTests.swift:1020/1041`** — preview truncation: assistant = 60 chars + `…` (61 total); user = `"You: "` + 50 + `…` (56 total); system → nil; whitespace-only → nil.
14. **`PromptBuilderTests.swift:100`** — unsupported attachments (image/audio/context without capability) are still SENT; only a warning is added.
15. **`InitializeParsingTests.swift:68` / `AgentViewModelTests.swift:215` / `ViewModelSyncTests.swift:137`** — ACP markers (`protocolVersion`, `agentInfo`) beat `userAgent` for protocol detection, including the VM-switch decision.
16. **`SessionUpdateHandlerTests.swift:370`** — empty-string content produces zero events.
17. **`CodexServerViewModelTests.swift:783/843`** — raw `codex/event/plan_delta` must be ignored once structured `item/plan/delta` for the same itemId arrives; plan deltas concatenate byte-exactly with zero trimming.
18. **`AppServerClientTests/ResponseParsingTests.swift:140`** — `AppServerSkillScope.allCases` declaration order IS the sort order (user < repo < system < admin).
---
## (2) Mocks / helpers / fixtures worth copying verbatim
- `ACPClient/Sources/ACPClientMocks/MockWebSocket.swift` — `MockWebSocketConnection` (`:4`, records `sentTexts`/`pingCount`/`receivedHeaders`, `enqueue(_:)` + polling `receive()`), `MockWebSocketProvider` (`:44`), `CapturingDelegate` (`:56`). Ships in `Sources/` (a product-target module) so app tests can import it. Twin at `AppServerClient/Sources/AppServerClientMocks/MockWebSocket.swift`.
- `AgmenteTests/AgentViewModelTests.swift:67` `RecordingWebSocketConnection` — `NSLock`-guarded, `autoCloseOnReceive`, `sentTextsSnapshot()`; `waitForSentResponse` (`:139`) polls decoded wire messages for a matching response id. Variant at `ServerViewModelTests.swift:133` adds `waitForSentRequest(method:)` (`:200`), `enqueueResponse`/`enqueueError` (`:214/:221`). **The pattern — read the real outbound request id off the transport and reply to it — is what makes these integration tests deterministic.** For a spawned-process host, the same trick works on the stdin pipe.
- `ACPClientManagerTests.swift:10` `extractRequestId`, `:19` `waitForSentText` — JSONSerialization-based variant.
- `ACPSessionViewModelTests.swift:12` `MockCacheDelegate` — most complete `ACPSessionCacheDelegate` fake (records save/load/clear/migrate/persist calls, separate `storedMessages` "disk" layer for cache→storage fallback tests). `ServerViewModelTests.swift:66` `StorageBackedCacheDelegate` round-trips through `SessionStorage`, filtering out `isStreaming` rows (`:127`).
- `ACPClientManagerTests.swift:342` `MockClientManagerDelegate`; `ACPServiceTests.swift:10` `CapturingServiceDelegate` (records `willSend` — how method names are asserted).
- `AgmenteTests/Fixtures/CodexThreadReadMerge/*.json` + `CodexThreadReadMergeFixtureTests.swift:249–378` (`CodexThreadReadMergeFixture`, `FixtureValue` recursive JSON decoder) — data-driven harness: schema `initialMessages`, `initialKeys`, `steps[{kind: thread_read|update}]`, `expectations{messageCount, orderedMessages, containsCounts}`; `assertOrderedMessages` subsequence matcher (`:91`). Each fixture runs in its own `XCTContext.runActivity`.
- `AgmenteUITests/AgmenteUITests.swift:162–286` — `replaceText`, `waitForEnabled` (NSPredicate `exists && enabled`), `waitForAny`, `mergedCodexE2EEnvironment` (env file `/tmp/agmente_codex_e2e_config.env` merged under process env, process wins), `parseEndpoint`.
---
## (3) Full catalog
### `AgmenteTests/`
**`ChatRenderingTests.swift`** (swift-testing) — `entryMapperGeneratesExpectedKinds` (user + streaming assistant with message+thought → kinds `.userText`, `.assistantMarkdown`, `.assistantThought`, `.streamingIndicator`; the indicator is a separate entry); `entryMapperExtractsFileChangeSummary` (`kind:"edit"` tool call with unified-diff output → extra `.fileChanges` entry; detection by output, not title); `renderDiffDetectsInsertUpdateRemove` (diff keyed on entry identity: same id changed text → updated; new id → inserted; vanished → removed); scroll policy: no animation before first render; none for bulk hydration (0→3); animate for a single tail append (2→3); none for 4 appends at once; animate for streaming content growth of the same id; none when the head id changed (session switch).
**`SessionStorageTests.swift`** — whitespace-only `workingDirectory` persists as `""` not `"/"`, and never becomes a "used directory".
**`ACPSessionViewModelTests.swift`** (`MockCacheDelegate :12`, `MockEventDelegate :95`) — `addUserMessage` auto-persists via cache delegate keyed by (serverId, sessionId); `setStopReason` writes through; no session context → ZERO cache writes; `loadChatState(canLoadFromStorage:false)` restores messages + stopReason from cache; cache miss + storage → loads, re-caches, logs "Restored 1 message"; both empty → `stopReason == ""`; `resetChatState` clears; `setChatMessages` replaces wholesale; `handleStopReason` notifies delegate AND sets stopReason; `handleSessionLoadCompleted` forwards ids; `current_mode_update` sets `currentModeId` and fires `sessionModeDidChange` exactly once; new user message `isStreaming == false`; `startNewStreamingResponse` appends empty assistant row `isStreaming == true`; system error row `.system` + `isError`; mode cache round-trips per (server, session); migration copies; migration does NOT overwrite destination; load→append→reset leaves empty; reset clears memory not cache, reload restores; one VM across two session keys keeps isolated transcripts; `isStreaming:true` survives persistence; commands update; `resetCommands` then `restoreAvailableCommands(isNew:false)` repopulates; command cache migrates with mode cache.
**`AgentViewModelTests.swift`** (`RecordingWebSocketConnection :67`, `makeConnectedService :121`) — before initialize, capability flags are nil (tri-state); ACP initialize → summary exactly `"test-agent v0.1.0 (initialized)"`; Codex `userAgent:"codex/1.0.0"` → name `codex-app-server`, `loadSession == false`, `sessionListSupport` stays true; ACP markers beat userAgent; qwen-code: title-preferred display name, `sessionListRequiresCwd` inferred, 4 modes, initialize-declared `currentModeId` applied to new session; missing `promptCapabilities` → all false; `session/list` populates summaries; success flips list support true; `methodNotFound` flips it false; `items[]` uses `prompt` as title (empty → nil) sorted by `mtime` desc; `available_commands_update` replaces list AND clears a stale `selectedCommandName`; two chunks concatenate into ONE segment; `tool_call` + `tool_call_update` attach to the same message and `content` gains a "Tool call:" line; **same toolCallId → single segment (invariant 1)**; prompt response `stopReason` ends streaming even when the id was never registered via `willSend`; log line `"session/update [sess-summary] message: Hello"`; thought summary contains session id and `agent_thought_chunk`; thought chunk → one `.thought` segment; inbound `session/request_permission` synthesizes a tool-call segment `awaiting_permission`, `acpPermissionRequestId == .int(0)`, option order preserved; end-to-end permission: options cleared locally AND `{outcome:{outcome:"selected",optionId}}` written to the socket, then content-array `tool_call_update` sets output, `stopReason` finishes; Gemini path: authMethods + all prompt caps true, `loadSession:false`, list → methodNotFound → false, 3 chunks → one message; `resumeConnectionIfNeeded` after init with `listSessions:true` emits `session/list` within 1 s; tool call + permission e2e: pending → awaiting → grant → in_progress → completed(rawOutput) → follow-up chunk → stopReason, final message contains both texts; preview truncation (invariant 13); `session/set_mode` RESPONSE logs "Mode changed to: code".
**`CodexServerViewModelTests.swift`** — userAgent-only initialize REPLACES the ACP VM with a Codex VM; ACP-shaped keeps ACP; Codex VM gets agentInfo with `description == "codex/1.0.0"`; `isPendingSession` hard-false; default preset `"Default permissions"` / `"on-request"` / `{"type":"workspaceWrite"}`; full access `"never"` / `dangerFullAccess`; `commandExecutionDisplayTitle` strips `/bin/zsh -lc`, `/usr/bin/env zsh -lc`, `pwsh -NoProfile -Command`, `cmd /C`; keeps direct scripts; unknown PowerShell flag sequences NOT stripped; type-erased accessor works for both; ACP-era summaries DISCARDED on Codex switch; `setActiveSession` sets both ids; Codex `openSession` never sets `pendingSessionLoad`; `turn/started` → streaming row, `turn/completed` → both false; unbinding the streaming row keeps `canInterruptActiveTurn` true (interruptibility derives from the turn); **stale-turn realignment (invariant 2)**; time-windowed in-flight (invariant 4); `clearLikelyInFlightState` keeps partial text as a finished message; **resume-derived turn not interruptible (invariant 3)**; `item/plan/delta` creates `.plan` segment, `turn/plan/updated` REPLACES it with `"Implementation plan\n\n- Step one (completed)\n- Step two: Run migration (in_progress)"`; `<proposed_plan>` wrapper → `.plan` with tags stripped; raw vs structured plan delta dedupe (invariant 17); collaborationMode default: `reasoning_effort`/`developer_instructions` explicit `.null`; plan mode `"plan"`; `savedTurnByThread` family (invariant 5); `removeAllSessionViewModels` clears saved turns; full session-switch streaming isolation composite. Test-only hooks: `seedLastStreamingEventAtForTesting`, `isLikelyInFlightStreamingStateForTesting`, `applyResumeMergeForTesting`, `beginOpenSessionRequestForTesting`, `savedTurnByThreadForTesting`, `seedSavedTurnForTesting`.
**`ServerViewModelTests.swift`** (`TestCacheDelegate :8`, `StorageBackedCacheDelegate :66`) — pending cwd flows into `session/new` and the prompt uses the server-returned id, no `session/load`, cache re-keyed; freshly created empty session never triggers `session/load` on reopen; failed `session/new` → `sessionId == ""`, no prompt for the dead placeholder; resolved id replaces placeholder in Core Data (exactly `["copilot-session-1"]`) and transcript persisted under resolved id only; offline (`getService: { nil }`) still lists and reopens from storage; stored-but-not-materialized + `loadSession:true` → `session/load` sent; `loadSession:false` → never sent, cached messages still render; **preflight ordering (invariant 7)**.
**`SessionIsolationTests.swift`** — distinct `ACPSessionViewModel` per session id; same instance (`===`) on return (pending permission stays with its session, issue #16); transcripts don't bleed; placeholder VM survives switch; `deleteSession` → fresh VM; `currentModeId` per session; streaming state per session; composer draft per session; VM creation lazy; 3 sessions coexist with stable identity; `ServerViewModel.isStreaming` derives from the current session.
**`ViewModelSyncTests.swift`** — agentInfo synced on addServer / ACP init / Codex init (a result with userAgent AND agentInfo stays ACP, name `"codex-agent"`); `methodNotFound` on list propagates to `serverViewModel.agentInfo.capabilities.listSessions`; `isPendingSession` false with no server; absent `listSessions` defaults TRUE; explicit false respected; `setActiveSession(id, cwd:)` rewrites summary cwd; **cwd update preserves `updatedAt` (invariant 12)**; selecting another server doesn't overwrite the first server's host/cwd.
**`CodexThreadReadMergeFixtureTests.swift`** — one test enumerates every fixture (sorted, non-empty), seeds via `seedMergeStateForTesting(threadId:messages:keySeeds:activeTurnId:)`, replays `thread_read`/`update` steps, asserts `messageCount`, ordered subsequence, per-role contains counts. Fixtures: `thread_read_after_background_turn_completion` (`preferLocalRichness:false`, no duplication trusting server), `thread_read_combined_reasoning_dedup` (server splits reasoning r1/r2, local has all inline → count 2, each header once), `thread_read_markdown_growth_then_updates` (second read grows the turn, then live delta → count 3, no prefix re-emit), `thread_read_new_turn_reasoning_not_suppressed` (turn-2 reasoning not suppressed by turn-1 dedup), `thread_read_overlaps_inflight_streaming` (`initialActiveTurnId:"turn-2"`, order preserved, user prompts once each), `thread_read_partial_thought_dedup` (local has r1,r2 not r3 → dedupe + append → count 4), `thread_read_same_prefix_then_updates` (shrunken second read keeps richer local row, then delta → count 3), `thread_read_then_updates` (server order agent-before-user → merged puts user first, count 2).
**`AgmenteTests.swift`** — empty placeholder. **`AgmenteUITests.swift`** — `testCodexDirectWebSocketConnectInitializeAndSessionFlow` (opt-in via `AGMENTE_E2E_CODEX_ENABLED`) pins the accessibility-id contract (`emptyStateAddServerButton`, `ServerNameField`, `ServerTypeCodex`/`ServerTypePicker`, `ProtocolPicker`, `HostField`, `saveToolbarButton`/`SaveServerButton`, `serverSummaryConfirmButton`, `newSessionButton`, `codexPromptEditor`, `codexSendButton`, one of `codexAssistantBubble`/`codexThinkingBubble`/`codexSystemBubble`) and the flow timings (new session enabled ≤45 s, echo ≤20 s, server progress ≤45 s).
### `ACPClient/Tests/ACPClientTests/`
**`PermissionRequestParsingTests`** — sessionId top-level, id/title/kind from nested `toolCall`, `allow_always` → `.allowAlways`, order preserved; missing title → `"Unknown operation"`, missing options → empty array.
**`ServiceModelTests`** — `session/load` params `{sessionId, cwd, mcpServers: []}` (wire key `cwd`; `mcpServers` always present); resume same; `session/new` `{cwd, mcpServers: []}` with `agent` omitted when nil; list default is an EMPTY object; list with `limit`/`cursor`/`cwd`; set_mode `{sessionId, modeId}`; cancel `{sessionId}`; initialize `protocolVersion == 1` + `clientInfo{name,version}`; `clientCapabilities` verbatim incl. `terminal:false`.
**`ACPServiceTests`** (swift-testing, `CapturingServiceDelegate :10`) — request ids start at 1; a response is never delivered as a notification; RPC error throws `ACPServiceError.rpc` and is NOT double-reported to `didEncounterError`; method names exactly `session/load`, `session/set_config_option`.
**`SessionUpdateParsingTests`** — log summaries: `"session/update [sess-1] message: Hello"`, `"… tool_call [execute] Shell: git status"`, `"… tool_call_update: completed"`, `"… mode -> plan"`, `"… available commands updated"`; text extraction walks `content[] → .content → .text`; `rawOutput` wins over `content.text`; user text from `content.text`.
**`ACPClientTests`** (swift-testing) — wire round-trip for request (string id) / response (int id) / notification without a `jsonrpc` discriminator; `.closed` after a message doesn't drop it; `Authorization: Bearer` from token provider + merged headers; `pingInterval: 0.01` → ≥2 pings in 30 ms; `setWithoutEscapingSlashesEnabled(true)` → `"method":"session/list"` not `session\/list` (codex-acp rejects escaped slashes).
**`ACPMessageBuilderTests`** — `{outcome:{outcome:"selected", optionId}}` (doubly nested); cancelled has NO `optionId` key; initialized notification params null (not `{}`); error carries code/message.
**`AgentInfoParsingTests`** — `title` preferred for display; `sessionCapabilities.resume` present (even `{}`) → `resumeSession == true`; `sessionListRequiresCwd` inferred; explicit `listSessions:false` respected; `input.hint` → `inputHint`.
**`SessionListParsingTests`** — `prompt` → title; `mtime` is MILLISECONDS; ISO `updatedAt` parsed; newest first; cwd transform hook applied.
**`InitializeParsingTests`** — ACP: 2 modes, `currentModeId`, 2 authMethods (null description tolerated), `promptCapabilitiesDeclared == true`; Codex userAgent-only: version split on `/`, description = raw userAgent, 0 authMethods; markers beat userAgent; `codex-1.0.0` (no slash) → version nil, name `"Codex app-server"`; `codex/` → version nil; `app-server/3.2.1` still Codex naming.
**`ACPClientManagerRaceTests`** — invariant 10.
**`ACPClientManagerTests`** — client id persisted under `"ACPClientManager.clientId"`, reused, explicit arg overrides; initial state `.disconnected`, `isNetworkAvailable == true` before monitoring; `lastConnectedAt` restored; config defaults `pingInterval == 15`, `requiresUnescapedSlashes == false`; reconnect defaults `maxReconnectAttempts == 3`, `reconnectBaseDelay == 1.0`, `healthCheckTimeout == 8.0`; disconnect nils service + clears `isConnecting` + emits `.disconnected`; `connectAndWait` writes timestamp; `verifyConnectionHealth` pings; `initializeAndWait` reads the real outbound id and replies → `isInitialized`; `resetSessionState` clears materialized + resuming sets; `.failed` clears `isConnecting`.
**`PromptBuilderTests`** — text-only valid; empty/whitespace invalid; `" Hello "` sent verbatim (trim only for the check); empty text + image valid; order text → images → audio → contexts; **unsupported attachments still sent with warning** (`"does not support image prompts"` / audio / `"does not support embedded context"`); JSON `{type:"text",text}`, `{type:"image",mimeType,data}` (base64 key `data`), `{type:"audio",…}`, `{type:"context",text,source}` (nil source omitted); debug description redacts base64 as `data:"<6 chars>"`; validate error exactly `"Cannot send empty prompt"`; invalid result → nil payload; 3 unsupported types → exactly 3 warnings.
**`ResponseDispatcherTests`** — `session/new` → `.sessionActivated` + `.sessionMaterialized`; `session/create` same; `session/load` adds `.sessionLoadCompleted`; resume same as new; placeholder ≠ returned id → `.sessionMigrated`; equal → none; set_mode via `currentModeId` or `modeId`; `configOptions` → `.configOptionsChanged` AND derived `.modeChanged` from the `mode`-category `currentValue`; `.initialized` for `initialize` and inferred from an `agent` object with nil method; `.stopReason`; `session/list` → `.sessionListReceived` + `.capabilityConfirmed(.listSessions)`; `items` alias; cwd transform; -32601 on load/resume/list → `.rpcError` + `.capabilityDisabled(...)`; -32600 → `.rpcError` only; modes ride inside `.sessionActivated`; a `session` key on a `session/prompt` response activates but must NOT materialize; `{}` on cancel → no actions; nil result on new → no activation; missing cwd → `context.pendingCwd`.
**`SessionResponseParsingTests`** — id aliases `sessionId`/`session`/`id`; cwd aliases `cwd`/`workingDirectory`; mode `description` optional; **configOptions synthesize modes (invariant 9)**; fallbacks; nil handling; load: `history`/`messages` aliases, roles mapped, ISO timestamps, nil result → requestedSessionId + empty history; set_mode `currentModeId`/`modeId`, `{success:true}` → nil; `selectedChoiceName` resolves display name; `parseModes`: order preserved, only-current → empty available, only-available → nil current, `modes:{}` → NIL, no key → nil, invalid modes skipped; Equatable conformances.
**`SessionUpdateHandlerTests`** (keys off `update.type`) — `content` bare string or `{text}`; thought; user; tool_call full; minimal → status defaults `"pending"`, id/kind nil; `rawOutput` → output; title/kind updatable post hoc; content-array output when no rawOutput; mode change; missing modeId → ZERO events; commands with/without `input.hint`; session filter mismatch → 0, match → 1, nil filter → pass; unknown type with text → `.agentMessage`, without → 0; nil params / empty update / **empty text → 0 events**; `sessionId(from:)`.
### `AppServerClient/Tests/AppServerClientTests/`
**`EventParserTests`** — `item/agentMessage/delta` → one `.agentMessageDelta(threadId, turnId, delta)`. **`JSONRPCTests`** — request MISSING `jsonrpc` still decodes (Codex app-server omits it); header is opt-in per request. **`ResponseParsingTests`** — skills at `data[0].skills` sort by scope user < repo < system < admin, then name; `Comparable` matches; display names `User`/`Repository`/`System`/`Admin`; `allCases` order load-bearing (invariant 18).
Date: 2026-09-02. Scope: how four shipping open-source ACP desktop clients actually spawn agents, fold session/update, render tool calls, gate permissions, run terminals, persist sessions, and inject MCP — plus the adapter bug themes that bite every host. Written to inform a Swift-native ACP host inside SnappyOS.app that spawns @agentclientprotocol/claude-agent-acp, @agentclientprotocol/codex-acp and gemini --acp and speaks ACP with wiedymi/swift-acp.
Prior art this report deliberately goes past: ~/.claude/skills/snappy-agent-host/references/extract-agent-host.md §A (fazm's Node bridge) and §D (a one-paragraph table row on Emdash). Everything below is read from source at the pinned commits.
Tags: [src] = repo code read locally · [docs] = in-repo docs/README · [issue] = GitHub issue/PR/release read via gh.
| # | Source | Pinned at | Stack | Read |
|---|---|---|---|---|
| 1 | generalaction/emdash — /Users/robertboulos/projects/cloned-repos/emdash |
30ddc86 (2026-09-02, "Merge PR #3099") |
Electron + Node 24 + pnpm/nx monorepo; Solid (chat transcript) + React (shell); TS everywhere | [src] ~40 files across packages/core/src/runtimes/acp/**, packages/plugins/src/agents/**, packages/chat-ui, packages/ui, apps/emdash-desktop/src/main/gateway, packages/wire |
| 2 | diodeme/Gold-Band — .../gold-band |
9f9247c (2026-09-01) |
Tauri (Rust) + web frontend, "local-first ACP desktop client" | [src] see §D |
| 3 | newioapp/acp-inspector — .../acp-inspector |
57b993e (2026-06-26) |
Electron + electron-vite | [src] see §E |
| 4 | recailai/jockey — .../jockey |
c7431a8 (2026-06-07) |
Tauri (Rust) + Vite frontend | [src] see §F |
| 5 | agentclientprotocol/claude-agent-acp |
releases to v0.73.0 (2026-09-01) | — | [issue] gh issue list --state all --limit 80, gh release list, ~11 issue bodies |
| 6 | agentclientprotocol/codex-acp |
releases to v1.8.0 (2026-09-01) | — | [issue] same treatment, ~14 issue bodies |
| 7 | emdash issue tracker | — | — | [issue] 8 searches (acp, permission, resume, terminal, mcp, zombie, session/update, codex-acp) + 9 bodies |
Not read (owned by other lanes): Agmente, zed.
Headline: Emdash is not "another Electron wrapper." It is the most complete open-source ACP client implementation in existence outside Zed — 23 ACP-capable providers, a pure transcript reducer with invariant assertions, a formal session state machine, client-side terminals, a raw ACP log with byte caps, suspend/rematerialize, and an allowlisted env. About 60% of it maps 1:1 onto Swift types. §I is written against it.
Emdash does not have a fazm-style "bridge" that re-frames ACP into a private protocol. Electron main forks one Node child (out/main/<acp worker>) that is the ACP client, and that worker spawns agent processes directly.
Electron main
└─ fork(desktopWorkerPath('acp')) ← Wire worker, IPC 'advanced' serialization
└─ AcpRuntime → SessionManager → ConversationHandle → SessionCell
└─ ChildAcpProcessHost.spawn(...) ← one process per (providerId, cwd)
├─ electron-as-node claude-acp.mjs (CLAUDE_CODE_EXECUTABLE=<host claude>)
└─ electron-as-node codex-acp.cjs (CODEX_PATH=<host codex>)
apps/emdash-desktop/src/main/gateway/entries/acp.ts:1-7 — runWireComponentWorker(createAcpComponent({ pluginRegistry })). The plugin registry is injected by the app so @emdash/core never imports @emdash/plugins [src].packages/wire/src/worker/node/child-process-spawner.ts:16-25 — fork(spec.entry, args, { stdio: ['ignore','pipe','pipe','ipc'], serialization: 'advanced' }). The comment names the reason: structured-clone preserves undefined, typed arrays and Date across IPC [src].child_process.fork behavior, which runs children with ELECTRON_RUN_AS_NODE. The packaged app must keep the RunAsNode fuse enabled while this fork model is used. If the app later disables that fuse for macOS hardening, the wire package exposes the Electron utilityProcessSpawner() seam." — agents/architecture/acp-runtime.md:158-163 [docs]. This is exactly the trap a Swift host does not have: Swift spawns Node directly and never fights a fuse.packages/core/src/runtimes/acp/node/worker-spec.ts:14, 38-60 — ACP_CONNECTION_IDLE_TTL_MS = 120_000, session idle SESSION_IDLE_MS = 60 * 60_000 (packages/core/src/services/session-lifecycle/api/index.ts:35), attachments under userData/acp-attachments, intents file per host [src].packages/plugins/src/agents/impl/claude/index.ts:140-157:
tsacp: {
buildSpawn: (ctx) => ({
// Run the adapter as plain Node inside the Electron binary.
command: process.execPath,
args: [resolveAdapterAsset(claudeAdapter)],
env: {
ELECTRON_RUN_AS_NODE: '1',
// Point the adapter's Claude Agent SDK at the host-installed claude
// binary instead of the SDK's auto-downloaded native binary.
CLAUDE_CODE_EXECUTABLE: ctx.cli,
},
}),
connect: (io, toClient) => connectStdioAcp(io, toClient),
enrich: enrichClaudeUpdate,
}
Codex is the same shape: packages/plugins/src/agents/impl/codex/index.ts:125-138 sets ELECTRON_RUN_AS_NODE: '1' and CODEX_PATH: ctx.cli.
Three non-obvious things here:
node_modules at runtime. packages/plugins/src/agents/helpers/adapter-assets.ts:21-38 looks for <moduleDir>/adapters/claude-acp.mjs then <moduleDir>/../adapters/claude-acp.mjs, and only falls back to createRequire(...).resolve(specifier). Asset definitions: impl/claude/adapter.ts:3-7 (@agentclientprotocol/claude-agent-acp/dist/index.js, format esm → .mjs) and impl/codex/adapter.ts:3-8 (@agentclientprotocol/codex-acp/dist/index.js, format cjs, external: ['@openai/codex']) [src]. The build bundles each adapter into one file; packages/plugins/src/agents/adapter-manifest.ts:1-4 is the whole manifest.@agentclientprotocol/codex-acp here is a JS/TS package, not Zed's Rust codex-acp binary. fazm shells @zed-industries/codex-acp-darwin-arm64/bin/codex-acp (extract §A.2); emdash runs dist/index.js under Node with @openai/codex kept external and located via CODEX_PATH. Two different products with the same name — pick deliberately.CLAUDE_CODE_EXECUTABLE / CODEX_PATH point at the user's installed CLI, resolved through a PATH-only host-dependency contract (agents/integrations/providers.md:62-66: "Provider plugins declare PATH-only definitions (binaryNames, install guidance, and optional update argv). Runtimes ... must not infer package managers, fetch latest versions, or keep a second executable cache.") [docs]. Emdash also ships the darwin/linux/win native SDK packages in ignoredOptionalDependencies — package.json:44-58 lists all of @anthropic-ai/claude-agent-sdk-* and @openai/codex-* as ignored [src]. That is a deliberate "never pull the vendored binary" posture.Actual spawn: packages/core/src/runtimes/acp/node/node/child-process-host.ts:126-154
tsconst child = spawn(launch.executable, launch.args, {
cwd: plan.cwd,
detached: platform !== 'win32', // process-group leader on POSIX
env: spec.env, // NOT {...process.env, ...} — see B.3
stdio: ['pipe','pipe','pipe'],
windowsVerbatimArguments: launch.windowsVerbatimArguments,
});
spawnTerminal (:156-184) is identical except stdio: ['ignore','pipe','pipe'].
connectStdioAcp is 7 lines — packages/plugins/src/agents/helpers/acp-stdio.ts:16-22: ndJsonStream(Writable.toWeb(io.stdin), Readable.toWeb(io.stdout)) into new ClientSideConnection(...). Nobody hand-rolls JSON-RPC framing.
process.env#packages/core/src/primitives/agent-env/api/index.ts is the single most portable artifact in the repo for a Swift host.
AGENT_ENV_VARS (:1-121) is a 121-name explicit allowlist: ANTHROPIC_API_KEY/AUTH_TOKEN/BASE_URL/MODEL/DEFAULT_{HAIKU,OPUS,SONNET}_MODEL, CLAUDE_CONFIG_DIR, CLAUDE_CODE_USE_{BEDROCK,VERTEX}, CLAUDE_CODE_SUBAGENT_MODEL, CLAUDE_CODE_DISABLE_BACKGROUND_TASKS, CODEX_HOME, OPENAI_{API_KEY,BASE_URL,MODEL,ORGANIZATION,PROJECT}, GEMINI_API_KEY, GOOGLE_{API_KEY,APPLICATION_CREDENTIALS,CLOUD_LOCATION,CLOUD_PROJECT,GENAI_API_VERSION,VERTEX_BASE_URL}, AWS/Azure keys, HTTP_PROXY/HTTPS_PROXY/NO_PROXY/ALL_PROXY, GH_TOKEN, XDG_CONFIG_HOME, plus per-provider homes.buildAllowlistedAgentEnv (:175-206) constructs the child env from scratch: TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: 'emdash', HOME, USER, PATH, then the three allowlists; TMPDIR and SSH_AUTH_SOCK are conditionally added; SHELL only when includeShellVar [src].:219-255) — a real bug class if a Swift host ever ports to Windows, ignorable for SnappyOS.mergeAgentEnvLayers (:257-269) is how per-call overrides layer on top (used by the terminal port at agent-ports/terminal-port.ts:27-30).Where PATH comes from: a login-shell probe, not the app's inherited env. packages/core/src/services/shell-env/node/capture.ts:42-78 runs spawnSync(shell, ['-ilc', 'env'], { timeout: 5000, maxBuffer: 1MB, detached: true, stdio:['ignore','pipe','pipe'] }) with a SHELL_ENV_CAPTURE_GUARD env marker so the probe can't recurse, then parses KEY=VALUE lines with /^[A-Za-z_]\w*$/ key validation. Shell candidates: $SHELL → os.userInfo().shell → /bin/bash → /bin/sh, first that existsSync (:87-96). Note -ilc — interactive login, so ~/.zshrc runs too. (fazm deliberately does the opposite: no login shell anywhere in its Swift tree, extract §A.11.)
initialize and connection pooling#packages/core/src/runtimes/acp/node/connection/acp-agent-connection.ts:157-166:
tsagent.initialize({
protocolVersion: 1,
clientInfo: { name: 'emdash', version: '1' },
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
terminal: typeof host.spawnTerminal === 'function',
},
});
Emdash declares both fs methods and terminals — the opposite of fazm, which declines fs (readTextFile:false, writeTextFile:false, extract §A.2).
Connection setup, in order (:59-136):
host.spawn(...); failure → acpErr.spawnFailed.logger.debug sink (:77-86) — kept, never scraped for meaning.handle.kill('SIGTERM') is registered on the scope before anything else can fail (:88-94).onceProcessClosed(handle) promise is armed (:138-155).behavior.connect(...) builds the ACP client.Promise.race([initializeAgent(...), processClosed.then(failClosedBeforeReady)]) (:114-117) — a die-before-initialize is turned into ACP agent process exited before initialize completed (code N) rather than an infinite hang (:168-173). Port this race verbatim.supportsLoadSession = agentCapabilities?.loadSession === true and mcpCapabilities = { http, sse } (:118-122).Pooling: one agent process per (providerId, cwd), hosting many ACP sessions. connection/source.ts:75-81 — makeAcpConnectionKey = `${providerId}:${nativePathIdentityKey(cwd)}`; createResourceCache with idleTtlMs (:56-73), generation counter ++nextGeneration per provision (:64). Route ownership is `${key}:${generation}` (runtime/session-router.ts:144-146) so a replacement process can never be mistaken for its predecessor. Comment in the architecture doc: "Cache identity includes provider, workspace, and cwd; the process route id stays provider/workspace and can host multiple ACP sessions" (agents/architecture/acp-runtime.md:38-40) [docs].
This is a four-stage pure pipeline, and the cleanest ACP-to-UI mapping available anywhere.
Stage 1: decode (stateless). packages/core/src/runtimes/acp/api/reducer/decode.ts:94-211 maps the raw SessionUpdate union to an internal NormalizedEvent. Notable:
if (update.content.type !== 'text' || !update.content.text) return { kind: 'ignored' } (:97, :107, :117).messageId is preserved as null when absent — "Preserves missing message ids for the stateful reducer to segment" (:12).{type:'content', content:{type:'text'}} (ACP-wrapped) and bare {type:'text'} (:54-69), recursing through nested content (:33-46), then stripSingleCodeFence (:48-52) removes a lone ` ` ` wrapper.ToolCallContent[] as {path, oldText|null, newText} (:20-31).terminalId and inputSummary are read in both camelCase and snake_case (:71-88) — defensive against adapter drift.plan_update / plan_removed are explicitly ignored with a reason: "UNSTABLE/ID-based ACP variants gated behind PlanCapabilities — not emitted by Claude" (:207-209).Stage 2: enrich (per provider). connection/acp-agent-connection.ts:98-101 — behavior.enrich?.(decodeSessionUpdate(raw), raw). Only Claude has one. packages/plugins/src/agents/impl/claude/acp-transform.ts:
_meta.claudeCode.parentToolUseId → first-class parentToolCallId so "downstream consumers never need to know about claudeCode" (:4-16, :37-42).<local-command- or containing <command-name> becomes {kind:'ignored'} (:20, :165-167).<task-notification> XML in user messages into a subagent_update event with task-id, tool-use-id, status, output-file, summary (:169-186)._meta.claudeCode.toolResponse.{isAsync:true, status:'async_launched', agentId} and a text fallback matching Async agent launched successfully. + /^agentId:\s+(\S+)/m (:103-135). Belt and braces because the structured field is newer.toolName === 'Agent' tool calls into a dedicated subagent event kind (:48-61).Stage 3: reducer (turn boundaries + slices). reducer.ts is pure and total.
{ transcript:{committed[],active}, config, usage, title, pendingModeId, segment, agents[], plan } (:63-72).:18-23): OPEN implicit on a new user message; OPEN lazy when agent content arrives with no active turn; CLOSE explicit on turn_end/replay_end.if (!t.active) { t = openTurn(t, deps, 'agent'); } (:536-539) with initiator: 'agent' — agent-initiated background activity gets its own turn. Compare §G: claude-agent-acp #864 is exactly the failure this defends against.messageId is solved by synthesized segments, not by concatenation. SegmentState = { open, user, assistant, thinking } counters (:51-56); synthesizedMessageId → auto:<stream>:<n> (:181-184); switching stream kind closes the open segment and bumps its counter (:186-221, :254-268). So an adapter that never sends messageId still produces stable, distinct message rows.messageId across multiple thinking blocks; resolveProviderThinkingMessageId (:223-246) reopens the still-thinking segment if one exists, otherwise mints <messageId>:segment:<n>.pendingModeId (:473-489) buffers a current_mode_update that arrives before config_option_update has delivered the mode catalog — the late-arrival rescue fazm needed too (extract §A.12).seq, duplicate sibling seq, more than one open thinking row, committed turns out of order (:379-437). Skipped when NODE_ENV === 'production' (:422). This is how you find fold bugs; port the assertions.Stage 4: item-fold (the merge rules). reducer/item-fold.ts.
pending|in_progress → 'running', completed → 'done', failed → 'error', anything else → undefined meaning "leave unchanged" (:44-56). The undefined case is the merge rule: a tool_call_update with no status must not clobber the existing one (:219-225).:66-96): subagent = subagent|task|agent; search = search|grep; read = read|read_file or a title starting "Read "; edit = edit|write|apply_patch; execute = execute|terminal|bash; mcp = mcp-tool|mcp_tool; fetch = web-fetch|web_fetch|fetch. Unmatched → unknown-tool-call carrying the raw toolKind (:209).tool_call/tool_call_update carrying diffs never creates a generic tool item — it upserts one create-file-tool-call (when oldText === null) or modify-file-tool-call per changed path, id `${toolId}:${path}` (makeDiffId, ids.ts:73-75; fold :365-435, :667-682, :706-717). An edit-kind tool call with no diffs yet is dropped outright (:684) so a bare "Edit" row never flashes before its diff arrives.tool_call_update for a tool that already produced file ops updates only those file-op statuses and adds nothing (:731-732, updateFileOperationStatuses :438-455).tool_call_update for an unknown toolCallId synthesizes the row (:736-749, title fallback 'unknown') — updates are never dropped for arriving first.durationMs (finalizeOpenThinking :349-363, called at :315, :664, :670, :705, :760, :765).read-tool-call siblings collapse into {kind:'tool-group', label:'N file reads', groupKind:'read-batch'} whose status is running if any child runs, else error if any errored, else done (wrapReadGroups :516-549, readGroupStatus :510-514).flattenItems strips children and sorts by seq (:113-129), buildTree re-parents via parentToolCallId and re-wraps read groups at every level (:551-592), normalizeToolStructure = flatten ∘ buildTree (:594-596). Expensive but always correct, and it makes late-arriving parents work.${conversationId}:turn:${i} / ${turnId}:message:${messageId} / ${turnId}:thinking:${messageId} / ${turnId}:tool:${toolCallId} / ${toolId}:${path} / ${turnId}:plan (ids.ts:18-82). The thinking id carries a kind prefix because "Claude reuses the same messageId across both update kinds" (ids.ts:36-38).finalizeItems on turn commit settles everything: thinking → done + duration, every running tool → done, groups recomputed (:785-824).Where session-level slices go instead of the transcript (reducer.ts:469-519): config_option_update → deriveConfigGroups; current_mode_update → mode selection; available_commands_update → availableCommands; usage_update → {contextUsed, contextSize, cost}; session_info_update → title. config-derive.ts:57-104 maps ACP SessionConfigOption.category → typed groups: 'model', 'thought_level' → efforts, 'mode' → permission mode, 'collaboration_mode' → a separate Default/Plan selector; unknown categories (e.g. Claude's model_config fast-mode toggle) are silently ignored as an extension point (:102). Providers doc confirms the split: "Codex ACP exposes collaboration mode separately from permission mode ... filesystem and approval controls remain in the existing permission-mode selector" (agents/integrations/providers.md:76-79) [docs].
Model: packages/core/src/runtimes/acp/api/models/turns/tool-calls.ts:5-92 — 11 typed tool rows plus tool-group, all extending {id, seq, toolCallId, title, status, inputSummary?, parentToolCallId?, children?}. Each typed row carries exactly the fields its renderer needs (execute → command/outputText/terminalId; modify-file → oldText/newText/path; mcp → server/tool; spawn-subagent → name/background/agentId).
Renderers, one directory per row kind: packages/chat-ui/src/components/rows/tools/{tool,execute,diff,file-op,subagent,tool-group}.
tools/tool/tool.def.tsx:10-46 maps a ToolNode to a one-line chip: display name by kind (Search/MCP/Fetch/Subagent/raw name/group label), and an inputSummary that is kind-specific — search shows "<query> (N matches)", MCP shows "<server>.<tool>", fetch shows pageTitle ?? url, subagent shows "<name> (background)".awaitingPermission: ctx.pendingToolCallIds().has(toolCallId) (tool.def.tsx:43, execute.presenter.ts:34). The pending-permission state is rendered on the tool row itself, not only in a modal.execute.presenter.ts:22-37 prefers live terminal output (ctx.terminalOutput(item.terminalId)) over the static outputText, and memoizes the line split in a WeakMap keyed by node identity so streaming re-renders stay cheap (:11-20).tool.def.tsx:48-63, measure() → vars.rowH) — expansion is a height tween, not a reflow.IconTerminal, IconPlanList, IconShieldAlert, IconError, IconStop, PlanPending/InProgress/Completed (packages/chat-ui/src/components/primitives/icons/).This is the sharpest contrast with fazm.
agent-ports/agent-client.ts:57-59 → router.onPermissionRequest(connection, params) → SessionRouter resolves the conversation (session-router.ts:58-68) → SessionManager.handlePermissionRequest (session-manager.ts:~613-625) → record.cell.requestPermission(params).SessionCell.requestPermission (session/cell.ts:305-323) mints its own requestId = crypto.randomUUID(), snapshots the typed tool call that triggered it, records a permission_request raw-log entry, dispatches PermissionRequested into the state machine, and returns this.permissions.request(request) — a promise that is only settled by a user action.buildPermissionToolCall (cell.ts:669-690) is the nice bit: it looks up the already-rendered tool row for that toolCallId in the active turn (recursing into children and tool-groups, findToolCall :697-713) and returns a structuredClone of it. If not found, it synthesizes a row via the same createToolCallItem factory the fold uses. The permission prompt and the transcript row are the same object shape — one renderer, no drift.PermissionBroker (session/permission-broker.ts:6-36) is 30 lines: a Map<requestId, resolve>; settle → {outcome:{outcome:'selected', optionId}}; cancel → {outcome:{outcome:'cancelled'}}; drain(pending) cancels all.APPROVAL_TIMEOUT_MS. A request sits pending until the user answers or the session tears down. SessionCell.dispose() (:405-409) calls permissions.drain(machine.pendingPermissions), and the router answers {outcome:'cancelled'} for any request whose conversation can't be resolved (session-router.ts:66).models/session.ts:40 — pendingPermissions: AcpPermissionRequest[] lives on the published sessionState LiveModel, so every surface (composer band, sidebar badge pendingPermissionCount at models/session.ts:70, tool row awaitingPermission) reads one source.resolvePermission validates first (cell.ts:286-303): unknown requestId → invalidState error; machine dispatch; raw-log permission_resolved; then broker settle. decide() in the machine likewise rejects unknown ids (machine/machine.ts:161-165).packages/ui/src/react/components/chat-composer/permission-band.tsx — a band docked flush above the composer, not a dialog. Tone from PermissionOption.kind prefix: allow_* → accept, reject_* → reject, else neutral (:48-52). Default selection: allow_once → any allow_* → first option (:54-60). It renders a SplitButton (primary action + menu) so one click is the common path, and shows "(1 of N)" when more are queued (:95-99). Selection resets on requestId change only, deliberately not on options-array identity (:83-86).allow_always is just another option forwarded to the agent; emdash never remembers a decision itself. Grep for autoApprove across the repo returns only the PTY/TUI launch path — packages/core/src/services/agent-plugins/api/plugins/helpers/standard-command.ts:123-124 appends spec.autoApproveFlag to argv, which is --dangerously-skip-permissions for Claude (impl/claude/index.ts:164) and -c approval_policy="never" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust for Codex (impl/codex/index.ts:152-153). In ACP mode there is no bypass; risk appetite is expressed only by choosing an ACP session mode (the 'mode' config category, §B.5). A user asking for mid-session bypass is an open feature request — [issue] emdash #1671.impl/claude/trust.ts:7-36 writes projects[<workspacePath>] = { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true } into .claude.json, and skips the write when both flags are already true. Copilot and Cursor have equivalents (impl/{copilot,cursor}/trust.ts). Without this the agent blocks on its own trust dialog, invisible to ACP.Declared in initialize (acp-agent-connection.ts:163) and implemented in agent-ports/:
TerminalPort (terminal-port.ts:22-71) implements createTerminal/terminalOutput/waitForTerminalExit/killTerminal/releaseTerminal. createTerminal merges the ACP-supplied env pairs over the allowlisted platform env (:27-30) and defaults cwd to the session cwd (:35). Unknown terminalId throws AcpRuntime: terminal not found: <id> rather than returning empty (:43, :56, :63).ManagedAgentTerminal (managed-terminal.ts:15-121): 4 MB default output cap (:6), a ring buffer that discards oldest chunks and sets truncated (:65-72), and a StringDecoder('utf8') so "multibyte UTF-8 sequences are never split across chunk boundaries" (:8-16, :59). waitForExit resolves immediately if already exited, else queues a waiter (:102-105).snapshot() deliberately returns metadata only — "no output text (see terminalStateSchema)" (:78-88) — while outputSnapshot() joins the ring only for the agent-facing terminal/output call (:90-100).TerminalLiveRegistry (runtime/terminal-live-registry.ts:10-52) keeps one LiveLogSource per terminal and republishes the conversation only on lifecycle transitions — "create, exit, release, and the first truncation — never per output chunk" (:21). The contract exposes it as terminalOutput: liveLog({ key: { terminalId } }) (api/contract.ts:170).spawn with stdio:['ignore','pipe','pipe'] and detached (child-process-host.ts:156-184). Emdash does have node-pty, but only for its separate TUI-agent runtime, not for ACP terminals.execute-tool-call.terminalId (tool-calls.ts:20), read back live in execute.presenter.ts:23.agent-ports/fs-port.ts:12-24 + fs-text.ts:5-19. Two rules worth stealing: read errors are re-thrown wrapped with the path (readTextFile failed for <path>: <msg>), and write creates parent directories (mkdir(dirname(path), {recursive:true})) before writing. No sandbox check, no path allowlist — the agent's cwd is trusted.
Materialization (runtime/session-materializer.ts:55-201) is the resume story:
(providerId, cwd, env) (:65-77); register a scope teardown that releases the lease (:78-81).:87-88).input.sessionId and connection.supportsLoadSession and agent.loadSession exists → serialize the handshake per process (acquireHandshake :203-228, a promise-chain mutex keyed by processOwner), register a provisional route (beginLoad), cell.beginReplay(), loadSession({cwd, sessionId, mcpServers}), then applySessionLoaded(modes, configOptions), apply desired config, queue initial prompts, cell.endReplay(), resumeOutcome = 'loaded' (:94-133).loadSession failure that is not auth-required: log and fall through to newSession — "SessionMaterializer: loadSession failed, starting a new session" (:139-141), discarding the provisional record (:147-150). resumeOutcome then reports 'replaced-by-new' (:91). Compare emdash [issue] #1229 ("dropped back to a bare shell") — that was the PTY path; the ACP path never does that.cause: code === -32000 (isAuthRequiredError :423-428).abortable(promise, signal) (:430-437) and re-checks callbacks.isCurrent(entry, epoch) after every await (:82-84, :121-123, :161-169) so a session killed mid-handshake can't resurrect.applyDesiredConfiguration (:341-355) loops until entry.desiredRevision stops changing — the user can change model while the session is materializing and the last write wins.applyConfigOverrides :305-339, applyInitialMode :377-405 ("persisted mode not advertised, skipping").Routing (runtime/session-router.ts): Map<processOwner, Map<acpSessionId, conversationId>> (:33) plus a single loadingConversationByOwner slot (:34). resolveConversationForSession (:134-141) falls back to the pending-load conversation and registers the id it just saw — this is how a provider that rebinds the session id during session/load still routes. beginLoad throws if a load is already active for that process (:103-105) — hence the handshake mutex above. invalidate(processOwner) on process close drops every route for that generation (:129-132).
Suspend / rematerialize (agents/architecture/acp-runtime.md:121-147) [docs]: a conversation keeps a wake descriptor and a "retained presentation" after its live SessionCell is evicted; suspended projections "keep controls visible and prompt submission enabled while clearing activation-local queues, permissions, terminals, active turns, plans, and agents." On worker boot "every valid persisted intent is restored only as a lightweight suspended index row; the worker never starts a provider from disk." Only loadHistory, sendPrompt and headless launch wake one; mode/model changes persist without waking.
What is persisted — runtime/session-intent-schemas.ts:24-32, versioned '1': {conversationId, providerId, cwd, sessionId|null, configured:{model,modeId,effort,collaborationMode}, presentation:{lastKnownCapabilities, lastKnownMcpServers, lastKnownUsage, observedAt}}. The doc states the exclusion explicitly: "Provider environment, MCP credentials, runtime endpoints, and unknown descriptor fields are never persisted" (acp-runtime.md:113-115). Legacy blobs are migrated through a restricted schema (:34-42).
Idle policy: sessions idle out after 60 min without output (services/session-lifecycle/api/index.ts:35, applied worker-spec.ts:55), swept every 60 s (session-lifecycle/node/session-lifecycle.ts:111); idle connections are reclaimed after 2 min (worker-spec.ts:14).
History: loadHistory is a paged read that "returns a successful page marked unavailable: true" when the provider can't replay, so "callers retain their existing transcript instead of replacing it with an empty one" (acp-runtime.md:143-146) [docs].
Three distinct verbs, all present:
cell.cancel() (cell.ts:268-279): machine dispatch first (rejects when nothing is cancellable, machine.ts:137-141), then agent.cancel({sessionId}). Nothing is force-killed.cell.closeSession() (:281-284), guarded by if (!this.deps.agent.closeSession) return since it is optional in the SDK.SessionManager.interruptRecord (session-manager.ts:~869-901) fires cancel() and closeSession() concurrently, each with its own .catch that only warns.onProcessClosed(processKey, generation, exitCode) (session-manager.ts:~634-657): invalidate the router generation, then for every record on that exact (key, generation): set connectionLeaseState.release = false (don't double-release a dead lease), cell.processClosed(exitCode), stop(conversationId, 'process-exited'), and invalidate the connection cache entry.ProcessTreeTerminator (primitives/exec/node/process-tree-terminator.ts:34-142). POSIX: process.kill(-pid, SIGTERM) plus child.kill(SIGTERM), wait up to graceMs = 1000 polling every 20 ms with process.kill(-pid, 0) for group liveness, then SIGKILL and wait again (:64-77, :106-141). Idempotent — terminate() memoizes its own promise (:53-62). Windows uses taskkill /PID n /T [/F] with an argv array "so no user-controlled text is interpreted by a shell" (:27-33, :93-104), and re-checks the original child before re-targeting the numeric PID "to avoid targeting a reused PID" (:85-88).sweepOrphanedBridges(). It relies on detached:true + group kill + the Electron parent outliving children. [issue] emdash #2153 is the receipt for that gap: 57 direct children, ~240 descendants, 21.1 GB RSS, "43 gemini launcher processes, 6 codex launcher processes" — though that report is against the PTY/TUI path, not ACP. [issue] #2580 is the SSH analogue (12 orphaned claude processes on a remote host). And on the adapter side, [issue] claude-agent-acp #1011 documents the agent itself leaking claude children across repeated session/load on one long-lived process.docs/adr/0004-cancellation-is-best-effort-across-planes.md) [docs] — the same conclusion fazm reached empirically.packages/core/src/runtimes/acp/node/machine/machine.ts — a decide/evolve (command → events → state+effects) machine, pure and unit-tested.
starting | replaying | ready | working | cancelling | closed (:15-21), documented one-line each at api/models/session.ts:14-31.:73-82): Prompt, QueuePrompt, Cancel, EditQueuedPrompt, RemoveQueuedPrompt, ReorderQueue, ResolvePermission, SetMode, SetConfigOption.:104-112): state, permissionRequest, permissionResolved, closed, agentEvent, settleAgents, sendPrompt, warn.:120-132): if phase is working/cancelling, or agentTurnActive, or backgroundAgentCount > 0 → PromptQueued. Queued prompts are first-class API objects (edit, delete, reorder — api/contract.ts:94-105).SetMode/SetConfigOption are validated against the provider's advertised catalogs before any RPC (:167-183, context built at cell.ts:609-622).isGenerating, canSubmit, canCancel (api/models/session.ts:51-56).phase === 'ready' flip AgentActivity{active:true} and arm a 250 ms debounce; on expiry the turn is settled with reason 'quiesced' (cell.ts:592-607, 652-658). That is emdash's answer to "updates keep arriving after session/prompt resolved" ([issue] claude-agent-acp #864).cell.ts:194-200, 660-667).stopReason: done{end_turn|max_tokens|max_turn_requests|refusal|quiesced} | cancelled | error{prompt_failed|process_closed|spawn_failed|initialize_failed|new_session_failed|load_session_failed|cancel_failed|set_config_failed|set_mode_failed} | interrupted{process_closed|replaced} (api/models/turns/turn.ts:17-49).cell.ts:502-532. The prompt array is assembled as: images first, then text, then hidden context:
tsprompt: [
...resolvedAttachments.map(a => ({ type:'image', data: a.data, mimeType: a.mimeType })),
...(prompt.text ? [{ type:'text', text: prompt.text }] : []),
...(prompt.hiddenContext ? [{ type:'text', text: prompt.hiddenContext }] : []),
]
Flat {type:'image', data, mimeType} — same shape fazm had to discover the hard way (extract §A.4). Attachments are refs on the wire and only resolved to bytes at send time (deps.resolveAttachment, :503-507); the user echo is pushed into the transcript before the RPC with messageId = `${conversationId}-${turnIndex}-user` (:484-499). hiddenContext is a first-class second text block — that is where a host injects context the user shouldn't see echoed.
runtime/mcp-servers.ts:37-71. Shapes emdash sends in session/new.mcpServers:
{name, command, args, env: [{name,value}]} — env is an array of pairs, not an object (:65-70, recordToPairs :85-87).{type:'http'|'sse', name, url, headers:[{name,value}]} (:44-62).!capabilities.http, sse when !capabilities.sse (:45, :55), where those come from initialize's agentCapabilities.mcpCapabilities (acp-agent-connection.ts:119-122).enabled === false registrations are skipped (:41); transport is inferred when unspecified — a registration with a url and no command is http (resolveTransport :73-83).[] (session-materializer.ts:357-375).{name, transport} is published to the UI (summarizeAcpMcpServers :28-35; contract session.mcpServers live state at api/contract.ts:167).passthroughMcpAdapter('.claude.json') for Claude (impl/claude/index.ts:172), codexMcpAdapter() for Codex (impl/codex/index.ts:164), both declared scope:'global', transports ['stdio','http'] (index.ts:121-125 / :109-113).agents/integrations/providers.md:10-16) [docs]; registry at packages/plugins/src/agents/registry.ts:44-85. Adding one is a documented 5-step recipe (providers.md:91-99), step 2 of which is "update allowlisted agent env vars".impl/gemini. Emdash ships antigravity, jules, qwen but not Gemini CLI — for a host targeting gemini --acp, emdash offers no precedent and fazm's recipe (extract §A.2: GEMINI_CLI_TRUST_WORKSPACE=true, mandatory authenticate) remains the only citation.ANTHROPIC_API_KEY present → authenticated; else claude auth status with a 5 s timeout, parse JSON stdout for email|account|accountEmail|oauthAccount.emailAddress, exit-code-1 + a logged-out pattern → unauthenticated, anything else → unknown (impl/claude/auth.ts:17-33, 41-56; regex :8). Codex: OPENAI_API_KEY or codex login status matched against /authenticated|logged in|signed in/i vs /not authenticated|.../i (impl/codex/index.ts:140-147, helper helpers/auth.ts:15-46).{kind:'cli-login', args:['auth','login']} + {kind:'api-key', envVars:[ANTHROPIC_API_KEY]} (impl/claude/index.ts:33-52); Codex {kind:'cli-login', args:['login','--device-auth']} (impl/codex/index.ts:37-42). Emdash never runs its own OAuth PKCE flow and never touches the Keychain — the opposite of fazm (extract §A.9). For a Swift host this is the lower-risk posture.usage_update gives contextUsed/contextSize/cost (decode.ts:186-199) and that is all the UI shows. Codex's RateLimitSnapshot never crosses ACP at all — [issue] codex-acp #227.UserPromptSubmit/Notification/Stop hooks written into settings.json under $CLAUDE_CONFIG_DIR (impl/claude/hooks.ts:39-48), and because Claude's Notification payload has no type field, emdash classifies by regex: /permission|approval/i → permission_prompt, else idle_prompt (:12-36). The providers doc states the principle: "Emdash does not infer agent status from terminal output" (providers.md:27-30) [docs]. Users pushed back on the file writes — [issue] #1944.session/raw-log.ts — worth copying wholesale.
:14-50): session_update{sessionId, update}, prompt{sessionId, content}, prompt_result{sessionId, stopReason}, permission_request{sessionId, request}, permission_resolved{sessionId, requestId, optionId}.{seq, ts, event} (:52-56).:58-61). Per-entry byte cost is measured once at record time and tracked incrementally (:92-104); eviction shifts from the head while over either cap but always keeps ≥1 entry (:106-114).{meta:{conversationId, providerId, acpSessionId, createdAt, generatedAt}, events[]} (:116-128), exposed as exportRawAcpLog on the contract (api/contract.ts:123-127) alongside exportAcpTranscript (:118-122).acp-runtime.md:117-119) [docs] — and the log is deliberately "fixture-compatible", recorded before normalization (raw-log.ts:68-71); the repo ships captured transcripts as fixtures (impl/{claude,codex}/fixtures/acp-transcript.json + __snapshots__/acp-fixture.test.ts.snap, driver acp-fixture-driver.ts). Record real traffic once, snapshot the fold forever.packages/core/src/runtimes/acp/api/contract.ts:70-171. Commands: attach, launch, terminate, sendPrompt, editQueuedPrompt, deleteQueuedPrompt, changeQueuePromptOrder, cancelTurn, setOption, resolvePermission, exportAcpTranscript, exportRawAcpLog, uploadAttachment, downloadAttachment, deleteAttachment, purgeConversationData, loadHistory. Live models: sessions.list (map of SessionSummary), and per-conversation session.{state, config, usage, plan, agents, activeTurn, terminals, mcpServers} plus terminalOutput as a keyed log.
Two design rules stated in the architecture doc and visible in the contract: "The public API describes user intent instead of exposing lifecycle choreography ... there is no public ensureActivation, start, or resume procedure" (acp-runtime.md:104-111), and "The public identity is always conversationId; provider process activations are internal" (:123) [docs]. Both are directly applicable to a Swift host's actor API.
No CHANGELOG file exists in-repo (find . -iname CHANGELOG* → none outside node_modules); the durable design record is docs/adr/0001..0007 + agents/architecture/*.md. Issue lessons:
| # | State | Lesson for a Swift host |
|---|---|---|
| #210 | CLOSED | "[feat]: Integrate Agent Client Protocol (ACP)" — the tracking issue. Everything in §B postdates it; ACP was retrofitted onto a PTY-first product, which is why the two paths (auto-approve flags vs permission band) still differ. |
| #2153 | CLOSED | Orphan/memory blowup: 57 direct children, ~240 descendants, 21.1 GB RSS, 43 stray gemini launchers. Group-kill alone is not enough at scale — budget for a startup orphan sweep. |
| #2580 | CLOSED | Detached sessions were never reaped on conversation close → 12 live claude processes on a remote host after days. "Preserve session on close" and "reap process on close" are different requirements; decide both explicitly. |
| #1716 | CLOSED | Error: Session ID <uuid> is already in use on resume after restart. Reusing a deterministic session id without proving the previous holder is dead is a live bug class. |
| #1229 | CLOSED | No conversation found with session ID → user dropped to a bare shell. Fix shape = detect resume failure, fall back to a fresh session, tell the user. Emdash's ACP materializer implements exactly that (session-materializer.ts:139-150). |
| #3093 | CLOSED | Restored tabs render stale content but silently discard input. "Selecting a restored tab should rehydrate/resume; if recovery fails, the UI should expose the failure instead of presenting an apparently interactive terminal." |
| #531 | CLOSED | A spinner for a pending approval reads as "busy", not "blocked on you". Ship a distinct pending-permission affordance + count badge. Emdash now does: awaitingPermission per row + pendingPermissionCount per session. |
| #1671 | OPEN | Users want to escalate to skip-permissions mid-session. Under ACP that is session/set_mode to a bypass mode — plan the control, don't require a restart. |
| #1944 | CLOSED | Writing hook configs into user-owned files (.claude/settings.local.json, .codex/config.toml) with no opt-out is resented. If you write trust or hook config, make it visible and switchable. |
| #1703 | CLOSED | Windows: spawning the extensionless npm shim → error 193. Detection succeeded via cmd.exe while spawn failed — detecting a binary is not the same as being able to exec it. |
| #3070 | OPEN | 1.2.x main process burns ~0.8 core at idle in libuv stream reads. High-rate stdio between host and agents has a real idle cost; batch/coalesce. |
| #2985 | OPEN | Claude Code TUI renders corrupted inside emdash. A reason to prefer ACP over hosting a TUI in a terminal emulator. |
| #1678 | OPEN | "more than one agent config per provider" — the (providerId, cwd) connection key is already a de-facto constraint; multiple configs per provider needs a richer key. |
diodeme/Gold-Band v0.14.1, AGPL-3.0. Read at 9f9247c. All paths below are relative to the repo root.
gold-band (all logic, Cargo.toml:1-9) and src-tauri/gold-band-desktop (shell). Rust edition 2024.agent-client-protocol-schema = "1.6.0" with feature unstable_elicitation — Cargo.toml:12. Note: the schema crate only. Gold Band hand-rolls the JSON-RPC transport rather than using the agent-client-protocol client runtime (contrast Jockey, which uses the full crate).rusqlite (bundled) for search indexing; minijinja for prompt templates; cron for scheduling; command-group 5.0.1 for process groups — Cargo.toml:19, src-tauri/Cargo.toml:12-52.src/acp/ — client.rs alone is 11 845 lines, events.rs 6 349, timeline.rs 3 991, connection.rs 3 205, branches.rs 2 824. It is by far the largest ACP client implementation in this survey.web/ with a dedicated web/src/components/acp/ and a themable design system (theme-sdk/, themes/, resources/themes/) — the theming layer is unique to Gold Band among these four.README.md:31-58): "connects to local Agents such as Claude Code and Codex through Agent Client Protocol (ACP)", with three execution modes — Direct Agent, WORKFLOW (fixed graph), and AUTO / AI-DYNAMIC (LLM-decomposed) — plus "Runtime observability: inspect Agent messages, tool calls, system prompts, raw frames, tokens, duration, and runtime state."include_str!("../resources/agent-catalog.json") parsed once into a OnceLock (src/agent_catalog.rs:48-54), schema-version-checked at :63-70. Its source block records provenance: {"url":"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json","registryVersion":"1.0.0","fetchedAt":"2026-09-01T03:41:38.550Z"} (resources/agent-catalog.json:1-9). A second raw copy lives at resources/acp-registry.snapshot.json.src/agent_catalog.rs:22-46): {id, label, version, description, repository, website, iconKey, command, args[], env{}, primaryAgentDir, projectPrimaryAgentDir, compatibleAgentDirs[], supportsSystemPrompt, supportsExternalSessionSync}. The last four fields are Gold Band's own additions on top of the registry.resources/agent-catalog.json):| id | command + args | agent dir | systemPrompt |
|---|---|---|---|
claude-acp |
npx -y @agentclientprotocol/claude-agent-acp@0.70.0 |
.claude |
yes |
codex-acp |
npx -y @agentclientprotocol/codex-acp@1.7.0 |
.codex |
no |
gemini |
npx -y @google/gemini-cli@0.57.0 **--acp** |
.gemini |
no |
cursor |
cursor-agent acp |
.cursor |
no |
codebuddy-code |
npx -y @tencent-ai/codebuddy-code@2.143.0 --acp |
.codebuddy |
no |
qwen-code |
npx -y @qwen-code/qwen-code@0.22.3 --acp --experimental-skills |
.qwen |
no |
goose |
goose acp |
.goose |
no |
opencode |
opencode acp |
.opencode |
no |
kimi |
kimi acp |
.kimi-code |
no |
amp-acp |
amp-acp |
.agents |
no |
pi-acp |
npx -y pi-acp@0.0.33 |
.pi/agent |
no |
Note the Gemini flag: --acp, not --experimental-acp. fazm and Jockey both still pass --experimental-acp (extract §A.2; jockey src-tauri/src/acp/adapter.rs:119-127). Gold Band's 2026-09-01 registry snapshot says --acp at @google/gemini-cli@0.57.0. Verify the flag against the installed version before shipping.
Also note supportsSystemPrompt is true only for Claude — matching fazm's finding that Codex/Gemini need the system prompt prepended as a text block instead (extract §A.4).
src/acp/adapter.rs:38-78:resolve_adapter requires a non-empty command (:25-29); normalize_args splits every arg on whitespace, so args: ["--acp --yolo"] becomes two argv entries (:80-84).npx → npx.cmd (:86-93) — the exact class of bug emdash hit as [issue] #1703.resolved_adapter_env :100-108), then each pair is set on the command (:56-58); stdio all piped; current_dir(cwd).CLAUDE_CODE_EXECUTABLE is set to a PATH-resolved local claude unless the config already supplies it (:59-62, local_claude_executable_for_env :140-148). An env/flag GOLD_BAND_REQUIRE_LOCAL_CLAUDE=1 turns a failed resolution into a hard error instead of letting the adapter download its own (:63-72, :150-168). Same intent as emdash's CLAUDE_CODE_EXECUTABLE, with an explicit strict mode.claude.cmd shims are parsed to find the real .exe: read the .cmd, scan lines in reverse for %dp0%/%~dp0 + .exe, resolve relative to the cmd dir (:185-256). This is the deepest treatment of the npm-shim problem in any of these repos.src/process.rs:49-98, doc comment at :43-48: priority is explicit adapter PATH → cached login-shell PATH → current process PATH → platform paths, deduped, then appended with ~/.local/bin, ~/.cargo/bin, ~/.volta/bin, every ~/.nvm/versions/node/*/bin, /opt/homebrew/{bin,sbin}, /usr/local/{bin,sbin} (:200-240, nvm enumeration at :250-262). The login-shell PATH is captured once into a OnceLock (:40-41). Windows re-reads user+machine PATH from the registry on every launch.ManagedProcessGroup (src/process.rs:452-591): command.group().spawn() on Unix (Job Object + kill_on_drop + CREATE_NO_WINDOW on Windows), and every live group id is registered in a global LIVE_PROCESS_GROUPS set (:466-469).try_wait returns None while process_group_is_alive(pgid) — i.e. the adapter is not "exited" until its whole descendant group is gone (:505-517, liveness via libc::kill(-pgid, 0) at :592-601).terminate(grace) = SIGTERM to the group → wait → force_kill (:549-557).Drop kills and reaps (:580-590).recover_persisted_process_group(pid) (:603-628) kills a pgid persisted before an app crash — kill -TERM -<pgid> on Unix, taskkill /PID n /T /F on Windows — and the pid is stored per agent at doctor_acp_provider_pid_file(agent_id) (src/storage/mod.rs:338). force_terminate_all_managed_process_groups() (:630-637) is the "emergency fallback used only after the bounded application cleanup expires". This is the orphan-sweep design emdash lacks and fazm bolted on with ps scraping.initialize — Gold Band declines fs and terminals#src/acp/client.rs:466-483:
rustjson!({
"protocolVersion": 1,
"clientCapabilities": {
"_meta": { (NESTED_AGENT_TRANSCRIPT_CAPABILITY): true },
"elicitation": { "form": {} }
},
"clientInfo": { "name":"gold-band", "title":"Gold Band", "version": VERSION }
})
No fs, no terminal. The agent does its own file IO and runs its own commands; Gold Band only observes. It does advertise ACP elicitation (elicitation.form) — src/acp/elicitation.rs is 768 lines — and a private _meta capability for nested agent transcripts (sub-agent output as a first-class stream). Compare: emdash declares fs+terminal, fazm declares neither, Jockey declares fs+terminal, acp-inspector declares fs.
Connection initialization is a shared-once latch with poisoning semantics: initialize_once (src/acp/connection.rs:975-1030) so N concurrent callers share one initialize, a failed or panicking initialize poisons that connection permanently but not its replacement, and cancelled waiters don't abort the shared attempt — all four properties have named tests (connection.rs:2393-2545).
session/resume vs session/load, capability-gated#src/acp/client.rs:2163-2228. Gold Band is the only client here that models two distinct restore methods:
SessionRestoreIntent::{ContinueOnly, SyncHistory} (:2164-2167) — what the user wants.SessionRestoreMethod::{Resume, Load} → RPC "session/resume" / "session/load", with replays_history() == (self == Load) (:2170-2186).resume: capabilities.session_capabilities.resume.is_some(), load: capabilities.load_session (:2194-2205). So session/resume here is a real spec method gated by sessionCapabilities.resume in agent-client-protocol-schema 1.6.0 — not the non-standard Claude-adapter extension fazm used (extract §A.3).plan_session_restore returns Restore(method) | StartNew, or a typed error RestoreUnsupported / HistorySyncUnsupported with stable error codes (:2207-2228) — the UI can say why a resume is impossible instead of silently starting over.client.rs:1624, 1680-1681, 3771-3800, 4559, 5244; catalog flag supportsExternalSessionSync) is an opt-in mode where Gold Band reconciles with the CLI's own session store, gated on restore_method.replays_history(). The changelog entry is fix(acp): prefer resume for detached session restore (v0.10.0, 2026-07-31, CHANGELOG.md:284).Everything is on disk under a runtime root, one directory per task/run/attempt (src/storage/mod.rs:170-450): project.json, settings.json, state.json, task.json, run.json, workflow.snapshot.json, authoring/*, conversation-attention.json, desktop/agent-diagnostics.json, desktop/agent-command-catalogs.json, scheduled tasks and triggers as JSON.
SQLite is only a search/index layer — src/storage/sqlite.rs:240-300: tasks(task_path PK), sessions(attempt_path PK, session_id, task_id, run_id, round_id, node_id, attempt_id, outer_node_id, outer_attempt_id, title, status, …), session_prompts(attempt_path, id), plus FTS5 virtual tables with triggers — session_prompts_fts (content-backed) and tasks_fts with tokenize='trigram' (:277-299). The changelog has fix(storage): correct SQLite session identity indexing (v0.13.2, CHANGELOG.md:88).
Note sessions.session_id is nullable and the primary key is the attempt path, not the ACP session id — a session exists as a directory before the agent hands out an id.
src/acp/timeline.rs is Gold Band's answer to emdash's in-memory reducer, and it is durable.
TimelineStore (:623-640) owns a path, a TurnFileStore blob store, a materialized index, a compaction policy and a checkpoint policy.IndexHit → TailReplay → FullRebuild (:59-80). Reading a conversation normally hits the materialized index; on a mismatch it replays the tail; only as a last resort does it rebuild from the whole log.TimelineCheckpointPolicy { patch_interval, tail_replay_limit } (:85-96) and TimelineCompactionPolicy { max_size_bytes, patch_ratio } (:601-614) — the log is periodically checkpointed and compacted in place; TimelineUpsertOutcome::{Unchanged, Appended, AppendedAndCompacted} (:616-621).TIMELINE_BLOB_REF_KEY = "$goldBandBlob" (:56) replaces an inline value with a blob reference into TurnFileStore (src/acp/turn_files.rs, 1 975 lines).{branch_id, item_id, revision} (:509-514) and settlement is optimistic-concurrency-checked: TimelineSettleOutcome::{Applied, AlreadyTerminal, RevisionConflict, IdentityMissing} (:593-599). A late tool_call_update for an already-terminal item cannot resurrect it.TimelineIndexedPage (:479-499) that carries, in one object: the events, pending_permissions, pending_elicitations, available_commands, usage, timing, latest_plan, has_older/has_newer and seq bounds. Pending interactions are part of the page, so a reopened conversation immediately shows the prompt it is blocked on.TimelineBranchProjection (:516-536) precomputes tool_call_count, read_file_count, written_file_count, has_pending_interaction, has_completion_evidence, agent_launches, prompt_turns — the sidebar reads projections, never the log.src/acp/branches.rs, 2 824 lines) make edit/regenerate a first-class tree, not a truncation.session/update → UI kinds#src/acp/events.rs:3321-3336 is the whole mapping and it is deliberately lossless-by-default:
rust"agent_message_chunk" => "textDelta",
"user_message_chunk" => "userTextDelta",
"agent_thought_chunk" => "thoughtDelta",
"tool_call" => "toolCall",
"tool_call_update" => "toolCallUpdate",
"plan" => "plan",
"available_commands_update" => "availableCommands",
"usage_update" => "usageUpdate",
"current_mode_update" => "modeUpdate",
"config_option_update" => "configUpdate",
"session_info_update" => "sessionInfo",
_ => "rawDiagnostic",
The _ arm is the important one: an unknown update kind is not dropped (emdash) — it becomes a visible rawDiagnostic row. Empty chunks are filtered (CHANGELOG.md:79, fix(acp): preserve message streams and hide empty chunks). Tool-call merging is keyed off sessionUpdate ∈ {tool_call, tool_call_update} at events.rs:2941-2952; a comment at events.rs:2916 records the principle: a normalized item never claims to be the source of truth — "The original provider frame remains in acp.raw.jsonl".
src/acp/permission.rs. This is architecturally different from every other client here.
acp.permission-request.<id>.json and acp.permission-response.<id>.json (:36-48).session/request_permission handler writes the pending file (write_pending_permission :107-131, payload = the raw ACP params) and then blocks on a 200 ms poll loop for the response file, returning early if turn cancellation is requested (wait_for_permission_response_until_cancelled :221-241). The response file is deleted on read (:236).PermissionResponseState { requestId, optionId: Option<String>, cancelled: bool, decidedAt } (:28-34) → ACP outcome: cancelled → {outcome:{outcome:"cancelled"}}, else {outcome:{outcome:"selected", optionId}}; a non-cancelled response with no optionId is an error (acp_permission_response_result :245-257).cancel_pending_permission_requests(attempt_dir, decided_at) scans the directory and writes a cancelled response for every pending file (:50-105) — cancelling a turn resolves every outstanding prompt.bind_pending_permission_timeline_identity :132-140) and settled through the timeline's revision-checked settle_permission_item, with a regression test proving a stale response cannot revive a cancelled permission (:695-732).fix(acp): bound permission parameter previews, fix(acp): clamp permission previews to full lines (v0.14.0, CHANGELOG.md:36-37), fix(chat): align permission intervention cards (:164).<attempt>/acp.raw.jsonl as {timestamp, direction, frame} (src/acp/events.rs:1286-1291, path at :900 and src/storage/mod.rs:634-643).append_raw_frames(path, direction, frames[], max_size, target_size) does "one file lock, open, buffered write, flush, and roll check" (events.rs:1245-1255).max_size it trims oldest lines down to target_size, with a pinned prefix that is never trimmed (roll_raw_log, events.rs:1320-1364), and returns RawLogRollStats { before_bytes, after_bytes, elapsed } for telemetry (:1257-1268). Unicode-safe line trimming has its own test (:6296).src/acp/pipeline_diagnostics.rs (436 lines) and src/inspect/ are a built-in inspector; src/observability/ carries the logging. Changelog: fix(observability): harden ACP runtime logging (CHANGELOG.md:62).theme-sdk/, themes/, resources/themes/, web/src/themes/generated).cron dependency + src/scheduler/ + scheduled-task.json / trigger files (src/storage/mod.rs:390-402) — recurring agent runs.src/dsl/, src/dynamic.rs, src/dynamic_store.rs, src/app/orchestrator.rs) with a configs/app-config.toml and JSON-schema validation (jsonschema crate).src/acp/elicitation.rs, 768 lines) — the only client here that does.src/acp/prompt_queue.rs, 1 198 lines); changelog feat(direct): queue prompts during active sessions (v0.11.0, CHANGELOG.md:278).src/personal_analytics/) and a CLI (src/cli/, src/bin/) over the same core.src/skill/) and MCP (src/mcp/) as first-class managed config.| Version (date) | Entry | Lesson |
|---|---|---|
| 0.14.0 (2026-08-25) | fix(acp): fence stale provider lifecycle writes (CHANGELOG.md:42) |
A dead process's late writes must be fenced by generation — same conclusion as emdash's routeOwnerId(key, generation). |
| 0.14.0 | fix(acp): preserve session identity after cancel timeout (:47) |
A cancel that times out must not orphan the session id. |
| 0.14.0 | fix(acp): preserve output through cancel convergence (:46) |
Output already streamed before a cancel must survive the cancel. |
| 0.14.0 | fix(acp): converge sub-agent lifecycle state (:41), converge activity state on lifecycle stop (:39), complete lifecycle state convergence (:38) |
Sub-agent and activity state drift from session state is a recurring, multi-release bug family. |
| 0.14.0 | fix(acp): preserve initial session model override (:45) |
A model chosen at session creation gets lost — cf. [issue] claude-agent-acp #1056. |
| 0.14.0 | fix(acp): settle turn file changes by tool outcome (:48) |
File-change attribution must follow the tool's terminal status, not its start. |
| 0.14.0 | fix(acp): unify session lifecycle and timeline recovery (:49) |
Two recovery paths (process lifecycle, transcript) must converge or they disagree after a crash. |
| 0.13.x (2026-08-17) | fix(acp): decouple stop lifecycle from timeline replay (:78), bound cancellation and placeholder hydration (:77) |
Stopping must not be entangled with replaying. |
| 0.13.0 | fix(acp): preserve persisted branch routes (:154), prevent cancellation from stalling on prompts (:155), refresh active session config catalogs (:156) |
Config catalogs go stale mid-session; refresh them. |
| 0.12.x (2026-08-07) | feat(agent): add extensible ACP agent catalog (:276) |
The catalog became data, not code. |
| 0.11.0 (2026-08-05) | fix(acp): prefer resume for detached session restore (:284), preserve history boundaries across live deltas (:285) |
Prefer session/resume over session/load when you only need to continue. |
| 0.10.0 (2026-07-31) | fix(acp): make prompt cancellation durable (:303) |
Cancellation must survive a restart — hence the file-based signalling. |
| 0.14.1 (2026-08-26) | fix(acp): harden prompt turn admission (:25) |
Admission control on which prompts may start a turn. |
newioapp/acp-inspector at 57b993e. Electron 39.8.6 + electron-vite 5 + React 19 + Zustand 5 + @agentclientprotocol/sdk ^0.17.1 (package.json:52-58). ~5.1k lines; the entire ACP layer is one 570-line file. Three build targets in one config (electron.vite.config.ts:16-44); shared types live in src/shared so main and renderer agree across the IPC boundary.
It is a first-class ACP client, not a man-in-the-middle: spawn(config.command, [...config.args], { stdio:['pipe','pipe','pipe'], env: { ...config.envVars, TERM:'dumb' }, cwd }) — src/main/acp-connection-manager.ts:81-85. The class itself implements acp.Client (:41) and wires new ClientSideConnection((_agent) => this, stream) (:144).
env replaces process.env rather than merging it; the map comes from a login-shell probe (zsh -ilc / bash -ilc, 10 s timeout) — src/main/shell-env.ts:154, allowed shells at :16. Transient vars _, PWD, OLDPWD, SHLVL are stripped (:27), and USER/HOME/SHELL are recomputed from the passwd DB because a Dock-launched GUI app gets a minimal launchd env (:42-58). That last detail is directly applicable to SnappyOS.app.TERM: 'dumb' — the opposite of emdash's xterm-256color. Both are defensible; pick one deliberately.cwd is stat-validated before spawn because a bad cwd surfaces as ENOENT indistinguishable from a missing binary — :72-79, :414-427. Steal verbatim.src/renderer/src/components/ConnectionBar.tsx:66-74) — a known weakness.stdin.end(), race exit against a 3 s timer, then SIGKILL (:215-232). One connection at a time (:63-66).initialize advertises fs.readTextFile/writeTextFile + auth.terminal, the latter only so agents list terminal auth methods; it never calls authenticate (:147-160, comment :151-154).The single most portable idea in this repo: capture is done by inserting two TransformStreams into the ndjson pipeline, not by wrapping SDK method calls — src/main/acp-connection-manager.ts:118-142. tapSent/tapReceived forward every AnyMessage to onProtocolMessage before enqueueing it downstream. The log is therefore wire truth, not a re-serialization of typed objects, and it costs ~20 lines.
src/shared/types.ts, deliberately separate from the SDK re-exports at :9-42:
tsJsonRpcRequest { jsonrpc?, id?: number|string|null, method: string, params?: Record<string,unknown> } // :65-70
JsonRpcResponse { jsonrpc?, id?, result?: unknown, error?: { code:number; message:string; data?:unknown } } // :73-78
ProcessEvent { stderr?, event?, code?: number|null, signal?: string|null } // :81-86
type ProtocolMessageData = JsonRpcRequest | JsonRpcResponse | ProcessEvent // :89
ProtocolMessage {
readonly id: number; // monotonic LOCAL counter, ≠ the JSON-RPC id inside `data`
readonly timestamp: number;
readonly direction: 'sent' | 'received';
readonly sessionId?: string;
readonly data: ProtocolMessageData;
} // :92-98
ProcessEvent). That is the right call: a debug view that shows frames but not the stderr line that explains the crash is useless.isJsonRpcRequest = 'method' in data; isJsonRpcResponse = 'result' in data || 'error' in data (:101-108). In Swift → an enum with associated values, decoded by key probing.InspectorSessionUpdate { timestamp, sessionId, data: SessionNotification } (:115-119) and InspectorPermissionRequest { requestId, timestamp, sessionId, data, respondedOptionId? } (:122-128) — the response is recorded on the request, so answered/unanswered is derivable for permissions.InspectorConfigOption { id, name, description?, category?, currentValue, options[] } (:144-151) is a generic select dimension, so a new ACP config category renders with zero new code.protocolMessages: ProtocolMessage[] = [], src/main/main-state.ts:53-55, rationale :1-6) so nothing is lost while the window is closed — an unbounded growth path. The IPC snapshot caps at MAX_SNAPSHOT_ITEMS = 500 (:16, :73-74).slice(-500) on every append — MAX_PROTOCOL_MESSAGES / MAX_SESSION_UPDATES = 500 (src/renderer/src/stores/inspector-store.ts:18-19, 318-322). That is an O(n) copy per message; a real ring buffer is the obvious Swift improvement.inspector-store.ts:365-371, main-state.ts:95-102).AgentInfoModal.tsx:48).src/renderer/src/components/ProtocolLog.tsx:102-131): active session (with a subtle rule that session-less messages arriving after session creation are hidden, :106-115), a hidden-method set, and a session/update sub-type set (hardcoded list at :35-47). Search is a brute-force JSON.stringify(msg.data).toLowerCase().includes(query) per message per render (:126); Cmd/Ctrl+F opens it, Esc closes (:63-81).src/renderer/src/App.tsx:43-59, rationale :1-6. Seven push-event subscriptions in one effect (:78-114).ConnectionBar — command + cwd + connect, with rotating placeholder examples kiro-cli acp, cursor acp, gemini --acp (ConnectionBar.tsx:9, 41-81).SessionPanel — session chips, max 5 visible with the active one pinned, overflow modal (SessionPanel.tsx:11, 27-43); controls are capability-gated with explanatory tooltips ("Agent does not support listing sessions", :63-73).ProtocolLog rows are one line each: timestamp + →/← + JSON.stringify(data, null, 2) in a <pre> (:256-269). No collapsible JSON tree, no request/response pairing in the view — every frame is fully expanded, always. Auto-scroll is instant on session switch, smooth otherwise (:133-139).OutputPanel is the semantic view: it allowlists 10 update types (:20-34) and groups contiguous chunks; tool_call + all subsequent tool_call_updates merge into one group keyed by toolCallId (:65-97), while user messages are deliberately never concatenated (:90).ToolCallCard builds ToolCallViewModel { sessionUpdate, title, kind, toolCallId, status, locations, content, rawInput, rawOutput } from the group (ToolCallCard.tsx:39-49), with icon/label/color tables for 10 ToolKinds and 4 ToolCallStatuses (:55-66, :68-73) — directly portable to a Swift enum. Diffs render as a collapsible per-path DiffView (:98-110); file locations show path:line (:79-96).PermissionCard renders inline in the output stream, not as a modal (PermissionCard.tsx:43-56); button variant is derived from optionId.startsWith('reject') (:36-41); answered cards stay visible marked "— responded" (:69).perm_N ids and parks the resolver in a map (acp-connection-manager.ts:368-375); on disconnect all pending resolve to {outcome:'cancelled'} (:205-209).The only correlation logic is src/main/index.ts:56-99: let messageCounter = 0 plus const requestSessionMap = new Map<unknown,string>() — "Maps JSON-RPC request id → sessionId for correlating responses". Algorithm: take sessionId from params.sessionId (requests) or result.sessionId (responses); if both rpcId and sessionId are present, record the pair; a response lacking a sessionId inherits it from the stored request. Then stamp {id: ++messageCounter, timestamp, direction, sessionId, data} (:101-107).
Holes a Swift host must not inherit:
1 and an agent-originated id 1 collide.acp-connection-manager.ts:226).One genuinely good pattern: -32601 (method not found) is caught and used to fall back from the generic setSessionConfigOption to legacy setSessionMode / unstable_setSessionModel (:447-479), and legacy models/modes fields are normalized into synthetic 'mode'/'model' config options so one dropdown renderer covers both protocol eras (:490-557).
recailai/jockey at c7431a8. Tauri 2 + SolidJS frontend; ~16.2k lines Rust, ~11.4k TS.
agent-client-protocol = "0.10.4", feature unstable_session_model (src-tauri/Cargo.toml:19) — the official ACP Rust SDK, used directly (acp::ClientSideConnection, acp::Agent, acp::Client). Storage is bundled rusqlite 0.39; git2 0.20; release profile LTO + panic=abort + strip (:45-50).acp-registry.latest.json (857 lines) is a vendored snapshot of the upstream registry: {version:"1.0.0", agents:[…], extensions:[…]} (:1-3), per agent {id,name,version,description,repository,authors,license,icon,distribution} (:4-14). distribution has three variants worth modelling in Swift:darwin-aarch64 | darwin-x86_64 | linux-* | windows-x86_64 → {archive: <tar.gz/zip URL>, cmd: "./amp-acp"} (:15-38);{package:"@augmentcode/auggie@0.19.0", args:["--acp"], env:{"AUGMENT_DISABLE_AUTO_UPDATE":"1"}} (:51-61), simplest form {package:"@zed-industries/claude-agent-acp@0.21.0"} (:90-95);28 agents listed.
acp-registry / acp_registry / registry.latest across .rs/.ts/.tsx/.json/.md/.toml returns zero references outside the file itself. All spawning is hardcoded. Take the schema; don't take the plumbing.Mock, ClaudeCode, GeminiCli, CodexCli (src-tauri/src/runtime_kind.rs:5-10), with alias parsing (claude/claude-code/claude-acp → ClaudeCode, :13-21).src-tauri/src/acp/adapter.rs:108-139): ClaudeCode → bin claude-agent-acp, pkg @agentclientprotocol/claude-agent-acp@latest; GeminiCli → bin gemini, pkg @google/gemini-cli@latest, args ["--experimental-acp"] plus a --help probe that requires that flag (:119-127); CodexCli → bin codex-acp, pkg @agentclientprotocol/codex-acp@0.0.40 (pinned), with a --version probe (:128-136).:141-214): (1) managed binary at <appdata>/adapters/node_modules/.bin/<bin> → launch_method:"managed-binary"; (2) which(<bin>) → "path-binary"; (3) package runners in order pnpm dlx <pkg> then npx -y <pkg> → "package-runner:{binary}". Cached per-kind in a DashMap (:19-27, :89-96). Node resolution is delegated entirely to which — no nvm/fnm/volta handling (contrast Gold Band, which enumerates nvm dirs).OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, GOOGLE_APPLICATION_CREDENTIALS, …) (:33-45), sourced by running $SHELL -lic env and parsing k=v (:279-299), applied only when the var is not already set in the process env (:244-256).src-tauri/src/acp/session/cold_start.rs:217-234): tokio::process::Command, all three stdio piped, .kill_on_drop(true) and .process_group(0). PIDs are registered globally (:232-234, worker/pool.rs:158-169); Drop for LiveConnection sends SIGTERM then SIGKILL to the negative pgid and then to the pid (worker/pool.rs:50-63). stderr is pumped line-by-line into a rolling tail buffer used to enrich error messages (cold_start.rs:239-261).tokio::select! (cold_start.rs:298-331): initialize vs a 30 s timeout vs child.wait() (→ process_crashed with the stderr tail) vs a health watch channel (→ connection_closed). INIT_TIMEOUT = SESSION_TIMEOUT = 30 s (:24-25). Client capabilities: fs read/write + terminal(true) + a terminal_output meta flag (:299-313).--experimental-acp, and Codex/ChatGPT model incompatibility (adapter.rs:329-390). A 512-entry in-memory ring of AcpLogEntry {ts_ms, event, payload} doubles as the debug log, snapshot-able from the UI (adapter.rs:31-32, :441-467).auto_approve (src-tauri/src/db/mod.rs:62-75).{app_session_id}:{runtime_key}:{role_name} (worker/pool.rs:21-23) — N roles × M sessions concurrent agent subprocesses, with no configured cap.current_thread Tokio runtime + LocalSet fed by an unbounded mpsc (worker/mod.rs:35-50); connections live in thread_local! CONN_MAP (pool.rs:129-133); LiveConnection is unsafe impl Send/Sync with a documented three-point justification that it never leaves the LocalSet (pool.rs:65-72). This single-threaded-actor design is the most transferable architectural decision in the repo — it maps 1:1 onto a Swift actor.@role: mention parsing (parser.rs:4-70, consumed at chat.rs:221-234), defaulting to a union assistant named "Jockey".for role_name in role_targets { … execute_runtime(…).await … } with outputs concatenated as [Role]\n{output} (chat.rs:246-399). Multiple agents are resident concurrently; a multi-role prompt runs them one after another.StatusUpdate { "Waiting for previous turn... queue position N" } before awaiting (worker/handlers.rs:740-773).ensure_connection calls for one key await a single futures::future::Shared instead of each spawning (pool.rs:145-153).worker/mod.rs:103-110) kills connections idle ≥ 300 s with no in-flight prompt (worker/handlers.rs:120-145, constants :25-26).session/prewarm.rs:172-206); in workflows the next role is prewarmed while the current one runs (db/session.rs:211-219). The repo's own reference table notes prewarm has no analogue in Zed or AionUi (docs/acp_references/README.md:30).summary.{role} in shared_context_snapshots and passed forward (db/session.rs:197-300).workspace_path; the Settings UI Worktrees tab is an explicit placeholder ("Worktree settings will be added when worktree management is wired", src/components/SettingsPage.tsx:346-352).acp/metrics.rs:5-19). The README's "control token costs per role" is role→model binding, not measurement (README.md:10).Client impl narrows acp::SessionUpdate into a flat, serde-tagged AcpEvent enum (tag="kind", camelCase) — acp/worker/types.rs:58-120: TextDelta, ThoughtDelta, ToolCall{toolCallId,title,toolKind,status,content,locations,rawInput,rawOutput,terminalMeta}, ToolCallUpdate, Plan, PermissionRequest, ModeUpdate, ConfigUpdate, SessionInfo, StatusUpdate, AvailableCommands, AvailableModes, SessionError, PermissionExpired. That narrowing — ACP's tagged union to one flat host event type — is exactly the boundary a Swift host needs.acp/client.rs:311-360); empty text chunks are dropped (:320-322). Events cross a bounded 512-slot mpsc (pool.rs:16-17), are re-emitted as Tauri events with TextDeltas batched on a ~30 ms flush (session/execute.rs:101-136), and the frontend coalesces noisy session/update lines over a 120 ms window (src/lib/sessionEventBuffer.ts:6, 24-69). Terminal meta is folded into per-session terminal entries with buffering for output that arrives before its info frame (src/lib/acpEventBridge.ts:32-70).acp/client.rs:212-258): (1) if the role has auto_approve, pick the first AllowOnce/AllowAlways option and answer immediately (:217-237); (2) else consult an LRU approval cache keyed by a derived permission_cache_key and auto-answer on hit (:239-246); (3) else mint a UUID request id, emit PermissionRequest and park a oneshot (:248-258). The cache is 256 entries and is written only when the chosen option was in allow_always_option_ids (worker/permission.rs:9, 70-86). Pending requests live in a DashMap and can be cancelled per-(runtime, role, session) or globally, each emitting PermissionExpired (:88-125). UI is an inline panel with a "remember" switch that filters the option list to allow_always (src/components/PermissionModal.tsx:12-50).Jockey is the only client here that implements "always allow" persistence — and it does it host-side, in an LRU keyed by a derived key, not by forwarding allow_always and forgetting.
src-tauri/src/db/mod.rs): app_sessions(id,title,active_role,runtime_kind,cwd,created_at,last_active_at,closed_at) (:113-122); app_session_roles(app_session_id, role_name, runtime_kind, acp_session_id, model_override, mode_override, mcp_servers_json, config_options_json) (:123-133) — the ACP session handle is keyed per (app session, role); app_session_messages (:142-148); workflow sessions/session_events (:83-98); shared_context_snapshots(scope,key,value) (:99-105).execute.rs loads the stored CLI session id and passes it as resume_session_id (session/execute.rs:92-125); cold start gates on the agent's load_session capability and falls back to new_session if load fails, logging session.load.fallback with the stderr tail (cold_start.rs:382-430). Same shape as emdash's materializer — two independent implementations converging on the same rule.acp_session_id) is restored (src-tauri/src/lib.rs:178-200).model[effort]; Jockey splits it (cold_start.rs:75-86) and re-synthesizes two select config options — model and reasoning_effort — for a uniform UI (:96-150).docs/completed/agent_orchestrator_design.md:1-30 states the thesis: Tauri as "Conductor" — all JSON-RPC traffic passes through the backend, which parses session/update for thought chains and plans (:16); agents never talk to each other, the Conductor is the sole instruction source, for predictability and safety (:17-19); plus a "Shared Brain" where Tauri itself acts as an MCP server exposing get_shared_context/update_shared_context as a blackboard, bridged by generating virtual MCP stdio configs so vendor CLIs think they are talking to a local process (:21-30, realized in src-tauri/src/jockey_mcp/bridge.rs).docs/acp_references/README.md:22-37: a per-file mapping of Jockey module → concept → Zed analogue with line numbers into crates/agent_servers/src/acp.rs → AionUi analogue (e.g. permission routing ↔ Zed acp.rs:3032; connection pool ↔ Zed's sessions: Rc<RefCell<HashMap<SessionId, AcpSession>>>).docs/acp_references/README.md:41-47): the Send→!Send bridging pattern; the admission that Jockey lacks a single-owner client class (its equivalent is "scattered across worker/pool.rs + worker/handlers.rs + worker/notify.rs"); and that Jockey "passes String everywhere" instead of a canonical error enum with retryability flags.Read 2026-09-02 via gh issue list --state all --limit 80 on both repos plus ~25 issue bodies and the full release history since 2026-06.
agentclientprotocol/claude-agent-acp (latest v0.73.0, 2026-09-01)#Theme 1 — Permissions / settings bypass. A host cannot assume its own permission gate is the only one.
session/new's _meta.claudeCode.options.model is silently overridden by settings.model in ~/.claude/settings.json; no error, no warning, and configOptions then reports a third value. Measured on 0.70.0. Same class as fazm's discovery that permissions.defaultMode: bypassPermissions in user settings starves the gate (extract §A.6) — the fix there was _meta.claudeCode.options.settingSources = [].toolCall.title verbatim will show the wrong thing.permission_denied emitted a tool_call_update for a tool call the client was never told about. Fix: "Only resolve a denied tool call the client was told about."session/new, session/list and other session operations were blocked by a plan-request permission.Theme 2 — session/update after the turn, and ghost tool calls. The single most important class for a fold implementation.
session/update keeps arriving after session/prompt resolves: "the Claude session is frequently not idle at that moment: after a background sub-agent's task-notification the model resumes and keeps producing… with no session-level active/idle signal." This is precisely why emdash has lazy agent-initiated turns + a 250 ms quiescence debounce.stream_event tool starts are forwarded without terminals: after session/cancel the adapter keeps emitting content_block_start for tool_use (→ tool_call starts) while the consolidated path that produces terminal tool_call_updates correctly drops the cancelled turn. "Any client that keeps a ledger of open tool calls … ghost open tool_calls poison the next turn." Mitigation: force-settle every running tool on turn close — exactly emdash's finalizeItems (item-fold.ts:785-824).session/update tool-call notifications arriving after end of turn.tool_progress forwarded the SDK's synthetic -heartbeat-N id, so clients got tool_call_update for tool calls that were never announced. Never trust that an update's toolCallId was previously announced (emdash synthesizes the row: item-fold.ts:736-749).session/prompt left unanswered when a turn fails to finalize; only resolves after session/cancel, as stopReason: cancelled. A host needs an inactivity watchdog on the prompt await.session/prompt unanswered forever, or detach it without an observable terminal response. Fixed progressively in 0.64.0 ("steering: add opt-in host-owned fallback", closes #903) and 0.65.0 ("settle a steered turn at idle, not at the interrupt").Theme 3 — Resume / session/load.
claude --session-id <uuid> silently completes without its history: the turn succeeds and the same UUID is echoed back, but the model has none of the native history. "Continuity silently lost." A host must not assume an echoed session id means the history loaded.session/load replays fine (~21 MB jsonl, ~4200 updates), then every prompt fails "Prompt is too long", auto-compaction never triggers, and /compact is a silent no-op.session/list matches cwd case-sensitively, so a /mnt/<drive> path can't find its own session.session/load drops marker-only user prompts for model-bound slash skills.session/load returned the wrong current config options; #848 "Preserve live model on resumed sessions". This is why emdash re-applies desired config after every load (session-materializer.ts:341-355).conversation_reset drops new_conversation_id, causing stale session resume after a worker restart.Theme 4 — Zombie / orphan processes.
session/load resumes": multiple live claude CLI children under one adapter parent instead of exactly one. The adapter itself leaks; a host that keeps one long-lived adapter process per (provider, cwd) — emdash's design — inherits this. Budget a periodic descendant audit.session/cancel is not a kill.Theme 5 — Quota / auth / 401 mapping.
ANTHROPIC_BASE_URL points at a third-party gateway: a stream that dies mid-flight hangs indefinitely with no error, no abort, no retry. "We observed a 26-minute dead hang." A host using a custom endpoint must impose its own idle timeout. (fazm learned the same lesson: a 120 s guard for custom endpoints, extract §A.11.)usage_update never carries the effective model id. Cost display is unreliable.Theme 6 — MCP.
session/new.mcpServers never reaches the model: no tools/list, not even listed as known-but-unconnected, while the same server works when driven directly. If your host injects its own tools per session (fazm's fazm_tools pattern), verify they actually appear.Theme 7 — Dropped signals.
system messages with subtypes hook_started, hook_progress, hook_response, task_notification, compact_boundary, status, files_persisted are silently dropped; the source break is annotated // Todo: process via status api. (fazm patched the adapter prototype to re-emit exactly these, extract §A.2.)agent_message_chunk, indistinguishable from model output.agent_message_chunk after available_commands_update./compact conversation summary is dropped rather than surfaced.ScheduleWakeup / CronCreate / /loop never fire under ACP: crons are registered but the wakeup prompt is never delivered while idle. (fazm's mitigation: disallow those tools outright, extract §A.6.)Release-note changes since 2026-06 (gh api .../releases): 0.60.0 (07-20) configurable LLM providers (#853), removed a ~15 s stall on session/new/model switch (#894). 0.63.0 (07-27) "key Bash terminal metas off the announced tool_use id" (#917) — terminal-id association changed. 0.64.x (07-30/08-02) opt-in host-owned steering fallback; restored the single-tool ExitPlanMode (#942). 0.67.0 (08-14) Skill tool calls carry name+kind in _meta (#986); model fallback as a warning advisory (#990). 0.68.0/0.69.0 (08-14/16) typed session failures + changed files aligned to "AIR" — a new failure taxonomy on the wire. 0.70.0 (08-18) provider switching on loaded sessions (#1002). 0.71.0 (08-31), the big one: AI session titles (#984), "align Claude modes and clear-context planning" (#1004), "expose permission mode kinds" (#1025), native subagents and async tasks (#1017), per-model token usage on prompt responses (#1037), message-specific session forks (#1046), min zod → 4.x (#1057). 0.72.0/0.73.0 (09-01) SDK 0.3.252; per-model effort settings and user_message_uuid result attribution (#1065).
agentclientprotocol/codex-acp (latest v1.8.0, 2026-09-01)#Permissions / sandbox.
kind:"execute" but the underlying MCP calls have already run without a session/request_permission. A read-only ACP mode is not a sandbox.workspace-write/workspace-write/danger-full-access. Mode names are not stable across releases.~/.codex/config.toml are ignored; only three predefined INITIAL_AGENT_MODE values exist.Resume / session/load.
session/load returns the config.toml model/effort instead of the thread's, because thread/resume is always sent a modelProvider. Any in-session model switch is lost on resume. Reproduced on 1.1.4 and 1.1.7.session/load replays turns that were removed by thread_rolled_back; the history fallback ignores the rollback event and merges obsolete turns back in./mnt/<drive> paths, so session/list can't find a session it just created.session/load restored text history but missed historical tool calls. #222 CLOSED — multi-agent tool calls were only emitted on session/load, never during the live turn.ephemeral: true threads).Quota / usage.
PromptResponse.usage reports only the turn's final request (lastTokenUsage), not the session-cumulative totalTokenUsage; multi-request tool-calling turns systematically under-report output tokens.RateLimitSnapshot (the ChatGPT subscription usage windows shown by /status) is ingested but never crosses the ACP boundary — only as markdown inside /status output. No live "47% used; resets in 2h 14m" badge is possible without scraping.Auth / launch.
codex-acp login tries to launch a separate codex on PATH instead of the bundled @openai/codex; if absent, the child exits before answering initialize, and on Windows the only message is "Pending response rejected since connection got disposed". Directly relevant: emdash's CODEX_PATH env is the fix for this class.account/login/start with type:"chatgptAuthTokens").wire_api gateway config is injected, so the first message gets a WebSocket 405 and falls back to HTTPS.chat-gpt auth always opened a browser even when already logged in.Fold / stream hygiene.
agent_message_chunk, the same kind as genuine assistant replies: "downstream consumers cannot distinguish a warning from model prose without unsafe text matching." (Identical to claude-agent-acp #1042.)"auto"; no detailed/raw reasoning (a regression from zed-industries/codex-acp).tool_call status, so the call never completed.agent_message_chunk.Terminal.
zed-industries/codex-acp uses terminal_output while agentclientprotocol/codex-acp uses terminal_output_delta. Two forks, two terminal wire shapes. Know which one you spawned.Protocol/version.
protocolVersion of a different type than codex-acp expects. A native host must match the schema's exact JSON type.Model.id to turn/start instead of the executable Model.model.npx -y <pkg>@latest can resolve to something older than the release notes claim.Release-note changes since 2026-06: v1.0.0 (06-23) rename/relaunch from the Zed fork; v1.1.0 (07-02) ACP SDK 1.1; v1.1.1 (07-09) SDK 1.2.1 + MCP elicitation; v1.1.4 (07-15) subagent activity over ACP; v1.1.8 (08-01) structured permission changes + plan-review confirmation; v1.2.0 (08-12) typed session failures; v1.3.0 (08-14) versioned context-compaction metadata; v1.5.0 (08-18) provider switching on loaded sessions; v1.7.0 (08-27) ACP v1 permission presentation + native ACP subagent sessions + permission mode kinds; v1.8.0 (09-01) session forks, /rename, OAuth2 for MCP servers, codex 0.152.0.
tool_call_update as possibly-first and possibly-late; synthesize missing rows, and force-settle open ones at turn close.session/prompt resolving as "the session is idle" — run a quiescence timer._meta.| Emdash | Gold Band | Jockey | ACP Inspector | (fazm, for reference) | |
|---|---|---|---|---|---|
| Language / shell | TS, Electron + Node worker | Rust, Tauri 2; React web | Rust, Tauri 2; SolidJS | TS, Electron | Swift app + Node bridge |
| ACP library | @agentclientprotocol/sdk (ClientSideConnection + ndJsonStream) |
agent-client-protocol-schema 1.6.0 (types only; own transport) |
agent-client-protocol 0.10.4 (full Rust SDK) |
@agentclientprotocol/sdk ^0.17.1 |
@agentclientprotocol/sdk 0.19.0 |
| Agents supported | 23 ACP-capable of 36 | 11 (embedded registry snapshot) | 4 hardcoded (registry file unused) | any (user types the command) | 3 (Claude, Codex, Gemini) |
| Spawn | process.execPath + ELECTRON_RUN_AS_NODE=1 + bundled adapter asset |
npx -y <pinned pkg> via ManagedProcessGroup |
managed-binary → which → pnpm dlx/npx -y |
user-supplied command, whitespace-split | bundled Node + patched adapter entry |
| Adapter binary policy | CLAUDE_CODE_EXECUTABLE / CODEX_PATH → host CLI; vendored SDK binaries in ignoredOptionalDependencies |
CLAUDE_CODE_EXECUTABLE → PATH claude, strict mode via GOLD_BAND_REQUIRE_LOCAL_CLAUDE |
none | none | bundled codex-acp native binary |
| PATH source | login interactive shell probe $SHELL -ilc env |
login-shell PATH cached in OnceLock + nvm/volta/homebrew appended |
$SHELL -lic env, only fills unset vars |
zsh/bash -ilc env + passwd-DB identity |
no shell probe; hardcoded ladder |
| Env policy | 121-name allowlist, env built from scratch | config env + merged PATH | 11-key allowlist, non-overriding | replaces process.env wholesale, TERM=dumb |
full process.env minus CLAUDECODE |
initialize client caps |
fs{read,write} + terminal |
none (only _meta.nestedAgentTranscript, elicitation.form) |
fs{read,write} + terminal(true) |
fs{read,write} + auth.terminal |
none (fs false) |
| Fold | 4-stage pure pipeline: decode → provider enrich → reducer → item-fold; 11 typed tool rows + auto read-groups; dev invariants | durable append-only timeline with checkpoints, compaction, blob externalization, revision-checked settlement; unknown kinds → rawDiagnostic |
narrow to a flat serde-tagged AcpEvent enum; 30 ms delta batching; 120 ms UI coalescing |
group contiguous chunks; merge tool_call + updates by toolCallId |
switch → private JSON-lines events; diffs/terminals flattened to text |
Missing messageId |
synthesized segments auto:<stream>:<n> |
n/a (log is append-only) | n/a | n/a | text-block boundaries |
| Permission default | no auto-approve on the ACP path; band above composer; default allow_once |
no auto-approve; file-based request/response, 200 ms poll | 3-stage: role auto_approve → 256-entry LRU cache → prompt |
inline card; no default | auto-approve everything (upstream added a gate) |
| "Always allow" persistence | none (forwarded to agent only) | none | yes — LRU keyed by derived permission_cache_key, written only for allow_always |
none | none |
| Permission timeout | none | none (poll until answered or cancelled) | none (explicit cancel emits PermissionExpired) |
none | 300 s → cancelled (upstream gate) |
| Cancel with pending perms | drain() → all {outcome:'cancelled'} |
writes a cancelled response file for every pending request | cancel per-(runtime,role,session) or global | all resolve cancelled on disconnect |
gate drains |
| Terminals | full client-side impl: 4 MB ring, StringDecoder, separate capped log channel |
agent-side (no terminal capability declared) |
declared + terminal_output meta; frontend buffers out-of-order frames |
declared but unused | none (no PTY, no terminal methods) |
| fs methods | implemented; write does mkdir -p |
not declared | implemented | implemented | declined |
| Sessions / resume | loadSession if supportsLoadSession, fall back to newSession on failure; suspend/rematerialize; versioned persisted intent |
session/resume vs session/load, capability-gated, typed "unsupported" errors; external session sync |
stored acp_session_id per (app session, role); fall back to new_session on load failure |
live only; session/list if supported |
cwd-addressed JSONL bookkeeping + JSONL migration |
| Persistence | SQLite (app) + JSON intents file; transcript in memory | files-per-artifact + append-only timeline; SQLite only as an FTS5 index | SQLite for everything | nothing persisted | ~/.fazm/acp-sessions.json |
| Process cleanup | detached + group SIGTERM→SIGKILL (1 s grace), idempotent; no orphan sweeper |
process groups registered globally, pgid persisted per agent, crash-recovery kill + emergency sweep | process_group(0) + kill-on-drop + negative-pgid SIGTERM→SIGKILL |
stdin.end() → 3 s → SIGKILL |
ps-scraping orphan sweep + parent-death watchdog |
| Connection pooling | one process per (providerId, cwd), 2 min idle TTL, generation-fenced routes |
per attempt/agent, initialize_once latch with poisoning |
pool key {app_session}:{runtime}:{role}, 300 s idle reclaim, cold-start dedup, prewarm |
one connection, period | one adapter per provider |
| MCP injection | session/new.mcpServers, env as [{name,value}], transport gated on agent mcpCapabilities |
managed MCP config (src/mcp/) |
per-role mcp_servers_json; Tauri itself is an MCP server (shared-context blackboard) |
none | stdio MCP dialing back over a Unix socket |
| Raw protocol log | RawAcpLog: 50 k entries and 16 MB caps, exportable, fixture-compatible |
acp.raw.jsonl per attempt, batched appends, size-rolling with pinned prefix |
512-entry in-memory ring, snapshot-able | uncapped in main + 500-item slice in renderer; no export |
stderr tee'd to a 10 MB rotating file |
| Debug UI | export commands on the API; no dedicated inspector | src/inspect/ + pipeline diagnostics + raw-frame viewer |
log snapshot in UI | the whole product | none |
| Multi-agent orchestration | one conversation per session; no fan-out | WORKFLOW graph + AI-DYNAMIC decomposition + cron scheduling | roles + sequential fan-out + workflow chain + shared-context blackboard | n/a | n/a |
| Cost / usage | contextUsed/contextSize/cost from usage_update only |
usage projection in the timeline + context gauge | none | none | patched adapter captures total_cost_usd |
| Auth | status detection only; login delegated to the CLI in a terminal | catalog-declared agent dirs; CLI-owned | none (env keys only) | never calls authenticate |
own OAuth PKCE + Keychain writes |
Each tied to the evidence above. Ordered by leverage.
I.1 Spawn the adapter with Swift's own bundled Node, and point it at the user's CLI.
Set CLAUDE_CODE_EXECUTABLE and CODEX_PATH to the resolved host binary (emdash impl/claude/index.ts:148-151, impl/codex/index.ts:132; Gold Band adapter.rs:59-62). Two independent implementations do this, and codex-acp #459 is the bug you avoid. Add Gold Band's strict mode: a setting that makes an unresolved CLI a hard error rather than letting the adapter download a second copy (adapter.rs:63-72).
I.2 Build the child environment from an allowlist, not from ProcessInfo.processInfo.environment.
Port AGENT_ENV_VARS (emdash primitives/agent-env/api/index.ts:1-121) plus TERM, COLORTERM, TERM_PROGRAM, HOME, USER, PATH, conditional TMPDIR/SSH_AUTH_SOCK. Set TERM_PROGRAM to your app name. Jockey's 11-key list is too small; acp-inspector's wholesale replacement is too blunt.
I.3 Resolve PATH from a login shell, and append the well-known dirs anyway.
A Dock-launched .app gets a minimal launchd environment. acp-inspector additionally recomputes USER/HOME/SHELL from the passwd DB for exactly this reason (shell-env.ts:42-58). Then append Gold Band's list: ~/.local/bin, ~/.cargo/bin, ~/.volta/bin, every ~/.nvm/versions/node/*/bin, /opt/homebrew/{bin,sbin}, /usr/local/{bin,sbin} (process.rs:200-262). Cache the probe once (Gold Band's OnceLock, process.rs:40-41); budget 5–10 s and a hard timeout.
I.4 Race initialize against process death, always.
Promise.race([initialize, processClosed]) in emdash (acp-agent-connection.ts:114-117, error text :168-173); Jockey's 4-way tokio::select! over initialize / 30 s timeout / child.wait() / health-watch, with the stderr tail attached to process_crashed (cold_start.rs:298-331, :239-261). In Swift: withThrowingTaskGroup, first-to-finish wins, and keep a rolling stderr tail to put in the error. Also stat-validate cwd before spawning (acp-inspector :72-79) — ENOENT is otherwise indistinguishable from a missing binary.
I.5 Own the process group and persist the pgid.
posix_spawn with POSIX_SPAWN_SETPGROUP (or setpgid in a posix_spawn_file_actions fork), then SIGTERM the negative pgid → poll kill(-pgid, 0) for ~1 s → SIGKILL (emdash process-tree-terminator.ts:64-141; Jockey pool.rs:50-63). Then add what only Gold Band has: write the pgid to a per-agent file and kill it on next launch (process.rs:603-637, pid file at storage/mod.rs:338). emdash [issue] #2153 (21.1 GB of orphans) and claude-agent-acp #1011 (the adapter leaks claude children across resumes) are the two receipts.
I.6 Model the fold as four pure stages and assert invariants in debug builds.
decode (stateless, ACP → your enum) → provider enrich → reducer (turn boundaries + session slices) → item fold (merge rules). Port the specific rules that took emdash real bugs to find:
undefined means "don't change" (item-fold.ts:44-56, 219-225);tool_call_update for an unknown id synthesizes the row (:736-749);create-file/modify-file items keyed ${toolId}:${path} (:365-435); an edit-kind call with no diffs yet renders nothing (:684);:349-363);running tool on turn close (:785-824) — this is the defence against claude-agent-acp #1061 ghost tool calls;read calls collapse into a group (:516-549);parentToolCallId on every fold (:551-596) so late parents work.In debug, throw on duplicate item id, unsorted/duplicate sibling seq, and >1 open thinking row (reducer.ts:379-437).
I.7 Handle a missing messageId with synthesized segments.
auto:<stream>:<n> counters that bump when the stream kind changes (reducer.ts:181-221, 254-268), plus Claude's thinking-id reuse workaround (:223-246). Otherwise separate thoughts merge into one blob.
I.8 Add a quiescence timer and a lazy agent-initiated turn.
Agent updates arriving while ready open a turn with initiator: .agent and arm a ~250 ms debounce that settles with reason quiesced (emdash cell.ts:592-607, reducer.ts:536-539). This is the concrete answer to claude-agent-acp #864, and it costs ~30 lines.
I.9 Permissions: no host-side bypass; render the request as the tool row; publish pending state on the session.
cell.ts:669-690) — one renderer, no drift.pendingPermissions on the session model so the composer band, the sidebar count and the tool row's awaitingPermission all read one source (models/session.ts:40, 70; tool.def.tsx:43). emdash [issue] #531 is the lesson: a spinner reads as "busy", not "blocked on you".allow_once, tone by kind prefix, show "1 of N" (permission-band.tsx:48-60, 95-99).config-derive.ts:88-93), not a bypass flag — and expose mid-session mode changes (emdash [issue] #1671, and claude-agent-acp 0.71.0 "expose permission mode kinds" / codex-acp 1.7.0 "ACP v1 permission presentation" make this newly practical).allow_always option (worker/permission.rs:70-86). Never infer it.permission.rs:36-48). In Swift, a small on-disk queue plus a CheckedContinuation gives you both.settingSources: [] / an explicit initial mode after session creation (fazm extract §A.6; claude-agent-acp #1056).I.10 Implement client-side terminals — it is cheap and it is the difference between a chip and a live view.
Declare terminal: true, implement the five methods, and copy emdash's ManagedAgentTerminal: byte-capped ring buffer that drops oldest and sets truncated, incremental UTF-8 decoding so multibyte chars never split, metadata-only snapshots, and output on a separate capped channel that republishes the conversation only on lifecycle transitions (managed-terminal.ts:6-100, terminal-live-registry.ts:21-51). Then link execute-tool-call.terminalId to the live stream (execute.presenter.ts:23). Note claude-agent-acp 0.63.0 changed terminal-meta keying to the announced tool_use id (#917), and the two codex-acp forks differ (terminal_output vs terminal_output_delta, #166).
I.11 Resume: try session/resume first, then session/load, then fall back to session/new — and say which happened.
Gold Band's SessionRestoreIntent{ContinueOnly, SyncHistory} × SessionRestoreMethod{Resume, Load} with capability gating and typed RestoreUnsupported/HistorySyncUnsupported errors (client.rs:2163-2228) is the right model. Both emdash (session-materializer.ts:139-150) and Jockey (cold_start.rs:382-430) independently fall back to a new session on load failure. Report the outcome (resumeOutcome: 'loaded' | 'replaced-by-new') — emdash [issue] #1229 and #3093 are what happens when you don't. Re-apply model/mode/effort after every load (adapter #845, #343), and clear a retained value the provider no longer advertises rather than sending it (session-materializer.ts:305-405).
I.12 Persist an explicit, versioned intent — never the environment.
{version, conversationId, providerId, cwd, sessionId?, configured{model,mode,effort,collaborationMode}, presentation{lastKnownCapabilities, lastKnownMcpServers, lastKnownUsage, observedAt}} (session-intent-schemas.ts:24-32). "Provider environment, MCP credentials, runtime endpoints, and unknown descriptor fields are never persisted" (acp-runtime.md:113-115). On boot restore index rows only — never start an agent from disk (acp-runtime.md:126-129).
I.13 Pool one agent process per (providerId, cwd) and fence routes by generation.
Route key "\(providerId):\(cwdIdentity):\(generation)" (emdash connection/source.ts:75-81, session-router.ts:144-146) so a replacement process cannot inherit its predecessor's updates — Gold Band shipped the same fix as fix(acp): fence stale provider lifecycle writes (CHANGELOG.md:42). Serialize session/load handshakes per process (session-materializer.ts:203-236) and keep a provisional route for the load in flight so a rebound session id still resolves (session-router.ts:134-141). Reclaim idle connections (emdash 2 min, Jockey 300 s); idle out sessions after ~60 min without output.
I.14 Ship a debug view. Capture at the frame layer.
Insert a tap in the ndjson pipeline, not around typed SDK calls (acp-inspector acp-connection-manager.ts:118-142) — the log is then wire truth. Adopt ProtocolMessage {localId, timestamp, direction, sessionId?, data} where data is JsonRpcRequest | JsonRpcResponse | ProcessEvent — fold stderr and process exit into the same stream (shared/types.ts:65-98). Then fix its four gaps: a real ring buffer (not slice(-500)), an id→session map pruned on response and namespaced by direction, pending-request tracking with elapsed time and unanswered-request surfacing, and export. For export, copy emdash's RawAcpLog — dual entry/byte caps (50 000 / 16 MB), {meta, events[]} envelope, and fixture-compatible so a captured session becomes a snapshot test (raw-log.ts:58-128; fixtures at impl/{claude,codex}/fixtures/acp-transcript.json). Add Gold Band's size-rolling on-disk acp.raw.jsonl if you want post-crash forensics (events.rs:1245-1364).
I.15 Design the public API around user intent, not lifecycle.
Copy emdash's contract shape (api/contract.ts:70-171): attach, launch, terminate, sendPrompt, editQueuedPrompt, deleteQueuedPrompt, changeQueuePromptOrder, cancelTurn, setOption, resolvePermission, loadHistory, export* and observable state sessions.list + per-conversation {state, config, usage, plan, agents, activeTurn, terminals, mcpServers}. Two stated rules to keep: "there is no public ensureActivation, start, or resume" and "the public identity is always conversationId" (acp-runtime.md:104-123). Publish the derived booleans isGenerating / canSubmit / canCancel from the state machine so SwiftUI never re-derives them (models/session.ts:51-56).
I.16 Queue prompts instead of rejecting them, and make the queue editable.
A prompt sent while working/cancelling/agentTurnActive/backgroundAgentCount > 0 becomes PromptQueued (machine/machine.ts:120-132), and queued prompts are editable/reorderable/removable API objects (contract.ts:94-105). Gold Band shipped the same feature (CHANGELOG.md:278).
I.17 Build the prompt as image blocks first, then text, then hidden context.
[{type:"image",data,mimeType}…, {type:"text",text}, {type:"text",text:hiddenContext}] (cell.ts:508-519). Flat image shape — not the Anthropic nested source form. Echo the user message into the transcript before the RPC.
I.18 MCP: send env as [{name,value}], gate http/sse on the agent's advertised mcpCapabilities, and verify your server actually appears.
(mcp-servers.ts:37-71; capability source acp-agent-connection.ts:119-122.) claude-agent-acp #883 is a live bug where a session-scoped stdio server never reaches the model — assert on tools/list before relying on injected tools.
I.19 Auth: detect status, delegate login to the CLI. Do not run your own OAuth.
Emdash's posture (impl/claude/auth.ts:17-33; login declared as {kind:'cli-login', args:['auth','login']}) versus fazm's own PKCE flow + Keychain writes (extract §A.9). The former has no policy exposure and no token-format risk. Report three states — authenticated / unauthenticated / unknown — and treat unknown as "let the agent try".
Pre-seed trust separately: write projects[<cwd>] = {hasTrustDialogAccepted:true, hasCompletedProjectOnboarding:true} into .claude.json (impl/claude/trust.ts:7-36), or the agent blocks on a dialog you can't see. Make that write visible and switchable (emdash [issue] #1944).
I.20 Add your own idle watchdog on every await, and classify errors.
claude-agent-acp #1023 (dead stream, 26-minute hang with a third-party ANTHROPIC_BASE_URL), #970/#896/#825 (unanswered session/prompt). Map the raw failures to user-facing strings the way Jockey does — rate limit, auth, crash, timeout, EPIPE, corrupt npx cache, missing --experimental-acp, model incompatibility (adapter.rs:329-390) — and give each a retryability flag, which Jockey itself admits it lacks (docs/acp_references/README.md:41-47).
I.21 Adopt the registry schema for the agent catalog, pin versions, and record provenance.
Gold Band's resources/agent-catalog.json is the model: {schemaVersion, source:{url, registryVersion, fetchedAt}, agents:[{id,label,version,command,args,env,primaryAgentDir,supportsSystemPrompt,supportsExternalSessionSync}]} compiled in via include_str! and validated at load (agent_catalog.rs:6-70). Jockey's acp-registry.latest.json gives the richer upstream distribution union (binary/npx/uvx). Pin exact versions — codex-acp #411 shows @latest can lag a release. And check the Gemini flag: Gold Band's 2026-09-01 snapshot says --acp for @google/gemini-cli@0.57.0, while fazm and Jockey still pass --experimental-acp.
I.22 One actor per connection; one serialized queue per session.
Jockey's single-threaded LocalSet + per-key mutex + user-visible "queue position N" (worker/mod.rs:35-50, worker/handlers.rs:740-773) maps directly onto a Swift actor per connection with an AsyncStream of commands. Add cold-start dedup (one shared in-flight connect per key, pool.rs:145-153) and, if startup latency matters, prewarm (session/prewarm.rs:172-206).
I.23 Things nobody has built that you could.
Across all four repos there is no protocol-log export from the inspector, no replay/re-send of a captured frame, no unanswered-request/timeout surfacing, no per-request latency, no worktree isolation per session (Jockey's is an explicit placeholder), and no real token/cost accounting. Emdash has the export and the fixtures; acp-inspector has the view; nobody has both. A Swift host with a frame-level tap, a ring buffer, request/response pairing with latency, unanswered-request highlighting, and one-click export-to-fixture would be the best ACP debug surface in the ecosystem — and it is maybe 600 lines.
npx -y @google/gemini-cli@0.57.0 **--acp** (resources/agent-catalog.json), while Jockey (adapter.rs:119-127) and fazm (extract §A.2) both pass --experimental-acp, and Jockey even probes --help for that string. Version-dependent. Resolve by running gemini --help against the pinned version before wiring it.codex-acp products. emdash bundles @agentclientprotocol/codex-acp/dist/index.js as CJS with @openai/codex external (impl/codex/adapter.ts:3-8); fazm shells the Rust binary from @zed-industries/codex-acp-darwin-arm64 (extract §A.2); Jockey pins @agentclientprotocol/codex-acp@0.0.40 (adapter.rs:128-136) which is a pre-1.0 version string that does not appear in the current release list. codex-acp #166 confirms the two forks differ on the terminal wire shape. Decide which fork, and pin it.terminal; Gold Band and fazm do not. Gold Band's product still shows terminal output (via session/update content), so declaring the capability is not required to render commands — it is required to own them. Unresolved which gives the better UX; emdash's live-log channel is the more impressive artifact.session/resume — spec or extension? Gold Band treats it as a spec method gated on sessionCapabilities.resume in agent-client-protocol-schema 1.6.0 (client.rs:2194-2205); fazm documented it as a non-standard Claude-adapter method (extract §A.3); emdash only ever calls loadSession. The schema crate is the more recent evidence, but I did not read the ACP v1 schema directly in this lane. Open: confirm sessionCapabilities.resume in the current published schema.-ilc or -lc? emdash and acp-inspector both use interactive login (-ilc), Jockey uses -lic, fazm uses no shell at all. Interactive sourcing picks up ~/.zshrc (where most people put nvm) but also runs prompt frameworks and can be slow or emit noise. All three that probe use a 5–10 s timeout. Unresolved which is right for a signed, sandboxed-off .app.TERM: xterm-256color or dumb? emdash sets the former (agent-env/api/index.ts:183), acp-inspector the latter (acp-connection-manager.ts:83). If a tool's output reaches your UI unfiltered, dumb avoids ANSI; if the agent runs a TUI-ish command, dumb degrades it. Neither repo justifies its choice.SESSION_IDLE_MS = 60 * 60_000 for sessions vs ACP_CONNECTION_IDLE_TTL_MS = 120_000 for connections (worker-spec.ts:14, session-lifecycle/api/index.ts:35). Jockey reclaims at 300 s. fazm's comment says leaving warm sessions alive forever "was the structural cause of the CPU regression reported 2026-05-14" (extract §A.3). Three different answers; the right one probably depends on whether the agent process is shared.cancelled (extract §A.6). For an unattended/headless SnappyOS run this matters; for an interactive one, an auto-cancel could silently kill long work. Product decision, not a technical one.total_cost_usd, by monkey-patching the SDK iterator (extract §A.2). Over stock ACP, usage_update gives context and an optional cost amount, codex-acp under-reports (#447), and usage_update never carries the effective model id (#1021). A trustworthy per-run cost number is currently not obtainable through stock ACP.client.rs. I read its initialize, restore-plan and external-sync surfaces but not the whole file; there is more there on branch/fork semantics, cancellation convergence and nested-agent transcripts than this report captures. If the Swift host adopts branches/forks, that file is the deepest prior art available.docs/acp_references/README.md:22-37 maps its own modules onto Zed's crates/agent_servers/src/acp.rs with line numbers — that table is the cheapest way to cross-check this report's conclusions against the reference client.# Lane R1b — Strong open-source ACP desktop clients, read for a Swift-native host
Date: 2026-09-02. Scope: how four shipping open-source ACP desktop clients actually spawn agents, fold `session/update`, render tool calls, gate permissions, run terminals, persist sessions, and inject MCP — plus the adapter bug themes that bite every host. Written to inform a Swift-native ACP host inside SnappyOS.app that spawns `@agentclientprotocol/claude-agent-acp`, `@agentclientprotocol/codex-acp` and `gemini --acp` and speaks ACP with `wiedymi/swift-acp`.
Prior art this report deliberately goes past: `~/.claude/skills/snappy-agent-host/references/extract-agent-host.md` §A (fazm's Node bridge) and §D (a one-paragraph table row on Emdash). Everything below is read from source at the pinned commits.
Tags: **[src]** = repo code read locally · **[docs]** = in-repo docs/README · **[issue]** = GitHub issue/PR/release read via `gh`.
---
## A. Sources
| # | Source | Pinned at | Stack | Read |
|---|---|---|---|---|
| 1 | `generalaction/emdash` — `/Users/robertboulos/projects/cloned-repos/emdash` | `30ddc86` (2026-09-02, "Merge PR #3099") | Electron + Node 24 + pnpm/nx monorepo; Solid (chat transcript) + React (shell); TS everywhere | **[src]** ~40 files across `packages/core/src/runtimes/acp/**`, `packages/plugins/src/agents/**`, `packages/chat-ui`, `packages/ui`, `apps/emdash-desktop/src/main/gateway`, `packages/wire` |
| 2 | `diodeme/Gold-Band` — `.../gold-band` | `9f9247c` (2026-09-01) | Tauri (Rust) + web frontend, "local-first ACP desktop client" | **[src]** see §D |
| 3 | `newioapp/acp-inspector` — `.../acp-inspector` | `57b993e` (2026-06-26) | Electron + electron-vite | **[src]** see §E |
| 4 | `recailai/jockey` — `.../jockey` | `c7431a8` (2026-06-07) | Tauri (Rust) + Vite frontend | **[src]** see §F |
| 5 | `agentclientprotocol/claude-agent-acp` | releases to v0.73.0 (2026-09-01) | — | **[issue]** `gh issue list --state all --limit 80`, `gh release list`, ~11 issue bodies |
| 6 | `agentclientprotocol/codex-acp` | releases to v1.8.0 (2026-09-01) | — | **[issue]** same treatment, ~14 issue bodies |
| 7 | emdash issue tracker | — | — | **[issue]** 8 searches (`acp`, `permission`, `resume`, `terminal`, `mcp`, `zombie`, `session/update`, `codex-acp`) + 9 bodies |
Not read (owned by other lanes): `Agmente`, `zed`.
**Headline:** Emdash is not "another Electron wrapper." It is the most complete open-source ACP *client* implementation in existence outside Zed — 23 ACP-capable providers, a pure transcript reducer with invariant assertions, a formal session state machine, client-side terminals, a raw ACP log with byte caps, suspend/rematerialize, and an allowlisted env. About 60% of it maps 1:1 onto Swift types. §I is written against it.
---
## B. Emdash deep-dive
### B.1 Topology — one worker process, N agent processes, ports not bridges
Emdash does **not** have a fazm-style "bridge" that re-frames ACP into a private protocol. Electron main forks **one Node child** (`out/main/<acp worker>`) that *is* the ACP client, and that worker spawns agent processes directly.
```
Electron main
└─ fork(desktopWorkerPath('acp')) ← Wire worker, IPC 'advanced' serialization
└─ AcpRuntime → SessionManager → ConversationHandle → SessionCell
└─ ChildAcpProcessHost.spawn(...) ← one process per (providerId, cwd)
├─ electron-as-node claude-acp.mjs (CLAUDE_CODE_EXECUTABLE=<host claude>)
└─ electron-as-node codex-acp.cjs (CODEX_PATH=<host codex>)
```
- Worker entry is three lines: `apps/emdash-desktop/src/main/gateway/entries/acp.ts:1-7` — `runWireComponentWorker(createAcpComponent({ pluginRegistry }))`. The plugin registry is injected by the app so `@emdash/core` never imports `@emdash/plugins` **[src]**.
- The fork itself: `packages/wire/src/worker/node/child-process-spawner.ts:16-25` — `fork(spec.entry, args, { stdio: ['ignore','pipe','pipe','ipc'], serialization: 'advanced' })`. The comment names the reason: structured-clone preserves `undefined`, typed arrays and `Date` across IPC **[src]**.
- Electron caveat, documented as a hard constraint: "Desktop relies on Electron's `child_process.fork` behavior, which runs children with `ELECTRON_RUN_AS_NODE`. The packaged app must keep the `RunAsNode` fuse enabled while this fork model is used. If the app later disables that fuse for macOS hardening, the wire package exposes the Electron `utilityProcessSpawner()` seam." — `agents/architecture/acp-runtime.md:158-163` **[docs]**. *This is exactly the trap a Swift host does not have: Swift spawns Node directly and never fights a fuse.*
- Worker spec (timeouts, dirs): `packages/core/src/runtimes/acp/node/worker-spec.ts:14, 38-60` — `ACP_CONNECTION_IDLE_TTL_MS = 120_000`, session idle `SESSION_IDLE_MS = 60 * 60_000` (`packages/core/src/services/session-lifecycle/api/index.ts:35`), attachments under `userData/acp-attachments`, intents file per host **[src]**.
### B.2 Adapter spawn — the two decisions that matter
`packages/plugins/src/agents/impl/claude/index.ts:140-157`:
```ts
acp: {
buildSpawn: (ctx) => ({
// Run the adapter as plain Node inside the Electron binary.
command: process.execPath,
args: [resolveAdapterAsset(claudeAdapter)],
env: {
ELECTRON_RUN_AS_NODE: '1',
// Point the adapter's Claude Agent SDK at the host-installed claude
// binary instead of the SDK's auto-downloaded native binary.
CLAUDE_CODE_EXECUTABLE: ctx.cli,
},
}),
connect: (io, toClient) => connectStdioAcp(io, toClient),
enrich: enrichClaudeUpdate,
}
```
Codex is the same shape: `packages/plugins/src/agents/impl/codex/index.ts:125-138` sets `ELECTRON_RUN_AS_NODE: '1'` and **`CODEX_PATH: ctx.cli`**.
Three non-obvious things here:
1. **The adapter is bundled as an asset, not resolved from `node_modules` at runtime.** `packages/plugins/src/agents/helpers/adapter-assets.ts:21-38` looks for `<moduleDir>/adapters/claude-acp.mjs` then `<moduleDir>/../adapters/claude-acp.mjs`, and only falls back to `createRequire(...).resolve(specifier)`. Asset definitions: `impl/claude/adapter.ts:3-7` (`@agentclientprotocol/claude-agent-acp/dist/index.js`, format `esm` → `.mjs`) and `impl/codex/adapter.ts:3-8` (`@agentclientprotocol/codex-acp/dist/index.js`, format **`cjs`**, `external: ['@openai/codex']`) **[src]**. The build bundles each adapter into one file; `packages/plugins/src/agents/adapter-manifest.ts:1-4` is the whole manifest.
2. **`@agentclientprotocol/codex-acp` here is a JS/TS package, not Zed's Rust `codex-acp` binary.** fazm shells `@zed-industries/codex-acp-darwin-arm64/bin/codex-acp` (extract §A.2); emdash runs `dist/index.js` under Node with `@openai/codex` kept external and located via `CODEX_PATH`. Two different products with the same name — pick deliberately.
3. **Neither adapter is allowed to download its own runtime binary.** `CLAUDE_CODE_EXECUTABLE` / `CODEX_PATH` point at the user's installed CLI, resolved through a PATH-only host-dependency contract (`agents/integrations/providers.md:62-66`: "Provider plugins declare PATH-only definitions (`binaryNames`, install guidance, and optional update argv). Runtimes ... must not infer package managers, fetch latest versions, or keep a second executable cache.") **[docs]**. Emdash also ships the darwin/linux/win native SDK packages in `ignoredOptionalDependencies` — `package.json:44-58` lists all of `@anthropic-ai/claude-agent-sdk-*` and `@openai/codex-*` as ignored **[src]**. That is a deliberate "never pull the vendored binary" posture.
Actual spawn: `packages/core/src/runtimes/acp/node/node/child-process-host.ts:126-154`
```ts
const child = spawn(launch.executable, launch.args, {
cwd: plan.cwd,
detached: platform !== 'win32', // process-group leader on POSIX
env: spec.env, // NOT {...process.env, ...} — see B.3
stdio: ['pipe','pipe','pipe'],
windowsVerbatimArguments: launch.windowsVerbatimArguments,
});
```
`spawnTerminal` (`:156-184`) is identical except `stdio: ['ignore','pipe','pipe']`.
`connectStdioAcp` is 7 lines — `packages/plugins/src/agents/helpers/acp-stdio.ts:16-22`: `ndJsonStream(Writable.toWeb(io.stdin), Readable.toWeb(io.stdout))` into `new ClientSideConnection(...)`. Nobody hand-rolls JSON-RPC framing.
### B.3 Env hygiene — an allowlist, not a copy of `process.env`
`packages/core/src/primitives/agent-env/api/index.ts` is the single most portable artifact in the repo for a Swift host.
- `AGENT_ENV_VARS` (`:1-121`) is a **121-name explicit allowlist**: `ANTHROPIC_API_KEY/AUTH_TOKEN/BASE_URL/MODEL/DEFAULT_{HAIKU,OPUS,SONNET}_MODEL`, `CLAUDE_CONFIG_DIR`, `CLAUDE_CODE_USE_{BEDROCK,VERTEX}`, `CLAUDE_CODE_SUBAGENT_MODEL`, `CLAUDE_CODE_DISABLE_BACKGROUND_TASKS`, `CODEX_HOME`, `OPENAI_{API_KEY,BASE_URL,MODEL,ORGANIZATION,PROJECT}`, `GEMINI_API_KEY`, `GOOGLE_{API_KEY,APPLICATION_CREDENTIALS,CLOUD_LOCATION,CLOUD_PROJECT,GENAI_API_VERSION,VERTEX_BASE_URL}`, AWS/Azure keys, `HTTP_PROXY/HTTPS_PROXY/NO_PROXY/ALL_PROXY`, `GH_TOKEN`, `XDG_CONFIG_HOME`, plus per-provider homes.
- `buildAllowlistedAgentEnv` (`:175-206`) constructs the child env from scratch: `TERM: 'xterm-256color'`, `COLORTERM: 'truecolor'`, **`TERM_PROGRAM: 'emdash'`**, `HOME`, `USER`, `PATH`, then the three allowlists; `TMPDIR` and `SSH_AUTH_SOCK` are conditionally added; `SHELL` only when `includeShellVar` **[src]**.
- Windows gets case-insensitive key resolution and a canonical-spelling merge (`:219-255`) — a real bug class if a Swift host ever ports to Windows, ignorable for SnappyOS.
- `mergeAgentEnvLayers` (`:257-269`) is how per-call overrides layer on top (used by the terminal port at `agent-ports/terminal-port.ts:27-30`).
Where `PATH` comes from: **a login-shell probe, not the app's inherited env.** `packages/core/src/services/shell-env/node/capture.ts:42-78` runs `spawnSync(shell, ['-ilc', 'env'], { timeout: 5000, maxBuffer: 1MB, detached: true, stdio:['ignore','pipe','pipe'] })` with a `SHELL_ENV_CAPTURE_GUARD` env marker so the probe can't recurse, then parses `KEY=VALUE` lines with `/^[A-Za-z_]\w*$/` key validation. Shell candidates: `$SHELL` → `os.userInfo().shell` → `/bin/bash` → `/bin/sh`, first that `existsSync` (`:87-96`). Note `-ilc` — **interactive** login, so `~/.zshrc` runs too. (fazm deliberately does the opposite: no login shell anywhere in its Swift tree, extract §A.11.)
### B.4 `initialize` and connection pooling
`packages/core/src/runtimes/acp/node/connection/acp-agent-connection.ts:157-166`:
```ts
agent.initialize({
protocolVersion: 1,
clientInfo: { name: 'emdash', version: '1' },
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
terminal: typeof host.spawnTerminal === 'function',
},
});
```
Emdash declares **both** fs methods and terminals — the opposite of fazm, which declines fs (`readTextFile:false, writeTextFile:false`, extract §A.2).
Connection setup, in order (`:59-136`):
1. `host.spawn(...)`; failure → `acpErr.spawnFailed`.
2. stderr is attached to a `logger.debug` sink (`:77-86`) — kept, never scraped for meaning.
3. A teardown closure `handle.kill('SIGTERM')` is registered on the scope **before** anything else can fail (`:88-94`).
4. `onceProcessClosed(handle)` promise is armed (`:138-155`).
5. `behavior.connect(...)` builds the ACP client.
6. **`Promise.race([initializeAgent(...), processClosed.then(failClosedBeforeReady)])`** (`:114-117`) — a die-before-initialize is turned into `ACP agent process exited before initialize completed (code N)` rather than an infinite hang (`:168-173`). *Port this race verbatim.*
7. From the initialize result it caches two capability facts: `supportsLoadSession = agentCapabilities?.loadSession === true` and `mcpCapabilities = { http, sse }` (`:118-122`).
**Pooling:** one agent process per `(providerId, cwd)`, hosting many ACP sessions. `connection/source.ts:75-81` — `makeAcpConnectionKey = \`${providerId}:${nativePathIdentityKey(cwd)}\``; `createResourceCache` with `idleTtlMs` (`:56-73`), generation counter `++nextGeneration` per provision (`:64`). Route ownership is `\`${key}:${generation}\`` (`runtime/session-router.ts:144-146`) so a *replacement* process can never be mistaken for its predecessor. Comment in the architecture doc: "Cache identity includes provider, workspace, and cwd; the process route id stays provider/workspace and can host multiple ACP sessions" (`agents/architecture/acp-runtime.md:38-40`) **[docs]**.
### B.5 The fold — decode → enrich → reduce → item-fold
This is a **four-stage pure pipeline**, and the cleanest ACP-to-UI mapping available anywhere.
**Stage 1: decode (stateless).** `packages/core/src/runtimes/acp/api/reducer/decode.ts:94-211` maps the raw `SessionUpdate` union to an internal `NormalizedEvent`. Notable:
- Empty/non-text content is dropped: `if (update.content.type !== 'text' || !update.content.text) return { kind: 'ignored' }` (`:97, :107, :117`).
- `messageId` is preserved *as `null` when absent* — "Preserves missing message ids for the stateful reducer to segment" (`:12`).
- Tool output text extraction handles **both wire shapes**: `{type:'content', content:{type:'text'}}` (ACP-wrapped) and bare `{type:'text'}` (`:54-69`), recursing through nested `content` (`:33-46`), then `stripSingleCodeFence` (`:48-52`) removes a lone ```` ``` ```` wrapper.
- Diffs come out of `ToolCallContent[]` as `{path, oldText|null, newText}` (`:20-31`).
- `terminalId` and `inputSummary` are read in **both camelCase and snake_case** (`:71-88`) — defensive against adapter drift.
- `plan_update` / `plan_removed` are explicitly ignored with a reason: "UNSTABLE/ID-based ACP variants gated behind PlanCapabilities — not emitted by Claude" (`:207-209`).
**Stage 2: enrich (per provider).** `connection/acp-agent-connection.ts:98-101` — `behavior.enrich?.(decodeSessionUpdate(raw), raw)`. Only Claude has one. `packages/plugins/src/agents/impl/claude/acp-transform.ts`:
- Promotes `_meta.claudeCode.parentToolUseId` → first-class `parentToolCallId` so "downstream consumers never need to know about `claudeCode`" (`:4-16, :37-42`).
- Drops harness noise: a user message starting `<local-command-` or containing `<command-name>` becomes `{kind:'ignored'}` (`:20, :165-167`).
- Parses `<task-notification>` XML in *user* messages into a `subagent_update` event with `task-id`, `tool-use-id`, `status`, `output-file`, `summary` (`:169-186`).
- Detects Claude's async subagent launch two ways: structured `_meta.claudeCode.toolResponse.{isAsync:true, status:'async_launched', agentId}` **and** a text fallback matching `Async agent launched successfully.` + `/^agentId:\s+(\S+)/m` (`:103-135`). Belt and braces because the structured field is newer.
- Rewrites `toolName === 'Agent'` tool calls into a dedicated `subagent` event kind (`:48-61`).
**Stage 3: reducer (turn boundaries + slices).** `reducer.ts` is pure and total.
- State is one composite: `{ transcript:{committed[],active}, config, usage, title, pendingModeId, segment, agents[], plan }` (`:63-72`).
- **Turn boundary rules are stated as a header contract** (`:18-23`): OPEN implicit on a new user message; OPEN lazy when agent content arrives with no active turn; CLOSE explicit on `turn_end`/`replay_end`.
- Lazy open matters: `if (!t.active) { t = openTurn(t, deps, 'agent'); }` (`:536-539`) with `initiator: 'agent'` — agent-initiated background activity gets its own turn. Compare §G: claude-agent-acp #864 is exactly the failure this defends against.
- **Missing `messageId` is solved by synthesized segments, not by concatenation.** `SegmentState = { open, user, assistant, thinking }` counters (`:51-56`); `synthesizedMessageId` → `auto:<stream>:<n>` (`:181-184`); switching stream kind closes the open segment and bumps its counter (`:186-221, :254-268`). So an adapter that never sends `messageId` still produces stable, distinct message rows.
- Claude reuses one `messageId` across multiple *thinking* blocks; `resolveProviderThinkingMessageId` (`:223-246`) reopens the still-`thinking` segment if one exists, otherwise mints `<messageId>:segment:<n>`.
- `pendingModeId` (`:473-489`) buffers a `current_mode_update` that arrives *before* `config_option_update` has delivered the mode catalog — the late-arrival rescue fazm needed too (extract §A.12).
- Dev-mode invariants throw: duplicate item id, unsorted sibling `seq`, duplicate sibling `seq`, more than one open thinking row, committed turns out of order (`:379-437`). Skipped when `NODE_ENV === 'production'` (`:422`). *This is how you find fold bugs; port the assertions.*
**Stage 4: item-fold (the merge rules).** `reducer/item-fold.ts`.
- ACP status → UI status: `pending|in_progress → 'running'`, `completed → 'done'`, `failed → 'error'`, anything else → `undefined` meaning "leave unchanged" (`:44-56`). The `undefined` case is the merge rule: a `tool_call_update` with no status must not clobber the existing one (`:219-225`).
- Tool *kind* → typed row, by string matching that is deliberately generous (`:66-96`): subagent = `subagent|task|agent`; search = `search|grep`; read = `read|read_file` **or a title starting `"Read "`**; edit = `edit|write|apply_patch`; execute = `execute|terminal|bash`; mcp = `mcp-tool|mcp_tool`; fetch = `web-fetch|web_fetch|fetch`. Unmatched → `unknown-tool-call` carrying the raw `toolKind` (`:209`).
- **Diffs replace the tool row entirely.** A `tool_call`/`tool_call_update` carrying `diffs` never creates a generic tool item — it upserts one `create-file-tool-call` (when `oldText === null`) or `modify-file-tool-call` per changed path, id `\`${toolId}:${path}\`` (`makeDiffId`, `ids.ts:73-75`; fold `:365-435`, `:667-682`, `:706-717`). An `edit`-kind tool call with *no* diffs yet is dropped outright (`:684`) so a bare "Edit" row never flashes before its diff arrives.
- A later `tool_call_update` for a tool that already produced file ops updates only those file-op statuses and adds nothing (`:731-732`, `updateFileOperationStatuses :438-455`).
- **`tool_call_update` for an unknown `toolCallId` synthesizes the row** (`:736-749`, title fallback `'unknown'`) — updates are never dropped for arriving first.
- Any content-bearing event auto-closes an open thinking row with a computed `durationMs` (`finalizeOpenThinking :349-363`, called at `:315, :664, :670, :705, :760, :765`).
- **Read-batch grouping is positional and automatic**: two or more *consecutive* `read-tool-call` siblings collapse into `{kind:'tool-group', label:'N file reads', groupKind:'read-batch'}` whose status is `running` if any child runs, else `error` if any errored, else `done` (`wrapReadGroups :516-549`, `readGroupStatus :510-514`).
- Nesting is rebuilt from scratch on every fold: `flattenItems` strips children and sorts by `seq` (`:113-129`), `buildTree` re-parents via `parentToolCallId` and re-wraps read groups at every level (`:551-592`), `normalizeToolStructure` = flatten ∘ buildTree (`:594-596`). Expensive but always correct, and it makes late-arriving parents work.
- Ids are deterministic and turn-scoped: `${conversationId}:turn:${i}` / `${turnId}:message:${messageId}` / `${turnId}:thinking:${messageId}` / `${turnId}:tool:${toolCallId}` / `${toolId}:${path}` / `${turnId}:plan` (`ids.ts:18-82`). The thinking id carries a kind prefix because "Claude reuses the same messageId across both update kinds" (`ids.ts:36-38`).
- `finalizeItems` on turn commit settles everything: thinking → done + duration, every `running` tool → `done`, groups recomputed (`:785-824`).
**Where session-level slices go instead of the transcript** (`reducer.ts:469-519`): `config_option_update` → `deriveConfigGroups`; `current_mode_update` → mode selection; `available_commands_update` → `availableCommands`; `usage_update` → `{contextUsed, contextSize, cost}`; `session_info_update` → title. `config-derive.ts:57-104` maps ACP `SessionConfigOption.category` → typed groups: `'model'`, `'thought_level'` → efforts, `'mode'` → **permission mode**, `'collaboration_mode'` → a *separate* Default/Plan selector; unknown categories (e.g. Claude's `model_config` fast-mode toggle) are silently ignored as an extension point (`:102`). Providers doc confirms the split: "Codex ACP exposes collaboration mode separately from permission mode ... filesystem and approval controls remain in the existing permission-mode selector" (`agents/integrations/providers.md:76-79`) **[docs]**.
### B.6 Tool-call UI
Model: `packages/core/src/runtimes/acp/api/models/turns/tool-calls.ts:5-92` — 11 typed tool rows plus `tool-group`, all extending `{id, seq, toolCallId, title, status, inputSummary?, parentToolCallId?, children?}`. Each typed row carries exactly the fields its renderer needs (`execute` → `command/outputText/terminalId`; `modify-file` → `oldText/newText/path`; `mcp` → `server/tool`; `spawn-subagent` → `name/background/agentId`).
Renderers, one directory per row kind: `packages/chat-ui/src/components/rows/tools/{tool,execute,diff,file-op,subagent,tool-group}`.
- `tools/tool/tool.def.tsx:10-46` maps a `ToolNode` to a one-line chip: display name by kind (`Search`/`MCP`/`Fetch`/`Subagent`/raw `name`/group `label`), and an `inputSummary` that is kind-specific — search shows `"<query> (N matches)"`, MCP shows `"<server>.<tool>"`, fetch shows `pageTitle ?? url`, subagent shows `"<name> (background)"`.
- Every row carries `awaitingPermission: ctx.pendingToolCallIds().has(toolCallId)` (`tool.def.tsx:43`, `execute.presenter.ts:34`). **The pending-permission state is rendered on the tool row itself**, not only in a modal.
- `execute.presenter.ts:22-37` prefers **live terminal output** (`ctx.terminalOutput(item.terminalId)`) over the static `outputText`, and memoizes the line split in a `WeakMap` keyed by node identity so streaming re-renders stay cheap (`:11-20`).
- Rows are fixed-height measured units in a custom layout engine (`tool.def.tsx:48-63`, `measure() → vars.rowH`) — expansion is a height tween, not a reflow.
- Icons that exist: `IconTerminal`, `IconPlanList`, `IconShieldAlert`, `IconError`, `IconStop`, `PlanPending/InProgress/Completed` (`packages/chat-ui/src/components/primitives/icons/`).
### B.7 Permissions — no auto-approve on the ACP path at all
This is the sharpest contrast with fazm.
- **Wire in:** `agent-ports/agent-client.ts:57-59` → `router.onPermissionRequest(connection, params)` → `SessionRouter` resolves the conversation (`session-router.ts:58-68`) → `SessionManager.handlePermissionRequest` (`session-manager.ts:~613-625`) → `record.cell.requestPermission(params)`.
- **`SessionCell.requestPermission`** (`session/cell.ts:305-323`) mints its own `requestId = crypto.randomUUID()`, snapshots the *typed tool call* that triggered it, records a `permission_request` raw-log entry, dispatches `PermissionRequested` into the state machine, and returns `this.permissions.request(request)` — **a promise that is only settled by a user action**.
- **`buildPermissionToolCall`** (`cell.ts:669-690`) is the nice bit: it looks up the already-rendered tool row for that `toolCallId` in the active turn (recursing into `children` and `tool-group`s, `findToolCall :697-713`) and returns a `structuredClone` of it. If not found, it synthesizes a row via the same `createToolCallItem` factory the fold uses. **The permission prompt and the transcript row are the same object shape** — one renderer, no drift.
- **`PermissionBroker`** (`session/permission-broker.ts:6-36`) is 30 lines: a `Map<requestId, resolve>`; `settle → {outcome:{outcome:'selected', optionId}}`; `cancel → {outcome:{outcome:'cancelled'}}`; `drain(pending)` cancels all.
- **There is no timeout.** No `APPROVAL_TIMEOUT_MS`. A request sits pending until the user answers or the session tears down. `SessionCell.dispose()` (`:405-409`) calls `permissions.drain(machine.pendingPermissions)`, and the router answers `{outcome:'cancelled'}` for any request whose conversation can't be resolved (`session-router.ts:66`).
- **Pending permissions are session state, not modal state.** `models/session.ts:40` — `pendingPermissions: AcpPermissionRequest[]` lives on the published `sessionState` LiveModel, so every surface (composer band, sidebar badge `pendingPermissionCount` at `models/session.ts:70`, tool row `awaitingPermission`) reads one source.
- **`resolvePermission` validates first** (`cell.ts:286-303`): unknown `requestId` → `invalidState` error; machine dispatch; raw-log `permission_resolved`; then broker settle. `decide()` in the machine likewise rejects unknown ids (`machine/machine.ts:161-165`).
- **UI**: `packages/ui/src/react/components/chat-composer/permission-band.tsx` — a band docked flush above the composer, not a dialog. Tone from `PermissionOption.kind` prefix: `allow_* → accept`, `reject_* → reject`, else neutral (`:48-52`). Default selection: `allow_once` → any `allow_*` → first option (`:54-60`). It renders a `SplitButton` (primary action + menu) so one click is the common path, and shows `"(1 of N)"` when more are queued (`:95-99`). Selection resets on `requestId` change only, deliberately not on options-array identity (`:83-86`).
- **No "always allow" persistence anywhere.** `allow_always` is just another option forwarded to the agent; emdash never remembers a decision itself. Grep for `autoApprove` across the repo returns only the **PTY/TUI launch path** — `packages/core/src/services/agent-plugins/api/plugins/helpers/standard-command.ts:123-124` appends `spec.autoApproveFlag` to argv, which is `--dangerously-skip-permissions` for Claude (`impl/claude/index.ts:164`) and `-c approval_policy="never" -c sandbox_mode="danger-full-access" --dangerously-bypass-hook-trust` for Codex (`impl/codex/index.ts:152-153`). **In ACP mode there is no bypass; risk appetite is expressed only by choosing an ACP session mode** (the `'mode'` config category, §B.5). A user asking for mid-session bypass is an open feature request — **[issue]** emdash #1671.
- **Trust is pre-seeded, separately from permissions.** `impl/claude/trust.ts:7-36` writes `projects[<workspacePath>] = { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true }` into `.claude.json`, and skips the write when both flags are already true. Copilot and Cursor have equivalents (`impl/{copilot,cursor}/trust.ts`). Without this the agent blocks on its own trust dialog, invisible to ACP.
### B.8 Terminals — emdash implements the client side, fully
Declared in `initialize` (`acp-agent-connection.ts:163`) and implemented in `agent-ports/`:
- `TerminalPort` (`terminal-port.ts:22-71`) implements `createTerminal`/`terminalOutput`/`waitForTerminalExit`/`killTerminal`/`releaseTerminal`. `createTerminal` merges the ACP-supplied `env` pairs over the allowlisted platform env (`:27-30`) and defaults `cwd` to the session cwd (`:35`). Unknown `terminalId` throws `AcpRuntime: terminal not found: <id>` rather than returning empty (`:43, :56, :63`).
- `ManagedAgentTerminal` (`managed-terminal.ts:15-121`): **4 MB default output cap** (`:6`), a **ring buffer that discards oldest chunks and sets `truncated`** (`:65-72`), and a `StringDecoder('utf8')` so "multibyte UTF-8 sequences are never split across chunk boundaries" (`:8-16, :59`). `waitForExit` resolves immediately if already exited, else queues a waiter (`:102-105`).
- `snapshot()` deliberately returns **metadata only** — "no output text (see terminalStateSchema)" (`:78-88`) — while `outputSnapshot()` joins the ring only for the agent-facing `terminal/output` call (`:90-100`).
- Output reaches the UI through a **separate capped log channel**, never through the session model: `TerminalLiveRegistry` (`runtime/terminal-live-registry.ts:10-52`) keeps one `LiveLogSource` per terminal and republishes the *conversation* only on lifecycle transitions — "create, exit, release, and the first truncation — never per output chunk" (`:21`). The contract exposes it as `terminalOutput: liveLog({ key: { terminalId } })` (`api/contract.ts:170`).
- No PTY: terminals are plain `spawn` with `stdio:['ignore','pipe','pipe']` and `detached` (`child-process-host.ts:156-184`). Emdash *does* have node-pty, but only for its separate TUI-agent runtime, not for ACP terminals.
- The transcript links to it: `execute-tool-call.terminalId` (`tool-calls.ts:20`), read back live in `execute.presenter.ts:23`.
### B.9 FS methods
`agent-ports/fs-port.ts:12-24` + `fs-text.ts:5-19`. Two rules worth stealing: read errors are re-thrown wrapped with the path (`readTextFile failed for <path>: <msg>`), and **write creates parent directories** (`mkdir(dirname(path), {recursive:true})`) before writing. No sandbox check, no path allowlist — the agent's cwd is trusted.
### B.10 Sessions: create, resume, suspend, list, persist
**Materialization** (`runtime/session-materializer.ts:55-201`) is the resume story:
1. Acquire a pooled connection for `(providerId, cwd, env)` (`:65-77`); register a scope teardown that releases the lease (`:78-81`).
2. Read MCP servers and convert them (`:87-88`).
3. **If `input.sessionId` and `connection.supportsLoadSession` and `agent.loadSession` exists** → serialize the handshake per process (`acquireHandshake :203-228`, a promise-chain mutex keyed by `processOwner`), register a *provisional* route (`beginLoad`), `cell.beginReplay()`, `loadSession({cwd, sessionId, mcpServers})`, then `applySessionLoaded(modes, configOptions)`, apply desired config, queue initial prompts, `cell.endReplay()`, `resumeOutcome = 'loaded'` (`:94-133`).
4. **On any `loadSession` failure that is not auth-required: log and fall through to `newSession`** — "SessionMaterializer: loadSession failed, starting a new session" (`:139-141`), discarding the provisional record (`:147-150`). `resumeOutcome` then reports `'replaced-by-new'` (`:91`). Compare emdash **[issue]** #1229 ("dropped back to a bare shell") — that was the PTY path; the ACP path never does that.
5. Auth-required is detected structurally, recursing through `cause`: `code === -32000` (`isAuthRequiredError :423-428`).
6. Everything is `abortable(promise, signal)` (`:430-437`) and re-checks `callbacks.isCurrent(entry, epoch)` after every await (`:82-84, :121-123, :161-169`) so a session killed mid-handshake can't resurrect.
7. `applyDesiredConfiguration` (`:341-355`) loops **until `entry.desiredRevision` stops changing** — the user can change model while the session is materializing and the last write wins.
8. A retained model/mode/effort that the provider no longer advertises is **cleared, not sent**: `applyConfigOverrides :305-339`, `applyInitialMode :377-405` ("persisted mode not advertised, skipping").
**Routing** (`runtime/session-router.ts`): `Map<processOwner, Map<acpSessionId, conversationId>>` (`:33`) plus a single `loadingConversationByOwner` slot (`:34`). `resolveConversationForSession` (`:134-141`) falls back to the pending-load conversation and *registers* the id it just saw — this is how a provider that **rebinds the session id during `session/load`** still routes. `beginLoad` throws if a load is already active for that process (`:103-105`) — hence the handshake mutex above. `invalidate(processOwner)` on process close drops every route for that generation (`:129-132`).
**Suspend / rematerialize** (`agents/architecture/acp-runtime.md:121-147`) **[docs]**: a conversation keeps a wake descriptor and a "retained presentation" after its live `SessionCell` is evicted; suspended projections "keep controls visible and prompt submission enabled while clearing activation-local queues, permissions, terminals, active turns, plans, and agents." On worker boot "every valid persisted intent is restored only as a lightweight suspended index row; **the worker never starts a provider from disk**." Only `loadHistory`, `sendPrompt` and headless `launch` wake one; mode/model changes persist without waking.
**What is persisted** — `runtime/session-intent-schemas.ts:24-32`, versioned `'1'`: `{conversationId, providerId, cwd, sessionId|null, configured:{model,modeId,effort,collaborationMode}, presentation:{lastKnownCapabilities, lastKnownMcpServers, lastKnownUsage, observedAt}}`. The doc states the exclusion explicitly: "Provider environment, MCP credentials, runtime endpoints, and unknown descriptor fields are never persisted" (`acp-runtime.md:113-115`). Legacy blobs are migrated through a restricted schema (`:34-42`).
**Idle policy:** sessions idle out after **60 min without output** (`services/session-lifecycle/api/index.ts:35`, applied `worker-spec.ts:55`), swept every 60 s (`session-lifecycle/node/session-lifecycle.ts:111`); idle *connections* are reclaimed after **2 min** (`worker-spec.ts:14`).
**History:** `loadHistory` is a paged read that "returns a successful page marked `unavailable: true`" when the provider can't replay, so "callers retain their existing transcript instead of replacing it with an empty one" (`acp-runtime.md:143-146`) **[docs]**.
### B.11 Cancel, close, kill, orphans
Three distinct verbs, all present:
- **Cancel a turn** — `cell.cancel()` (`cell.ts:268-279`): machine dispatch first (rejects when nothing is cancellable, `machine.ts:137-141`), then `agent.cancel({sessionId})`. Nothing is force-killed.
- **Close a session** — `cell.closeSession()` (`:281-284`), guarded by `if (!this.deps.agent.closeSession) return` since it is optional in the SDK.
- **Teardown** — `SessionManager.interruptRecord` (`session-manager.ts:~869-901`) fires `cancel()` **and** `closeSession()` concurrently, each with its own `.catch` that only warns.
- **Process death** — `onProcessClosed(processKey, generation, exitCode)` (`session-manager.ts:~634-657`): invalidate the router generation, then for every record on that exact `(key, generation)`: set `connectionLeaseState.release = false` (don't double-release a dead lease), `cell.processClosed(exitCode)`, `stop(conversationId, 'process-exited')`, and invalidate the connection cache entry.
- **Kill** — `ProcessTreeTerminator` (`primitives/exec/node/process-tree-terminator.ts:34-142`). POSIX: `process.kill(-pid, SIGTERM)` **plus** `child.kill(SIGTERM)`, wait up to `graceMs = 1000` polling every 20 ms with `process.kill(-pid, 0)` for group liveness, then `SIGKILL` and wait again (`:64-77, :106-141`). Idempotent — `terminate()` memoizes its own promise (`:53-62`). Windows uses `taskkill /PID n /T [/F]` with an argv array "so no user-controlled text is interpreted by a shell" (`:27-33, :93-104`), and re-checks the original child before re-targeting the numeric PID "to avoid targeting a reused PID" (`:85-88`).
- **No orphan sweeper.** Emdash has nothing like fazm's `sweepOrphanedBridges()`. It relies on `detached:true` + group kill + the Electron parent outliving children. **[issue]** emdash #2153 is the receipt for that gap: 57 direct children, ~240 descendants, **21.1 GB RSS**, "43 gemini launcher processes, 6 codex launcher processes" — though that report is against the PTY/TUI path, not ACP. **[issue]** #2580 is the SSH analogue (12 orphaned `claude` processes on a remote host). And on the adapter side, **[issue]** claude-agent-acp #1011 documents *the agent itself* leaking `claude` children across repeated `session/load` on one long-lived process.
- ADR 0004 is titled "cancellation is best-effort across planes" (`docs/adr/0004-cancellation-is-best-effort-across-planes.md`) **[docs]** — the same conclusion fazm reached empirically.
### B.12 The session state machine
`packages/core/src/runtimes/acp/node/machine/machine.ts` — a decide/evolve (command → events → state+effects) machine, pure and unit-tested.
- Phases: `starting | replaying | ready | working | cancelling | closed` (`:15-21`), documented one-line each at `api/models/session.ts:14-31`.
- Commands (`:73-82`): `Prompt, QueuePrompt, Cancel, EditQueuedPrompt, RemoveQueuedPrompt, ReorderQueue, ResolvePermission, SetMode, SetConfigOption`.
- Effects (`:104-112`): `state, permissionRequest, permissionResolved, closed, agentEvent, settleAgents, sendPrompt, warn`.
- **Prompting while busy queues instead of failing** (`:120-132`): if phase is `working`/`cancelling`, or `agentTurnActive`, or `backgroundAgentCount > 0` → `PromptQueued`. Queued prompts are first-class API objects (edit, delete, reorder — `api/contract.ts:94-105`).
- `SetMode`/`SetConfigOption` are validated against the provider's advertised catalogs before any RPC (`:167-183`, context built at `cell.ts:609-622`).
- The machine publishes three derived UI booleans so the renderer never re-derives them: `isGenerating`, `canSubmit`, `canCancel` (`api/models/session.ts:51-56`).
- **Quiescence**: agent-originated transcript events while `phase === 'ready'` flip `AgentActivity{active:true}` and arm a **250 ms** debounce; on expiry the turn is settled with reason `'quiesced'` (`cell.ts:592-607, 652-658`). That is emdash's answer to "updates keep arriving after `session/prompt` resolved" (**[issue]** claude-agent-acp #864).
- Transcript events arriving in an impossible phase are **dropped with a warning**, not folded (`cell.ts:194-200, 660-667`).
- Turn outcomes are a richer union than ACP's `stopReason`: `done{end_turn|max_tokens|max_turn_requests|refusal|quiesced} | cancelled | error{prompt_failed|process_closed|spawn_failed|initialize_failed|new_session_failed|load_session_failed|cancel_failed|set_config_failed|set_mode_failed} | interrupted{process_closed|replaced}` (`api/models/turns/turn.ts:17-49`).
### B.13 Prompts and attachments
`cell.ts:502-532`. The prompt array is assembled as: **images first, then text, then hidden context**:
```ts
prompt: [
...resolvedAttachments.map(a => ({ type:'image', data: a.data, mimeType: a.mimeType })),
...(prompt.text ? [{ type:'text', text: prompt.text }] : []),
...(prompt.hiddenContext ? [{ type:'text', text: prompt.hiddenContext }] : []),
]
```
Flat `{type:'image', data, mimeType}` — same shape fazm had to discover the hard way (extract §A.4). Attachments are refs on the wire and only resolved to bytes at send time (`deps.resolveAttachment`, `:503-507`); the user echo is pushed into the transcript *before* the RPC with `messageId = \`${conversationId}-${turnIndex}-user\`` (`:484-499`). `hiddenContext` is a first-class second text block — that is where a host injects context the user shouldn't see echoed.
### B.14 MCP injection
`runtime/mcp-servers.ts:37-71`. Shapes emdash sends in `session/new.mcpServers`:
- stdio: `{name, command, args, env: [{name,value}]}` — **env is an array of pairs, not an object** (`:65-70`, `recordToPairs :85-87`).
- http/sse: `{type:'http'|'sse', name, url, headers:[{name,value}]}` (`:44-62`).
- **Transport is gated on the agent's advertised capability**: an http server is dropped when `!capabilities.http`, sse when `!capabilities.sse` (`:45, :55`), where those come from `initialize`'s `agentCapabilities.mcpCapabilities` (`acp-agent-connection.ts:119-122`).
- `enabled === false` registrations are skipped (`:41`); transport is inferred when unspecified — a registration with a `url` and no `command` is http (`resolveTransport :73-83`).
- MCP read failures are non-fatal: log and send `[]` (`session-materializer.ts:357-375`).
- The per-session summary `{name, transport}` is published to the UI (`summarizeAcpMcpServers :28-35`; contract `session.mcpServers` live state at `api/contract.ts:167`).
- Provider-side MCP config is also *synchronized to the CLI's own config file* by a separate adapter — `passthroughMcpAdapter('.claude.json')` for Claude (`impl/claude/index.ts:172`), `codexMcpAdapter()` for Codex (`impl/codex/index.ts:164`), both declared `scope:'global'`, transports `['stdio','http']` (`index.ts:121-125` / `:109-113`).
### B.15 Multi-agent, auth, quota
- **23 ACP-capable providers** out of 36 registered (`agents/integrations/providers.md:10-16`) **[docs]**; registry at `packages/plugins/src/agents/registry.ts:44-85`. Adding one is a documented 5-step recipe (`providers.md:91-99`), step 2 of which is "update allowlisted agent env vars".
- **Notably absent: Gemini.** No `impl/gemini`. Emdash ships `antigravity`, `jules`, `qwen` but not Gemini CLI — for a host targeting `gemini --acp`, emdash offers no precedent and fazm's recipe (extract §A.2: `GEMINI_CLI_TRUST_WORKSPACE=true`, mandatory `authenticate`) remains the only citation.
- **Auth is status-detection, not a login flow the host implements.** Claude: `ANTHROPIC_API_KEY` present → authenticated; else `claude auth status` with a 5 s timeout, parse JSON stdout for `email|account|accountEmail|oauthAccount.emailAddress`, exit-code-1 + a logged-out pattern → `unauthenticated`, anything else → **`unknown`** (`impl/claude/auth.ts:17-33, 41-56`; regex `:8`). Codex: `OPENAI_API_KEY` or `codex login status` matched against `/authenticated|logged in|signed in/i` vs `/not authenticated|.../i` (`impl/codex/index.ts:140-147`, helper `helpers/auth.ts:15-46`).
- **Login is delegated to the CLI in a terminal**, declared as capability data: Claude `{kind:'cli-login', args:['auth','login']}` + `{kind:'api-key', envVars:[ANTHROPIC_API_KEY]}` (`impl/claude/index.ts:33-52`); Codex `{kind:'cli-login', args:['login','--device-auth']}` (`impl/codex/index.ts:37-42`). **Emdash never runs its own OAuth PKCE flow and never touches the Keychain** — the opposite of fazm (extract §A.9). For a Swift host this is the lower-risk posture.
- **Quota/rate limits: nothing.** No rate-limit surface on the ACP path. `usage_update` gives `contextUsed/contextSize/cost` (`decode.ts:186-199`) and that is all the UI shows. Codex's `RateLimitSnapshot` never crosses ACP at all — **[issue]** codex-acp #227.
- **Hooks are the out-of-band status channel.** Claude gets `UserPromptSubmit`/`Notification`/`Stop` hooks written into `settings.json` under `$CLAUDE_CONFIG_DIR` (`impl/claude/hooks.ts:39-48`), and because Claude's Notification payload has no type field, emdash classifies by regex: `/permission|approval/i` → `permission_prompt`, else `idle_prompt` (`:12-36`). The providers doc states the principle: "Emdash does not infer agent status from terminal output" (`providers.md:27-30`) **[docs]**. Users pushed back on the file writes — **[issue]** #1944.
### B.16 The debug surface emdash already has
`session/raw-log.ts` — worth copying wholesale.
- Event union (`:14-50`): `session_update{sessionId, update}`, `prompt{sessionId, content}`, `prompt_result{sessionId, stopReason}`, `permission_request{sessionId, request}`, `permission_resolved{sessionId, requestId, optionId}`.
- Entry: `{seq, ts, event}` (`:52-56`).
- **Dual caps**: 50 000 entries **and** 16 MB, "so a chatty session (large tool results, big prompts) cannot grow the in-memory log without bound" (`:58-61`). Per-entry byte cost is measured once at record time and tracked incrementally (`:92-104`); eviction shifts from the head while over either cap but always keeps ≥1 entry (`:106-114`).
- Export is `{meta:{conversationId, providerId, acpSessionId, createdAt, generatedAt}, events[]}` (`:116-128`), exposed as `exportRawAcpLog` on the contract (`api/contract.ts:123-127`) alongside `exportAcpTranscript` (`:118-122`).
- Both exports are **activation-local reads that never wake a suspended session** — "a post-wake export would describe the replay rather than the evicted process" (`acp-runtime.md:117-119`) **[docs]** — and the log is deliberately "fixture-compatible", recorded before normalization (`raw-log.ts:68-71`); the repo ships captured transcripts as fixtures (`impl/{claude,codex}/fixtures/acp-transcript.json` + `__snapshots__/acp-fixture.test.ts.snap`, driver `acp-fixture-driver.ts`). **Record real traffic once, snapshot the fold forever.**
### B.17 The public API surface (a ready-made Swift protocol)
`packages/core/src/runtimes/acp/api/contract.ts:70-171`. Commands: `attach, launch, terminate, sendPrompt, editQueuedPrompt, deleteQueuedPrompt, changeQueuePromptOrder, cancelTurn, setOption, resolvePermission, exportAcpTranscript, exportRawAcpLog, uploadAttachment, downloadAttachment, deleteAttachment, purgeConversationData, loadHistory`. Live models: `sessions.list` (map of `SessionSummary`), and per-conversation `session.{state, config, usage, plan, agents, activeTurn, terminals, mcpServers}` plus `terminalOutput` as a keyed log.
Two design rules stated in the architecture doc and visible in the contract: **"The public API describes user intent instead of exposing lifecycle choreography ... there is no public `ensureActivation`, `start`, or `resume` procedure"** (`acp-runtime.md:104-111`), and **"The public identity is always `conversationId`; provider process activations are internal"** (`:123`) **[docs]**. Both are directly applicable to a Swift host's actor API.
---
## C. Emdash lessons from the tracker
No CHANGELOG file exists in-repo (`find . -iname CHANGELOG*` → none outside `node_modules`); the durable design record is `docs/adr/0001..0007` + `agents/architecture/*.md`. Issue lessons:
| # | State | Lesson for a Swift host |
|---|---|---|
| **#210** | CLOSED | "[feat]: Integrate Agent Client Protocol (ACP)" — the tracking issue. Everything in §B postdates it; ACP was retrofitted onto a PTY-first product, which is why the two paths (auto-approve flags vs permission band) still differ. |
| **#2153** | CLOSED | Orphan/memory blowup: 57 direct children, ~240 descendants, **21.1 GB RSS**, 43 stray gemini launchers. Group-kill alone is not enough at scale — budget for a startup orphan sweep. |
| **#2580** | CLOSED | Detached sessions were never reaped on conversation close → 12 live `claude` processes on a remote host after days. "Preserve session on close" and "reap process on close" are different requirements; decide both explicitly. |
| **#1716** | CLOSED | `Error: Session ID <uuid> is already in use` on resume after restart. Reusing a deterministic session id without proving the previous holder is dead is a live bug class. |
| **#1229** | CLOSED | `No conversation found with session ID` → user dropped to a bare shell. Fix shape = detect resume failure, fall back to a fresh session, tell the user. Emdash's ACP materializer implements exactly that (`session-materializer.ts:139-150`). |
| **#3093** | CLOSED | Restored tabs render stale content but silently discard input. "Selecting a restored tab should rehydrate/resume; **if recovery fails, the UI should expose the failure** instead of presenting an apparently interactive terminal." |
| **#531** | CLOSED | A spinner for a pending approval reads as "busy", not "blocked on you". Ship a distinct pending-permission affordance + count badge. Emdash now does: `awaitingPermission` per row + `pendingPermissionCount` per session. |
| **#1671** | OPEN | Users want to escalate to skip-permissions **mid-session**. Under ACP that is `session/set_mode` to a bypass mode — plan the control, don't require a restart. |
| **#1944** | CLOSED | Writing hook configs into user-owned files (`.claude/settings.local.json`, `.codex/config.toml`) with no opt-out is resented. If you write trust or hook config, make it visible and switchable. |
| **#1703** | CLOSED | Windows: spawning the extensionless npm shim → `error 193`. Detection succeeded via `cmd.exe` while spawn failed — **detecting a binary is not the same as being able to exec it**. |
| **#3070** | OPEN | 1.2.x main process burns ~0.8 core at idle in libuv stream reads. High-rate stdio between host and agents has a real idle cost; batch/coalesce. |
| **#2985** | OPEN | Claude Code TUI renders corrupted inside emdash. A reason to prefer ACP over hosting a TUI in a terminal emulator. |
| **#1678** | OPEN | "more than one agent config per provider" — the (providerId, cwd) connection key is already a de-facto constraint; multiple configs per provider needs a richer key. |
---
## D. Gold Band — the Rust/Tauri, local-first, file-backed client
`diodeme/Gold-Band` v0.14.1, AGPL-3.0. Read at `9f9247c`. All paths below are relative to the repo root.
### D.1 Stack
- Two crates: root `gold-band` (all logic, `Cargo.toml:1-9`) and `src-tauri/gold-band-desktop` (shell). Rust **edition 2024**.
- **`agent-client-protocol-schema = "1.6.0"` with feature `unstable_elicitation`** — `Cargo.toml:12`. Note: the *schema* crate only. Gold Band hand-rolls the JSON-RPC transport rather than using the `agent-client-protocol` client runtime (contrast Jockey, which uses the full crate).
- Tauri 2 + plugins dialog/opener/single-instance/updater; `rusqlite` (bundled) for search indexing; `minijinja` for prompt templates; `cron` for scheduling; `command-group 5.0.1` for process groups — `Cargo.toml:19`, `src-tauri/Cargo.toml:12-52`.
- The ACP layer is **37 219 lines of Rust across 17 files** in `src/acp/` — `client.rs` alone is 11 845 lines, `events.rs` 6 349, `timeline.rs` 3 991, `connection.rs` 3 205, `branches.rs` 2 824. It is by far the largest ACP client implementation in this survey.
- Frontend is a React/Vite app under `web/` with a dedicated `web/src/components/acp/` and a **themable design system** (`theme-sdk/`, `themes/`, `resources/themes/`) — the theming layer is unique to Gold Band among these four.
- Product framing (`README.md:31-58`): "connects to local Agents such as Claude Code and Codex through Agent Client Protocol (ACP)", with three execution modes — **Direct Agent**, **WORKFLOW** (fixed graph), and **AUTO / AI-DYNAMIC** (LLM-decomposed) — plus "Runtime observability: inspect Agent messages, tool calls, system prompts, **raw frames**, tokens, duration, and runtime state."
### D.2 Agent spawn — an embedded registry snapshot, then npx
- **The agent catalog is a compiled-in JSON snapshot of the official ACP registry**: `include_str!("../resources/agent-catalog.json")` parsed once into a `OnceLock` (`src/agent_catalog.rs:48-54`), schema-version-checked at `:63-70`. Its `source` block records provenance: `{"url":"https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json","registryVersion":"1.0.0","fetchedAt":"2026-09-01T03:41:38.550Z"}` (`resources/agent-catalog.json:1-9`). A second raw copy lives at `resources/acp-registry.snapshot.json`.
- Entry schema (`src/agent_catalog.rs:22-46`): `{id, label, version, description, repository, website, iconKey, command, args[], env{}, primaryAgentDir, projectPrimaryAgentDir, compatibleAgentDirs[], supportsSystemPrompt, supportsExternalSessionSync}`. The last four fields are Gold Band's own additions on top of the registry.
- **11 agents, with pinned versions** (`resources/agent-catalog.json`):
| id | command + args | agent dir | systemPrompt |
|---|---|---|---|
| `claude-acp` | `npx -y @agentclientprotocol/claude-agent-acp@0.70.0` | `.claude` | **yes** |
| `codex-acp` | `npx -y @agentclientprotocol/codex-acp@1.7.0` | `.codex` | no |
| `gemini` | `npx -y @google/gemini-cli@0.57.0 **--acp**` | `.gemini` | no |
| `cursor` | `cursor-agent acp` | `.cursor` | no |
| `codebuddy-code` | `npx -y @tencent-ai/codebuddy-code@2.143.0 --acp` | `.codebuddy` | no |
| `qwen-code` | `npx -y @qwen-code/qwen-code@0.22.3 --acp --experimental-skills` | `.qwen` | no |
| `goose` | `goose acp` | `.goose` | no |
| `opencode` | `opencode acp` | `.opencode` | no |
| `kimi` | `kimi acp` | `.kimi-code` | no |
| `amp-acp` | `amp-acp` | `.agents` | no |
| `pi-acp` | `npx -y pi-acp@0.0.33` | `.pi/agent` | no |
**Note the Gemini flag: `--acp`, not `--experimental-acp`.** fazm and Jockey both still pass `--experimental-acp` (extract §A.2; jockey `src-tauri/src/acp/adapter.rs:119-127`). Gold Band's 2026-09-01 registry snapshot says `--acp` at `@google/gemini-cli@0.57.0`. Verify the flag against the installed version before shipping.
**Also note `supportsSystemPrompt` is true only for Claude** — matching fazm's finding that Codex/Gemini need the system prompt prepended as a text block instead (extract §A.4).
- **Spawn** — `src/acp/adapter.rs:38-78`:
- `resolve_adapter` requires a non-empty command (`:25-29`); `normalize_args` splits every arg on whitespace, so `args: ["--acp --yolo"]` becomes two argv entries (`:80-84`).
- Windows rewrites bare `npx` → `npx.cmd` (`:86-93`) — the exact class of bug emdash hit as **[issue]** #1703.
- Env is resolved first (`resolved_adapter_env :100-108`), then each pair is set on the command (`:56-58`); stdio all piped; `current_dir(cwd)`.
- `CLAUDE_CODE_EXECUTABLE` is set to a **PATH-resolved local `claude`** unless the config already supplies it (`:59-62`, `local_claude_executable_for_env :140-148`). An env/flag `GOLD_BAND_REQUIRE_LOCAL_CLAUDE=1` turns a failed resolution into a hard error instead of letting the adapter download its own (`:63-72`, `:150-168`). Same intent as emdash's `CLAUDE_CODE_EXECUTABLE`, with an explicit strict mode.
- Windows `claude.cmd` shims are **parsed to find the real `.exe`**: read the `.cmd`, scan lines in reverse for `%dp0%`/`%~dp0` + `.exe`, resolve relative to the cmd dir (`:185-256`). This is the deepest treatment of the npm-shim problem in any of these repos.
- **PATH construction** — `src/process.rs:49-98`, doc comment at `:43-48`: priority is **explicit adapter `PATH` → cached login-shell PATH → current process PATH → platform paths**, deduped, then *appended* with `~/.local/bin`, `~/.cargo/bin`, `~/.volta/bin`, **every `~/.nvm/versions/node/*/bin`**, `/opt/homebrew/{bin,sbin}`, `/usr/local/{bin,sbin}` (`:200-240`, nvm enumeration at `:250-262`). The login-shell PATH is captured once into a `OnceLock` (`:40-41`). Windows re-reads user+machine PATH from the registry on every launch.
- **Process groups** — `ManagedProcessGroup` (`src/process.rs:452-591`): `command.group().spawn()` on Unix (Job Object + `kill_on_drop` + `CREATE_NO_WINDOW` on Windows), and **every live group id is registered in a global `LIVE_PROCESS_GROUPS` set** (`:466-469`).
- `try_wait` returns `None` while `process_group_is_alive(pgid)` — i.e. **the adapter is not "exited" until its whole descendant group is gone** (`:505-517`, liveness via `libc::kill(-pgid, 0)` at `:592-601`).
- `terminate(grace)` = `SIGTERM` to the group → wait → `force_kill` (`:549-557`).
- `Drop` kills and reaps (`:580-590`).
- **`recover_persisted_process_group(pid)`** (`:603-628`) kills a pgid persisted *before an app crash* — `kill -TERM -<pgid>` on Unix, `taskkill /PID n /T /F` on Windows — and the pid is stored per agent at `doctor_acp_provider_pid_file(agent_id)` (`src/storage/mod.rs:338`). `force_terminate_all_managed_process_groups()` (`:630-637`) is the "emergency fallback used only after the bounded application cleanup expires". **This is the orphan-sweep design emdash lacks and fazm bolted on with `ps` scraping.**
### D.3 `initialize` — Gold Band declines fs *and* terminals
`src/acp/client.rs:466-483`:
```rust
json!({
"protocolVersion": 1,
"clientCapabilities": {
"_meta": { (NESTED_AGENT_TRANSCRIPT_CAPABILITY): true },
"elicitation": { "form": {} }
},
"clientInfo": { "name":"gold-band", "title":"Gold Band", "version": VERSION }
})
```
No `fs`, no `terminal`. The agent does its own file IO and runs its own commands; Gold Band only observes. It *does* advertise ACP **elicitation** (`elicitation.form`) — `src/acp/elicitation.rs` is 768 lines — and a private `_meta` capability for **nested agent transcripts** (sub-agent output as a first-class stream). Compare: emdash declares fs+terminal, fazm declares neither, Jockey declares fs+terminal, acp-inspector declares fs.
Connection initialization is a shared-once latch with poisoning semantics: `initialize_once` (`src/acp/connection.rs:975-1030`) so N concurrent callers share one `initialize`, a **failed or panicking initialize poisons that connection permanently** but not its replacement, and cancelled waiters don't abort the shared attempt — all four properties have named tests (`connection.rs:2393-2545`).
### D.4 Session restore — `session/resume` vs `session/load`, capability-gated
`src/acp/client.rs:2163-2228`. Gold Band is the only client here that models **two distinct restore methods**:
- `SessionRestoreIntent::{ContinueOnly, SyncHistory}` (`:2164-2167`) — what the *user* wants.
- `SessionRestoreMethod::{Resume, Load}` → RPC `"session/resume"` / `"session/load"`, with `replays_history() == (self == Load)` (`:2170-2186`).
- Capabilities are read from the ACP schema type: `resume: capabilities.session_capabilities.resume.is_some()`, `load: capabilities.load_session` (`:2194-2205`). So `session/resume` here is a **real spec method gated by `sessionCapabilities.resume`** in `agent-client-protocol-schema` 1.6.0 — not the non-standard Claude-adapter extension fazm used (extract §A.3).
- `plan_session_restore` returns `Restore(method) | StartNew`, or a typed error `RestoreUnsupported` / `HistorySyncUnsupported` with stable error codes (`:2207-2228`) — the UI can say *why* a resume is impossible instead of silently starting over.
- **External session sync** (`client.rs:1624, 1680-1681, 3771-3800, 4559, 5244`; catalog flag `supportsExternalSessionSync`) is an opt-in mode where Gold Band reconciles with the CLI's *own* session store, gated on `restore_method.replays_history()`. The changelog entry is `fix(acp): prefer resume for detached session restore` (v0.10.0, 2026-07-31, `CHANGELOG.md:284`).
### D.5 Storage — file-per-artifact, SQLite only as an index
Everything is on disk under a runtime root, one directory per task/run/attempt (`src/storage/mod.rs:170-450`): `project.json`, `settings.json`, `state.json`, `task.json`, `run.json`, `workflow.snapshot.json`, `authoring/*`, `conversation-attention.json`, `desktop/agent-diagnostics.json`, `desktop/agent-command-catalogs.json`, scheduled tasks and triggers as JSON.
SQLite is **only a search/index layer** — `src/storage/sqlite.rs:240-300`: `tasks(task_path PK)`, `sessions(attempt_path PK, session_id, task_id, run_id, round_id, node_id, attempt_id, outer_node_id, outer_attempt_id, title, status, …)`, `session_prompts(attempt_path, id)`, plus **FTS5 virtual tables** with triggers — `session_prompts_fts` (content-backed) and `tasks_fts` with `tokenize='trigram'` (`:277-299`). The changelog has `fix(storage): correct SQLite session identity indexing` (v0.13.2, `CHANGELOG.md:88`).
Note `sessions.session_id` is nullable and the primary key is the **attempt path**, not the ACP session id — a session exists as a directory before the agent hands out an id.
### D.6 The timeline — an append-only, checkpointed, compacting event log
`src/acp/timeline.rs` is Gold Band's answer to emdash's in-memory reducer, and it is durable.
- `TimelineStore` (`:623-640`) owns a path, a `TurnFileStore` blob store, a materialized index, a compaction policy and a checkpoint policy.
- **Restore modes**, ranked: `IndexHit` → `TailReplay` → `FullRebuild` (`:59-80`). Reading a conversation normally hits the materialized index; on a mismatch it replays the tail; only as a last resort does it rebuild from the whole log.
- `TimelineCheckpointPolicy { patch_interval, tail_replay_limit }` (`:85-96`) and `TimelineCompactionPolicy { max_size_bytes, patch_ratio }` (`:601-614`) — the log is periodically checkpointed and compacted in place; `TimelineUpsertOutcome::{Unchanged, Appended, AppendedAndCompacted}` (`:616-621`).
- **Large payloads are externalized**: `TIMELINE_BLOB_REF_KEY = "$goldBandBlob"` (`:56`) replaces an inline value with a blob reference into `TurnFileStore` (`src/acp/turn_files.rs`, 1 975 lines).
- Items carry a three-part identity `{branch_id, item_id, revision}` (`:509-514`) and settlement is optimistic-concurrency-checked: `TimelineSettleOutcome::{Applied, AlreadyTerminal, RevisionConflict, IdentityMissing}` (`:593-599`). A late `tool_call_update` for an already-terminal item cannot resurrect it.
- A read returns a `TimelineIndexedPage` (`:479-499`) that carries, in one object: the events, **`pending_permissions`**, **`pending_elicitations`**, `available_commands`, `usage`, `timing`, `latest_plan`, `has_older`/`has_newer` and seq bounds. Pending interactions are part of the page, so a reopened conversation immediately shows the prompt it is blocked on.
- `TimelineBranchProjection` (`:516-536`) precomputes `tool_call_count`, `read_file_count`, `written_file_count`, `has_pending_interaction`, `has_completion_evidence`, `agent_launches`, `prompt_turns` — the sidebar reads projections, never the log.
- **Branches** (`src/acp/branches.rs`, 2 824 lines) make edit/regenerate a first-class tree, not a truncation.
### D.7 `session/update` → UI kinds
`src/acp/events.rs:3321-3336` is the whole mapping and it is deliberately lossless-by-default:
```rust
"agent_message_chunk" => "textDelta",
"user_message_chunk" => "userTextDelta",
"agent_thought_chunk" => "thoughtDelta",
"tool_call" => "toolCall",
"tool_call_update" => "toolCallUpdate",
"plan" => "plan",
"available_commands_update" => "availableCommands",
"usage_update" => "usageUpdate",
"current_mode_update" => "modeUpdate",
"config_option_update" => "configUpdate",
"session_info_update" => "sessionInfo",
_ => "rawDiagnostic",
```
**The `_` arm is the important one**: an unknown update kind is not dropped (emdash) — it becomes a visible `rawDiagnostic` row. Empty chunks are filtered (`CHANGELOG.md:79`, `fix(acp): preserve message streams and hide empty chunks`). Tool-call merging is keyed off `sessionUpdate ∈ {tool_call, tool_call_update}` at `events.rs:2941-2952`; a comment at `events.rs:2916` records the principle: a normalized item never claims to be the source of truth — "The original provider frame remains in `acp.raw.jsonl`".
### D.8 Permissions — a file-based, restart-survivable protocol
`src/acp/permission.rs`. This is architecturally different from every other client here.
- Two files per request in the attempt directory: `acp.permission-request.<id>.json` and `acp.permission-response.<id>.json` (`:36-48`).
- The ACP `session/request_permission` handler writes the pending file (`write_pending_permission :107-131`, payload = the raw ACP params) and then **blocks on a 200 ms poll loop** for the response file, returning early if turn cancellation is requested (`wait_for_permission_response_until_cancelled :221-241`). The response file is deleted on read (`:236`).
- `PermissionResponseState { requestId, optionId: Option<String>, cancelled: bool, decidedAt }` (`:28-34`) → ACP outcome: `cancelled` → `{outcome:{outcome:"cancelled"}}`, else `{outcome:{outcome:"selected", optionId}}`; a non-cancelled response with no `optionId` is an error (`acp_permission_response_result :245-257`).
- `cancel_pending_permission_requests(attempt_dir, decided_at)` scans the directory and writes a cancelled response for every pending file (`:50-105`) — cancelling a turn resolves every outstanding prompt.
- Each pending request is **bound to a timeline item identity** (`bind_pending_permission_timeline_identity :132-140`) and settled through the timeline's revision-checked `settle_permission_item`, with a regression test proving a stale response cannot revive a cancelled permission (`:695-732`).
- **No timeout, no auto-approve, no remembered decisions in this layer.** Like emdash, risk appetite is expressed through the ACP mode selector. Unlike emdash, the pending state survives an app crash because it is on disk.
- UI polish shows up in the changelog: `fix(acp): bound permission parameter previews`, `fix(acp): clamp permission previews to full lines` (v0.14.0, `CHANGELOG.md:36-37`), `fix(chat): align permission intervention cards` (`:164`).
### D.9 Raw frames — the observability surface
- Every frame in both directions is appended to `<attempt>/acp.raw.jsonl` as `{timestamp, direction, frame}` (`src/acp/events.rs:1286-1291`, path at `:900` and `src/storage/mod.rs:634-643`).
- Append is **batched under one file lock**: `append_raw_frames(path, direction, frames[], max_size, target_size)` does "one file lock, open, buffered write, flush, and roll check" (`events.rs:1245-1255`).
- The log **rolls by size**: over `max_size` it trims oldest lines down to `target_size`, with a pinned prefix that is never trimmed (`roll_raw_log`, `events.rs:1320-1364`), and returns `RawLogRollStats { before_bytes, after_bytes, elapsed }` for telemetry (`:1257-1268`). Unicode-safe line trimming has its own test (`:6296`).
- `src/acp/pipeline_diagnostics.rs` (436 lines) and `src/inspect/` are a built-in inspector; `src/observability/` carries the logging. Changelog: `fix(observability): harden ACP runtime logging` (`CHANGELOG.md:62`).
### D.10 What Gold Band has that the others don't
- **Themes as an SDK** (`theme-sdk/`, `themes/`, `resources/themes/`, `web/src/themes/generated`).
- **Scheduling**: `cron` dependency + `src/scheduler/` + `scheduled-task.json` / trigger files (`src/storage/mod.rs:390-402`) — recurring agent runs.
- **Workflows and AI-DYNAMIC orchestration** (`src/dsl/`, `src/dynamic.rs`, `src/dynamic_store.rs`, `src/app/orchestrator.rs`) with a `configs/app-config.toml` and JSON-schema validation (`jsonschema` crate).
- **ACP elicitation** implemented (`src/acp/elicitation.rs`, 768 lines) — the only client here that does.
- **Prompt queue** as a durable component (`src/acp/prompt_queue.rs`, 1 198 lines); changelog `feat(direct): queue prompts during active sessions` (v0.11.0, `CHANGELOG.md:278`).
- **Personal analytics** (`src/personal_analytics/`) and a CLI (`src/cli/`, `src/bin/`) over the same core.
- **Skills** (`src/skill/`) and MCP (`src/mcp/`) as first-class managed config.
### D.11 Changelog — the ACP lessons, dated
| Version (date) | Entry | Lesson |
|---|---|---|
| 0.14.0 (2026-08-25) | `fix(acp): fence stale provider lifecycle writes` (`CHANGELOG.md:42`) | A dead process's late writes must be fenced by generation — same conclusion as emdash's `routeOwnerId(key, generation)`. |
| 0.14.0 | `fix(acp): preserve session identity after cancel timeout` (`:47`) | A cancel that times out must not orphan the session id. |
| 0.14.0 | `fix(acp): preserve output through cancel convergence` (`:46`) | Output already streamed before a cancel must survive the cancel. |
| 0.14.0 | `fix(acp): converge sub-agent lifecycle state` (`:41`), `converge activity state on lifecycle stop` (`:39`), `complete lifecycle state convergence` (`:38`) | Sub-agent and activity state drift from session state is a recurring, multi-release bug family. |
| 0.14.0 | `fix(acp): preserve initial session model override` (`:45`) | A model chosen at session creation gets lost — cf. **[issue]** claude-agent-acp #1056. |
| 0.14.0 | `fix(acp): settle turn file changes by tool outcome` (`:48`) | File-change attribution must follow the tool's terminal status, not its start. |
| 0.14.0 | `fix(acp): unify session lifecycle and timeline recovery` (`:49`) | Two recovery paths (process lifecycle, transcript) must converge or they disagree after a crash. |
| 0.13.x (2026-08-17) | `fix(acp): decouple stop lifecycle from timeline replay` (`:78`), `bound cancellation and placeholder hydration` (`:77`) | Stopping must not be entangled with replaying. |
| 0.13.0 | `fix(acp): preserve persisted branch routes` (`:154`), `prevent cancellation from stalling on prompts` (`:155`), `refresh active session config catalogs` (`:156`) | Config catalogs go stale mid-session; refresh them. |
| 0.12.x (2026-08-07) | `feat(agent): add extensible ACP agent catalog` (`:276`) | The catalog became data, not code. |
| 0.11.0 (2026-08-05) | `fix(acp): prefer resume for detached session restore` (`:284`), `preserve history boundaries across live deltas` (`:285`) | Prefer `session/resume` over `session/load` when you only need to continue. |
| 0.10.0 (2026-07-31) | `fix(acp): make prompt cancellation durable` (`:303`) | Cancellation must survive a restart — hence the file-based signalling. |
| 0.14.1 (2026-08-26) | `fix(acp): harden prompt turn admission` (`:25`) | Admission control on which prompts may start a turn. |
---
## E. ACP Inspector — the debug view, and its gaps
`newioapp/acp-inspector` at `57b993e`. Electron 39.8.6 + electron-vite 5 + React 19 + Zustand 5 + `@agentclientprotocol/sdk ^0.17.1` (`package.json:52-58`). ~5.1k lines; the entire ACP layer is one 570-line file. Three build targets in one config (`electron.vite.config.ts:16-44`); shared types live in `src/shared` so main and renderer agree across the IPC boundary.
### E.1 It spawns, it does not proxy
It is a first-class ACP **client**, not a man-in-the-middle: `spawn(config.command, [...config.args], { stdio:['pipe','pipe','pipe'], env: { ...config.envVars, TERM:'dumb' }, cwd })` — `src/main/acp-connection-manager.ts:81-85`. The class itself implements `acp.Client` (`:41`) and wires `new ClientSideConnection((_agent) => this, stream)` (`:144`).
- `env` **replaces** `process.env` rather than merging it; the map comes from a login-shell probe (`zsh -ilc` / `bash -ilc`, 10 s timeout) — `src/main/shell-env.ts:154`, allowed shells at `:16`. Transient vars `_`, `PWD`, `OLDPWD`, `SHLVL` are stripped (`:27`), and `USER`/`HOME`/`SHELL` are recomputed **from the passwd DB** because a Dock-launched GUI app gets a minimal launchd env (`:42-58`). *That last detail is directly applicable to SnappyOS.app.*
- `TERM: 'dumb'` — the opposite of emdash's `xterm-256color`. Both are defensible; pick one deliberately.
- `cwd` is **stat-validated before spawn** because a bad cwd surfaces as ENOENT indistinguishable from a missing binary — `:72-79`, `:414-427`. Steal verbatim.
- Command parsing is a naive whitespace split with no quoting (`src/renderer/src/components/ConnectionBar.tsx:66-74`) — a known weakness.
- Teardown: `stdin.end()`, race `exit` against a 3 s timer, then `SIGKILL` (`:215-232`). One connection at a time (`:63-66`).
- `initialize` advertises `fs.readTextFile/writeTextFile` + `auth.terminal`, the latter only so agents *list* terminal auth methods; it never calls `authenticate` (`:147-160`, comment `:151-154`).
### E.2 The capture technique — tap the ndjson stream
**The single most portable idea in this repo:** capture is done by inserting two `TransformStream`s into the ndjson pipeline, not by wrapping SDK method calls — `src/main/acp-connection-manager.ts:118-142`. `tapSent`/`tapReceived` forward every `AnyMessage` to `onProtocolMessage` before enqueueing it downstream. The log is therefore **wire truth**, not a re-serialization of typed objects, and it costs ~20 lines.
### E.3 The data model (the thing Robert asked for)
`src/shared/types.ts`, deliberately separate from the SDK re-exports at `:9-42`:
```ts
JsonRpcRequest { jsonrpc?, id?: number|string|null, method: string, params?: Record<string,unknown> } // :65-70
JsonRpcResponse { jsonrpc?, id?, result?: unknown, error?: { code:number; message:string; data?:unknown } } // :73-78
ProcessEvent { stderr?, event?, code?: number|null, signal?: string|null } // :81-86
type ProtocolMessageData = JsonRpcRequest | JsonRpcResponse | ProcessEvent // :89
ProtocolMessage {
readonly id: number; // monotonic LOCAL counter, ≠ the JSON-RPC id inside `data`
readonly timestamp: number;
readonly direction: 'sent' | 'received';
readonly sessionId?: string;
readonly data: ProtocolMessageData;
} // :92-98
```
- **stderr and process exit are folded into the same log stream** as JSON-RPC frames (`ProcessEvent`). That is the right call: a debug view that shows frames but not the stderr line that explains the crash is useless.
- Discrimination is **structural, not tagged**: `isJsonRpcRequest = 'method' in data`; `isJsonRpcResponse = 'result' in data || 'error' in data` (`:101-108`). In Swift → an enum with associated values, decoded by key probing.
- Inspector-owned wrappers around raw ACP payloads: `InspectorSessionUpdate { timestamp, sessionId, data: SessionNotification }` (`:115-119`) and `InspectorPermissionRequest { requestId, timestamp, sessionId, data, respondedOptionId? }` (`:122-128`) — **the response is recorded on the request**, so answered/unanswered is derivable for permissions.
- `InspectorConfigOption { id, name, description?, category?, currentValue, options[] }` (`:144-151`) is a generic select dimension, so a new ACP config category renders with zero new code.
### E.4 Storage, caps, filtering
- **Two-tier buffer.** The main process keeps an **uncapped** array (`protocolMessages: ProtocolMessage[] = []`, `src/main/main-state.ts:53-55`, rationale `:1-6`) so nothing is lost while the window is closed — an unbounded growth path. The IPC snapshot caps at `MAX_SNAPSHOT_ITEMS = 500` (`:16, :73-74`).
- The renderer caps by `slice(-500)` on every append — `MAX_PROTOCOL_MESSAGES` / `MAX_SESSION_UPDATES` = 500 (`src/renderer/src/stores/inspector-store.ts:18-19, 318-322`). That is an O(n) copy per message; a real ring buffer is the obvious Swift improvement.
- Clearing is per-session on both sides; a null sessionId clears only session-less messages (`inspector-store.ts:365-371`, `main-state.ts:95-102`).
- **No file persistence, no export, no clipboard export of the log.** Only the connect command is copyable (`AgentInfoModal.tsx:48`).
- Filtering is three-way and client-side (`src/renderer/src/components/ProtocolLog.tsx:102-131`): active session (with a subtle rule that session-less messages arriving *after* session creation are hidden, `:106-115`), a hidden-method set, and a `session/update` sub-type set (hardcoded list at `:35-47`). Search is a brute-force `JSON.stringify(msg.data).toLowerCase().includes(query)` per message per render (`:126`); Cmd/Ctrl+F opens it, Esc closes (`:63-81`).
### E.5 UI structure
- Two tabs (Inspector / Environment) plus settings; the Inspector tab is a horizontal split with a hand-rolled draggable divider clamped 200–800 px between **OutputPanel** (semantic) and **ProtocolLog** (raw) — `src/renderer/src/App.tsx:43-59`, rationale `:1-6`. Seven push-event subscriptions in one effect (`:78-114`).
- `ConnectionBar` — command + cwd + connect, with rotating placeholder examples `kiro-cli acp`, `cursor acp`, `gemini --acp` (`ConnectionBar.tsx:9, 41-81`).
- `SessionPanel` — session chips, max 5 visible with the active one pinned, overflow modal (`SessionPanel.tsx:11, 27-43`); controls are capability-gated with explanatory tooltips ("Agent does not support listing sessions", `:63-73`).
- `ProtocolLog` rows are one line each: timestamp + `→`/`←` + `JSON.stringify(data, null, 2)` in a `<pre>` (`:256-269`). **No collapsible JSON tree, no request/response pairing in the view** — every frame is fully expanded, always. Auto-scroll is `instant` on session switch, `smooth` otherwise (`:133-139`).
- `OutputPanel` is the semantic view: it allowlists 10 update types (`:20-34`) and groups contiguous chunks; **`tool_call` + all subsequent `tool_call_update`s merge into one group keyed by `toolCallId`** (`:65-97`), while user messages are deliberately never concatenated (`:90`).
- `ToolCallCard` builds `ToolCallViewModel { sessionUpdate, title, kind, toolCallId, status, locations, content, rawInput, rawOutput }` from the group (`ToolCallCard.tsx:39-49`), with icon/label/color tables for **10 `ToolKind`s and 4 `ToolCallStatus`es** (`:55-66`, `:68-73`) — directly portable to a Swift enum. Diffs render as a collapsible per-path `DiffView` (`:98-110`); file locations show `path:line` (`:79-96`).
- `PermissionCard` renders **inline in the output stream, not as a modal** (`PermissionCard.tsx:43-56`); button variant is derived from `optionId.startsWith('reject')` (`:36-41`); answered cards stay visible marked "— responded" (`:69`).
- Permission plumbing: main assigns `perm_N` ids and parks the resolver in a map (`acp-connection-manager.ts:368-375`); on disconnect **all pending resolve to `{outcome:'cancelled'}`** (`:205-209`).
### E.6 Correlation — and its four holes
The only correlation logic is `src/main/index.ts:56-99`: `let messageCounter = 0` plus `const requestSessionMap = new Map<unknown,string>()` — "Maps JSON-RPC request id → sessionId for correlating responses". Algorithm: take `sessionId` from `params.sessionId` (requests) or `result.sessionId` (responses); if both rpcId and sessionId are present, record the pair; a response lacking a sessionId inherits it from the stored request. Then stamp `{id: ++messageCounter, timestamp, direction, sessionId, data}` (`:101-107`).
Holes a Swift host must not inherit:
1. The map is **never pruned on response** → unbounded growth.
2. Ids are **not namespaced by direction**, so a client-originated id `1` and an agent-originated id `1` collide.
3. **No latency/duration computation, no pending-request tracking, no timeout detection, no "unanswered request" surfacing** anywhere. The only timeout in the app is the 3 s SIGKILL grace (`acp-connection-manager.ts:226`).
4. No replay, no re-send, no export.
One genuinely good pattern: `-32601` (method not found) is caught and used to fall back from the generic `setSessionConfigOption` to legacy `setSessionMode` / `unstable_setSessionModel` (`:447-479`), and legacy `models`/`modes` fields are normalized into synthetic `'mode'`/`'model'` config options so one dropdown renderer covers both protocol eras (`:490-557`).
---
## F. Jockey — orchestration patterns over ACP
`recailai/jockey` at `c7431a8`. Tauri 2 + **SolidJS** frontend; ~16.2k lines Rust, ~11.4k TS.
### F.1 Stack and the registry that isn't used
- **`agent-client-protocol = "0.10.4"`, feature `unstable_session_model`** (`src-tauri/Cargo.toml:19`) — the official ACP **Rust** SDK, used directly (`acp::ClientSideConnection`, `acp::Agent`, `acp::Client`). Storage is bundled `rusqlite 0.39`; `git2 0.20`; release profile LTO + `panic=abort` + strip (`:45-50`).
- `acp-registry.latest.json` (857 lines) is a vendored snapshot of the upstream registry: `{version:"1.0.0", agents:[…], extensions:[…]}` (`:1-3`), per agent `{id,name,version,description,repository,authors,license,icon,distribution}` (`:4-14`). `distribution` has three variants worth modelling in Swift:
- **binary** — per-platform `darwin-aarch64 | darwin-x86_64 | linux-* | windows-x86_64` → `{archive: <tar.gz/zip URL>, cmd: "./amp-acp"}` (`:15-38`);
- **npx** — `{package:"@augmentcode/auggie@0.19.0", args:["--acp"], env:{"AUGMENT_DISABLE_AUTO_UPDATE":"1"}}` (`:51-61`), simplest form `{package:"@zed-industries/claude-agent-acp@0.21.0"}` (`:90-95`);
- **uvx** — Python agents.
28 agents listed.
- **Jockey never reads this file.** A repo-wide grep for `acp-registry` / `acp_registry` / `registry.latest` across `.rs/.ts/.tsx/.json/.md/.toml` returns zero references outside the file itself. All spawning is hardcoded. *Take the schema; don't take the plumbing.*
### F.2 Spawn and supervision — the best process discipline in this survey
- Four hardcoded runtimes: `Mock, ClaudeCode, GeminiCli, CodexCli` (`src-tauri/src/runtime_kind.rs:5-10`), with alias parsing (`claude`/`claude-code`/`claude-acp` → ClaudeCode, `:13-21`).
- Package mapping (`src-tauri/src/acp/adapter.rs:108-139`): ClaudeCode → bin `claude-agent-acp`, pkg `@agentclientprotocol/claude-agent-acp@latest`; GeminiCli → bin `gemini`, pkg `@google/gemini-cli@latest`, args `["--experimental-acp"]` **plus a `--help` probe that requires that flag** (`:119-127`); CodexCli → bin `codex-acp`, pkg `@agentclientprotocol/codex-acp@0.0.40` (pinned), with a `--version` probe (`:128-136`).
- **Three-tier resolution** (`:141-214`): (1) managed binary at `<appdata>/adapters/node_modules/.bin/<bin>` → `launch_method:"managed-binary"`; (2) `which(<bin>)` → `"path-binary"`; (3) package runners in order `pnpm dlx <pkg>` then `npx -y <pkg>` → `"package-runner:{binary}"`. Cached per-kind in a `DashMap` (`:19-27, :89-96`). Node resolution is delegated entirely to `which` — no nvm/fnm/volta handling (contrast Gold Band, which enumerates nvm dirs).
- Env is an **11-key allowlist** (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_APPLICATION_CREDENTIALS`, …) (`:33-45`), sourced by running `$SHELL -lic env` and parsing `k=v` (`:279-299`), applied **only when the var is not already set** in the process env (`:244-256`).
- **The spawn** (`src-tauri/src/acp/session/cold_start.rs:217-234`): `tokio::process::Command`, all three stdio piped, `.kill_on_drop(true)` and **`.process_group(0)`**. PIDs are registered globally (`:232-234`, `worker/pool.rs:158-169`); `Drop for LiveConnection` sends SIGTERM then SIGKILL to the **negative pgid** and then to the pid (`worker/pool.rs:50-63`). stderr is pumped line-by-line into a rolling tail buffer used to enrich error messages (`cold_start.rs:239-261`).
- **The handshake is a 4-way `tokio::select!`** (`cold_start.rs:298-331`): `initialize` vs a 30 s timeout vs `child.wait()` (→ `process_crashed` with the stderr tail) vs a health watch channel (→ `connection_closed`). `INIT_TIMEOUT` = `SESSION_TIMEOUT` = 30 s (`:24-25`). Client capabilities: fs read/write + `terminal(true)` + a `terminal_output` meta flag (`:299-313`).
- Raw errors are mapped to user-facing strings for rate limits, auth, crashes, timeouts, EPIPE, **corrupt npx cache**, **missing `--experimental-acp`**, and Codex/ChatGPT model incompatibility (`adapter.rs:329-390`). A 512-entry in-memory ring of `AcpLogEntry {ts_ms, event, payload}` doubles as the debug log, snapshot-able from the UI (`adapter.rs:31-32, :441-467`).
### F.3 Orchestration
- **Roles, not agents, are the unit**: a role = name + runtime_kind + system prompt + model/mode + MCP servers + `auto_approve` (`src-tauri/src/db/mod.rs:62-75`).
- **Connection pool key is `{app_session_id}:{runtime_key}:{role_name}`** (`worker/pool.rs:21-23`) — N roles × M sessions concurrent agent subprocesses, **with no configured cap**.
- **All ACP I/O runs on one dedicated thread**: a `current_thread` Tokio runtime + `LocalSet` fed by an unbounded mpsc (`worker/mod.rs:35-50`); connections live in `thread_local! CONN_MAP` (`pool.rs:129-133`); `LiveConnection` is `unsafe impl Send/Sync` with a documented three-point justification that it never leaves the LocalSet (`pool.rs:65-72`). **This single-threaded-actor design is the most transferable architectural decision in the repo** — it maps 1:1 onto a Swift `actor`.
- **Routing is `@role:` mention parsing** (`parser.rs:4-70`, consumed at `chat.rs:221-234`), defaulting to a union assistant named `"Jockey"`.
- **Fan-out is sequential, not parallel**: `for role_name in role_targets { … execute_runtime(…).await … }` with outputs concatenated as `[Role]\n{output}` (`chat.rs:246-399`). Multiple agents are *resident* concurrently; a multi-role prompt runs them one after another.
- **Per-connection prompt serialization with a visible queue**: an async mutex per pool key; on contention it emits `StatusUpdate { "Waiting for previous turn... queue position N" }` before awaiting (`worker/handlers.rs:740-773`).
- **Cold-start dedup**: concurrent `ensure_connection` calls for one key await a single `futures::future::Shared` instead of each spawning (`pool.rs:145-153`).
- **Idle reclaim**: a 30 s ticker (`worker/mod.rs:103-110`) kills connections idle ≥ 300 s with no in-flight prompt (`worker/handlers.rs:120-145`, constants `:25-26`).
- **Prewarm**: fire-and-forget connection pre-creation (`session/prewarm.rs:172-206`); in workflows the *next* role is prewarmed while the current one runs (`db/session.rs:211-219`). The repo's own reference table notes prewarm has **no analogue in Zed or AionUi** (`docs/acp_references/README.md:30`).
- **Workflows** are a linear role chain with a shared-context blackboard: each step's output is summarized into `summary.{role}` in `shared_context_snapshots` and passed forward (`db/session.rs:197-300`).
- **No git worktree isolation** — all roles share one `workspace_path`; the Settings UI Worktrees tab is an explicit placeholder ("Worktree settings will be added when worktree management is wired", `src/components/SettingsPage.tsx:346-352`).
- **No cost or token accounting** — metrics are counts + latency sums only (`acp/metrics.rs:5-19`). The README's "control token costs per role" is role→model binding, not measurement (`README.md:10`).
### F.4 Fold and permissions
- The Rust `Client` impl narrows `acp::SessionUpdate` into a **flat, serde-tagged `AcpEvent` enum** (`tag="kind"`, camelCase) — `acp/worker/types.rs:58-120`: `TextDelta, ThoughtDelta, ToolCall{toolCallId,title,toolKind,status,content,locations,rawInput,rawOutput,terminalMeta}, ToolCallUpdate, Plan, PermissionRequest, ModeUpdate, ConfigUpdate, SessionInfo, StatusUpdate, AvailableCommands, AvailableModes, SessionError, PermissionExpired`. **That narrowing — ACP's tagged union to one flat host event type — is exactly the boundary a Swift host needs.**
- Conversion validates the session id first (`acp/client.rs:311-360`); empty text chunks are dropped (`:320-322`). Events cross a bounded 512-slot mpsc (`pool.rs:16-17`), are re-emitted as Tauri events with TextDeltas batched on a ~30 ms flush (`session/execute.rs:101-136`), and the frontend coalesces noisy `session/update` lines over a 120 ms window (`src/lib/sessionEventBuffer.ts:6, 24-69`). Terminal meta is folded into per-session terminal entries **with buffering for output that arrives before its info frame** (`src/lib/acpEventBridge.ts:32-70`).
- **Permissions are a three-stage cascade** (`acp/client.rs:212-258`): (1) if the role has `auto_approve`, pick the first `AllowOnce`/`AllowAlways` option and answer immediately (`:217-237`); (2) else consult an **LRU approval cache** keyed by a derived `permission_cache_key` and auto-answer on hit (`:239-246`); (3) else mint a UUID request id, emit `PermissionRequest` and park a `oneshot` (`:248-258`). The cache is 256 entries and is written **only when the chosen option was in `allow_always_option_ids`** (`worker/permission.rs:9, 70-86`). Pending requests live in a `DashMap` and can be cancelled per-(runtime, role, session) or globally, each emitting `PermissionExpired` (`:88-125`). UI is an inline panel with a "remember" switch that filters the option list to `allow_always` (`src/components/PermissionModal.tsx:12-50`).
**Jockey is the only client here that implements "always allow" persistence** — and it does it host-side, in an LRU keyed by a derived key, not by forwarding `allow_always` and forgetting.
### F.5 Persistence and resume
- SQLite (`src-tauri/src/db/mod.rs`): `app_sessions(id,title,active_role,runtime_kind,cwd,created_at,last_active_at,closed_at)` (`:113-122`); **`app_session_roles(app_session_id, role_name, runtime_kind, acp_session_id, model_override, mode_override, mcp_servers_json, config_options_json)`** (`:123-133`) — the ACP session handle is keyed per (app session, role); `app_session_messages` (`:142-148`); workflow `sessions`/`session_events` (`:83-98`); `shared_context_snapshots(scope,key,value)` (`:99-105`).
- Resume: `execute.rs` loads the stored CLI session id and passes it as `resume_session_id` (`session/execute.rs:92-125`); cold start gates on the agent's `load_session` capability and **falls back to `new_session` if load fails**, logging `session.load.fallback` with the stderr tail (`cold_start.rs:382-430`). Same shape as emdash's materializer — two independent implementations converging on the same rule.
- On boot the most recently active non-closed session (joined to its role's runtime and `acp_session_id`) is restored (`src-tauri/src/lib.rs:178-200`).
- **Codex effort encoding**: Codex reports the model as `model[effort]`; Jockey splits it (`cold_start.rs:75-86`) and re-synthesizes **two** select config options — `model` and `reasoning_effort` — for a uniform UI (`:96-150`).
### F.6 Docs worth reading
- `docs/completed/agent_orchestrator_design.md:1-30` states the thesis: Tauri as **"Conductor"** — all JSON-RPC traffic passes through the backend, which parses `session/update` for thought chains and plans (`:16`); agents never talk to each other, the Conductor is the sole instruction source, for predictability and safety (`:17-19`); plus a **"Shared Brain"** where Tauri itself acts as an MCP server exposing `get_shared_context`/`update_shared_context` as a blackboard, bridged by generating virtual MCP stdio configs so vendor CLIs think they are talking to a local process (`:21-30`, realized in `src-tauri/src/jockey_mcp/bridge.rs`).
- **The single most useful table for a Swift port** is `docs/acp_references/README.md:22-37`: a per-file mapping of Jockey module → concept → **Zed analogue with line numbers into `crates/agent_servers/src/acp.rs`** → AionUi analogue (e.g. permission routing ↔ Zed `acp.rs:3032`; connection pool ↔ Zed's `sessions: Rc<RefCell<HashMap<SessionId, AcpSession>>>`).
- Self-critique worth heeding (`docs/acp_references/README.md:41-47`): the `Send`→`!Send` bridging pattern; the admission that Jockey lacks a single-owner client class (its equivalent is "scattered across `worker/pool.rs` + `worker/handlers.rs` + `worker/notify.rs`"); and that Jockey "passes `String` everywhere" instead of a canonical error enum with retryability flags.
---
## G. Adapter issue themes — what bites every host
Read 2026-09-02 via `gh issue list --state all --limit 80` on both repos plus ~25 issue bodies and the full release history since 2026-06.
### G.1 `agentclientprotocol/claude-agent-acp` (latest **v0.73.0**, 2026-09-01)
**Theme 1 — Permissions / settings bypass.** A host cannot assume its own permission gate is the only one.
- **#1056** OPEN — `session/new`'s `_meta.claudeCode.options.model` is **silently overridden by `settings.model`** in `~/.claude/settings.json`; no error, no warning, and `configOptions` then reports a *third* value. Measured on 0.70.0. Same class as fazm's discovery that `permissions.defaultMode: bypassPermissions` in user settings starves the gate (extract §A.6) — the fix there was `_meta.claudeCode.options.settingSources = []`.
- **#1068** OPEN — regression in **0.71.0**: Bash permission prompts show the *model-written description* instead of the command. A permission UI that renders `toolCall.title` verbatim will show the wrong thing.
- **#1050** OPEN — selecting an option in a permission/question dialog is discarded; only the follow-up notes text is returned.
- **#918** CLOSED (fixed 0.63.0, PR #923) — `permission_denied` emitted a `tool_call_update` for a tool call the client was never told about. Fix: "Only resolve a denied tool call the client was told about."
- **#830** CLOSED — `session/new`, `session/list` and other session operations were **blocked by a plan-request permission**.
**Theme 2 — `session/update` after the turn, and ghost tool calls.** The single most important class for a fold implementation.
- **#864** OPEN — `session/update` keeps arriving after `session/prompt` resolves: "the Claude **session** is frequently not idle at that moment: after a background sub-agent's task-notification the model resumes and keeps producing… with no session-level active/idle signal." *This is precisely why emdash has lazy agent-initiated turns + a 250 ms quiescence debounce.*
- **#1061** OPEN (0.70.0) — **post-cancel `stream_event` tool starts are forwarded without terminals**: after `session/cancel` the adapter keeps emitting `content_block_start` for `tool_use` (→ `tool_call` starts) while the consolidated path that produces terminal `tool_call_update`s correctly drops the cancelled turn. "Any client that keeps a ledger of open tool calls … ghost open tool_calls poison the next turn." **Mitigation: force-settle every running tool on turn close** — exactly emdash's `finalizeItems` (`item-fold.ts:785-824`).
- **#824** CLOSED — `session/update` tool-call notifications arriving after end of turn.
- **#913** CLOSED (fixed 0.63.0) — `tool_progress` forwarded the SDK's synthetic `-heartbeat-N` id, so clients got `tool_call_update` for tool calls that were never announced. **Never trust that an update's `toolCallId` was previously announced** (emdash synthesizes the row: `item-fold.ts:736-749`).
- **#970** CLOSED / **#896** OPEN / **#825** CLOSED — `session/prompt` left unanswered when a turn fails to finalize; only resolves after `session/cancel`, as `stopReason: cancelled`. **A host needs an inactivity watchdog on the prompt await.**
- **#1027**, **#1039**, **#934**, **#903** — steering (mid-turn injection) can leave a turn's `session/prompt` unanswered forever, or detach it without an observable terminal response. Fixed progressively in 0.64.0 ("steering: add opt-in host-owned fallback", closes #903) and 0.65.0 ("settle a steered turn at idle, not at the interrupt").
**Theme 3 — Resume / `session/load`.**
- **#1019** OPEN — resuming a conversation created by the native `claude --session-id <uuid>` **silently completes without its history**: the turn succeeds and the same UUID is echoed back, but the model has none of the native history. "Continuity silently lost." A host must not assume an echoed session id means the history loaded.
- **#1024** OPEN — a resumed over-limit session is **permanently stuck**: `session/load` replays fine (~21 MB jsonl, ~4200 updates), then every prompt fails "Prompt is too long", auto-compaction never triggers, and `/compact` is a silent no-op.
- **#1041** OPEN — `session/list` matches `cwd` case-sensitively, so a `/mnt/<drive>` path can't find its own session.
- **#998** OPEN — `session/load` drops marker-only user prompts for model-bound slash skills.
- **#845** CLOSED (0.58.0) — `session/load` returned the wrong current config options; **#848** "Preserve live model on resumed sessions". *This is why emdash re-applies desired config after every load (`session-materializer.ts:341-355`).*
- **#906** OPEN — `conversation_reset` drops `new_conversation_id`, causing stale session resume after a worker restart.
**Theme 4 — Zombie / orphan processes.**
- **#1011** OPEN — "Orphaned claude subprocess children accumulate under a long-lived agent across repeated `session/load` resumes": multiple live `claude` CLI children under one adapter parent instead of exactly one. **The adapter itself leaks; a host that keeps one long-lived adapter process per (provider, cwd) — emdash's design — inherits this.** Budget a periodic descendant audit.
- **#994** OPEN — "Client stop cancels the turn but leaves background sub-agents running — expose a stop primitive." `session/cancel` is not a kill.
- **#976** OPEN — follow-up prompts kill the background subagents a held turn is waiting for.
**Theme 5 — Quota / auth / 401 mapping.**
- **#1023** OPEN — the byte-stream idle watchdog is **disabled when `ANTHROPIC_BASE_URL` points at a third-party gateway**: a stream that dies mid-flight hangs indefinitely with no error, no abort, no retry. "We observed a 26-minute dead hang." **A host using a custom endpoint must impose its own idle timeout.** (fazm learned the same lesson: a 120 s guard for custom endpoints, extract §A.11.)
- **#988** OPEN — synthetic auth message before the replayed user echo leaves the prompt pending. **#863** CLOSED — resuming an unauthenticated session showed an invalid message; fixed 0.59.0 by "Skip synthetic login messages on replay" (#869).
- **#1009** OPEN — "How to use a different API key in ACP" is still an open question.
- **#782** OPEN — "When will the Claude subscription button be restored?"
- **#953** OPEN / **#927** OPEN / **#1021** OPEN — token accounting: no deduplicated accounting for failed/autonomous results; no monotonic counters during a turn; `usage_update` **never carries the effective model id**. Cost display is unreliable.
**Theme 6 — MCP.**
- **#883** OPEN — **a session-scoped stdio MCP server passed in `session/new.mcpServers` never reaches the model**: no `tools/list`, not even listed as known-but-unconnected, while the same server works when driven directly. If your host injects its own tools per session (fazm's `fazm_tools` pattern), verify they actually appear.
**Theme 7 — Dropped signals.**
- **#1030** OPEN — `system` messages with subtypes `hook_started`, `hook_progress`, `hook_response`, `task_notification`, `compact_boundary`, `status`, `files_persisted` are **silently dropped**; the source `break` is annotated `// Todo: process via status api`. (fazm patched the adapter prototype to re-emit exactly these, extract §A.2.)
- **#1042** OPEN — hook notices are emitted as unmarked `agent_message_chunk`, indistinguishable from model output.
- **#781** OPEN — available commands / skills leak into `agent_message_chunk` after `available_commands_update`.
- **#873** OPEN — the `/compact` conversation summary is dropped rather than surfaced.
- **#838** OPEN — `ScheduleWakeup` / `CronCreate` / `/loop` **never fire under ACP**: crons are registered but the wakeup prompt is never delivered while idle. (fazm's mitigation: disallow those tools outright, extract §A.6.)
**Release-note changes since 2026-06** (`gh api .../releases`): **0.60.0** (07-20) configurable LLM providers (#853), removed a ~15 s stall on `session/new`/model switch (#894). **0.63.0** (07-27) "key Bash terminal metas off the announced `tool_use` id" (#917) — **terminal-id association changed**. **0.64.x** (07-30/08-02) opt-in host-owned steering fallback; restored the single-tool `ExitPlanMode` (#942). **0.67.0** (08-14) Skill tool calls carry name+kind in `_meta` (#986); model fallback as a warning advisory (#990). **0.68.0/0.69.0** (08-14/16) typed session failures + changed files aligned to "AIR" — a new failure taxonomy on the wire. **0.70.0** (08-18) provider switching on loaded sessions (#1002). **0.71.0** (08-31), the big one: AI session titles (#984), "**align Claude modes and clear-context planning**" (#1004), "**expose permission mode kinds**" (#1025), native subagents and async tasks (#1017), per-model token usage on prompt responses (#1037), **message-specific session forks** (#1046), min zod → 4.x (#1057). **0.72.0/0.73.0** (09-01) SDK 0.3.252; per-model effort settings and `user_message_uuid` result attribution (#1065).
### G.2 `agentclientprotocol/codex-acp` (latest **v1.8.0**, 2026-09-01)
**Permissions / sandbox.**
- **#401** OPEN (1.2.0) — **MCP calls can run before permission mediation**: in a read-only session, Codex routes a read through an MCP tool invoked from a code-mode wrapper; codex-acp surfaces the outer wrapper as `kind:"execute"` but the underlying MCP calls **have already run** without a `session/request_permission`. A read-only ACP mode is not a sandbox.
- **#450** OPEN — **Read-only mode disappeared** from the JetBrains mode selector; the three remaining modes map to `workspace-write`/`workspace-write`/`danger-full-access`. Mode names are not stable across releases.
- **#310** OPEN — sandbox and approval policies in `~/.codex/config.toml` are ignored; only three predefined `INITIAL_AGENT_MODE` values exist.
- v1.1.8 (2026-08-01) added "Expose structured permission changes in ACP metadata"; v1.7.0 (2026-08-27) added "**ACP v1 permission presentation**" and "expose permission mode kinds" — the permission surface changed twice in a month.
**Resume / `session/load`.**
- **#343** OPEN — `session/load` returns the **`config.toml` model/effort instead of the thread's**, because `thread/resume` is always sent a `modelProvider`. Any in-session model switch is lost on resume. Reproduced on 1.1.4 and 1.1.7.
- **#355** OPEN — `session/load` replays turns that were removed by `thread_rolled_back`; the history fallback ignores the rollback event and merges obsolete turns back in.
- **#431** OPEN — cwd normalisation misses WSL `/mnt/<drive>` paths, so `session/list` can't find a session it just created.
- **#206** CLOSED — `session/load` restored text history but missed historical tool calls. **#222** CLOSED — multi-agent tool calls were only emitted on `session/load`, never during the live turn.
- **#448** OPEN — no ephemeral / non-persisted session option (Codex app-server has `ephemeral: true` threads).
**Quota / usage.**
- **#447** OPEN — `PromptResponse.usage` reports **only the turn's final request** (`lastTokenUsage`), not the session-cumulative `totalTokenUsage`; multi-request tool-calling turns systematically under-report output tokens.
- **#227** OPEN — Codex's `RateLimitSnapshot` (the ChatGPT subscription usage windows shown by `/status`) is ingested but **never crosses the ACP boundary** — only as markdown inside `/status` output. No live "47% used; resets in 2h 14m" badge is possible without scraping.
- **#285** OPEN — fast mode is disabled when Codex reports the canonical priority service tier.
**Auth / launch.**
- **#459** OPEN — `codex-acp login` tries to launch a separate `codex` on PATH instead of the bundled `@openai/codex`; if absent, the child exits before answering `initialize`, and on Windows the only message is "Pending response rejected since connection got disposed". **Directly relevant: emdash's `CODEX_PATH` env is the fix for this class.**
- **#319** OPEN — no way for a host to supply externally managed ChatGPT tokens (`account/login/start` with `type:"chatgptAuthTokens"`).
- **#378** OPEN — Codex is spawned **before** the `wire_api` gateway config is injected, so the first message gets a WebSocket 405 and falls back to HTTPS.
- **#243** CLOSED — `chat-gpt` auth always opened a browser even when already logged in.
- v1.1.10 (2026-08-06) added "device code authentication via URL elicitation"; v1.7.0 "send elicitation complete event for device authentication".
**Fold / stream hygiene.**
- **#340** OPEN — **adapter warnings are emitted as `agent_message_chunk`**, the same kind as genuine assistant replies: "downstream consumers cannot distinguish a warning from model prose without unsafe text matching." (Identical to claude-agent-acp #1042.)
- **#294** OPEN — reasoning-summary mode is hardcoded to `"auto"`; no detailed/raw reasoning (a regression from `zed-industries/codex-acp`).
- **#259** CLOSED — image-generation completion emitted a non-terminal `tool_call` status, so the call never completed.
- **#169** CLOSED — Codex commentary messages streamed as `agent_message_chunk`.
**Terminal.**
- **#166** CLOSED — **`zed-industries/codex-acp` uses `terminal_output` while `agentclientprotocol/codex-acp` uses `terminal_output_delta`.** Two forks, two terminal wire shapes. Know which one you spawned.
**Protocol/version.**
- **#321** OPEN — Xcode 27.3's ACP integration sends a `protocolVersion` of a different **type** than codex-acp expects. A native host must match the schema's exact JSON type.
- **#198** OPEN — codex-acp sends `Model.id` to `turn/start` instead of the executable `Model.model`.
- **#411** OPEN — v1.5.0 tagged on GitHub but not published to npm. `npx -y <pkg>@latest` can resolve to something older than the release notes claim.
**Release-note changes since 2026-06**: v1.0.0 (06-23) rename/relaunch from the Zed fork; v1.1.0 (07-02) ACP SDK 1.1; v1.1.1 (07-09) SDK 1.2.1 + MCP elicitation; v1.1.4 (07-15) subagent activity over ACP; v1.1.8 (08-01) structured permission changes + plan-review confirmation; v1.2.0 (08-12) typed session failures; v1.3.0 (08-14) **versioned context-compaction metadata**; v1.5.0 (08-18) provider switching on loaded sessions; v1.7.0 (08-27) **ACP v1 permission presentation + native ACP subagent sessions + permission mode kinds**; v1.8.0 (09-01) session forks, `/rename`, OAuth2 for MCP servers, codex 0.152.0.
### G.3 The cross-cutting rules these issues imply
1. Treat every `tool_call_update` as possibly-first and possibly-late; synthesize missing rows, and force-settle open ones at turn close.
2. Never treat `session/prompt` resolving as "the session is idle" — run a quiescence timer.
3. Never treat an echoed session id as proof history loaded.
4. Assume the agent's own settings file can override anything you pass in `_meta`.
5. Assume warnings and hook notices arrive as ordinary assistant text.
6. Impose your own idle timeout; the adapters' watchdogs have holes.
7. Pin adapter versions and read the release notes — the permission surface changed twice in August 2026 alone.
---
## H. Cross-client comparison
| | **Emdash** | **Gold Band** | **Jockey** | **ACP Inspector** | (fazm, for reference) |
|---|---|---|---|---|---|
| Language / shell | TS, Electron + Node worker | **Rust**, Tauri 2; React web | **Rust**, Tauri 2; SolidJS | TS, Electron | Swift app + Node bridge |
| ACP library | `@agentclientprotocol/sdk` (`ClientSideConnection` + `ndJsonStream`) | `agent-client-protocol-schema` 1.6.0 **(types only; own transport)** | `agent-client-protocol` 0.10.4 **(full Rust SDK)** | `@agentclientprotocol/sdk` ^0.17.1 | `@agentclientprotocol/sdk` 0.19.0 |
| Agents supported | **23 ACP-capable of 36** | 11 (embedded registry snapshot) | **4 hardcoded** (registry file unused) | any (user types the command) | 3 (Claude, Codex, Gemini) |
| Spawn | `process.execPath` + `ELECTRON_RUN_AS_NODE=1` + bundled adapter asset | `npx -y <pinned pkg>` via `ManagedProcessGroup` | managed-binary → `which` → `pnpm dlx`/`npx -y` | user-supplied command, whitespace-split | bundled Node + patched adapter entry |
| Adapter binary policy | `CLAUDE_CODE_EXECUTABLE` / `CODEX_PATH` → host CLI; vendored SDK binaries in `ignoredOptionalDependencies` | `CLAUDE_CODE_EXECUTABLE` → PATH `claude`, strict mode via `GOLD_BAND_REQUIRE_LOCAL_CLAUDE` | none | none | bundled `codex-acp` native binary |
| PATH source | login **interactive** shell probe `$SHELL -ilc env` | login-shell PATH cached in `OnceLock` + nvm/volta/homebrew appended | `$SHELL -lic env`, only fills unset vars | `zsh/bash -ilc env` + passwd-DB identity | **no shell probe**; hardcoded ladder |
| Env policy | **121-name allowlist**, env built from scratch | config env + merged PATH | **11-key allowlist**, non-overriding | replaces `process.env` wholesale, `TERM=dumb` | full `process.env` minus `CLAUDECODE` |
| `initialize` client caps | `fs{read,write}` + `terminal` | **none** (only `_meta.nestedAgentTranscript`, `elicitation.form`) | `fs{read,write}` + `terminal(true)` | `fs{read,write}` + `auth.terminal` | **none** (`fs` false) |
| Fold | 4-stage pure pipeline: decode → provider enrich → reducer → item-fold; 11 typed tool rows + auto read-groups; dev invariants | durable append-only **timeline** with checkpoints, compaction, blob externalization, revision-checked settlement; unknown kinds → `rawDiagnostic` | narrow to a flat serde-tagged `AcpEvent` enum; 30 ms delta batching; 120 ms UI coalescing | group contiguous chunks; merge `tool_call` + updates by `toolCallId` | switch → private JSON-lines events; diffs/terminals flattened to text |
| Missing `messageId` | synthesized segments `auto:<stream>:<n>` | n/a (log is append-only) | n/a | n/a | text-block boundaries |
| Permission default | **no auto-approve on the ACP path**; band above composer; default `allow_once` | **no auto-approve**; file-based request/response, 200 ms poll | **3-stage: role `auto_approve` → 256-entry LRU cache → prompt** | inline card; no default | **auto-approve everything** (upstream added a gate) |
| "Always allow" persistence | none (forwarded to agent only) | none | **yes** — LRU keyed by derived `permission_cache_key`, written only for `allow_always` | none | none |
| Permission timeout | **none** | **none** (poll until answered or cancelled) | none (explicit cancel emits `PermissionExpired`) | none | 300 s → `cancelled` (upstream gate) |
| Cancel with pending perms | `drain()` → all `{outcome:'cancelled'}` | writes a cancelled response file for every pending request | cancel per-(runtime,role,session) or global | all resolve `cancelled` on disconnect | gate drains |
| Terminals | **full client-side impl**: 4 MB ring, `StringDecoder`, separate capped log channel | agent-side (no `terminal` capability declared) | declared + `terminal_output` meta; frontend buffers out-of-order frames | declared but unused | none (no PTY, no terminal methods) |
| fs methods | implemented; write does `mkdir -p` | not declared | implemented | implemented | declined |
| Sessions / resume | `loadSession` if `supportsLoadSession`, **fall back to `newSession` on failure**; suspend/rematerialize; versioned persisted intent | **`session/resume` vs `session/load`**, capability-gated, typed "unsupported" errors; external session sync | stored `acp_session_id` per (app session, role); **fall back to `new_session` on load failure** | live only; `session/list` if supported | cwd-addressed JSONL bookkeeping + JSONL migration |
| Persistence | SQLite (app) + JSON intents file; transcript in memory | **files-per-artifact + append-only timeline**; SQLite only as an FTS5 index | SQLite for everything | **nothing persisted** | `~/.fazm/acp-sessions.json` |
| Process cleanup | `detached` + group SIGTERM→SIGKILL (1 s grace), idempotent; **no orphan sweeper** | process **groups registered globally**, pgid persisted per agent, **crash-recovery kill** + emergency sweep | `process_group(0)` + kill-on-drop + negative-pgid SIGTERM→SIGKILL | `stdin.end()` → 3 s → SIGKILL | `ps`-scraping orphan sweep + parent-death watchdog |
| Connection pooling | one process per `(providerId, cwd)`, 2 min idle TTL, generation-fenced routes | per attempt/agent, `initialize_once` latch with poisoning | pool key `{app_session}:{runtime}:{role}`, 300 s idle reclaim, cold-start dedup, prewarm | **one connection, period** | one adapter per provider |
| MCP injection | `session/new.mcpServers`, **env as `[{name,value}]`**, transport gated on agent `mcpCapabilities` | managed MCP config (`src/mcp/`) | per-role `mcp_servers_json`; **Tauri itself is an MCP server** (shared-context blackboard) | none | stdio MCP dialing back over a Unix socket |
| Raw protocol log | `RawAcpLog`: 50 k entries **and** 16 MB caps, exportable, fixture-compatible | `acp.raw.jsonl` per attempt, batched appends, **size-rolling with pinned prefix** | 512-entry in-memory ring, snapshot-able | uncapped in main + 500-item `slice` in renderer; **no export** | stderr tee'd to a 10 MB rotating file |
| Debug UI | export commands on the API; no dedicated inspector | `src/inspect/` + pipeline diagnostics + raw-frame viewer | log snapshot in UI | **the whole product** | none |
| Multi-agent orchestration | one conversation per session; no fan-out | WORKFLOW graph + AI-DYNAMIC decomposition + cron scheduling | roles + sequential fan-out + workflow chain + shared-context blackboard | n/a | n/a |
| Cost / usage | `contextUsed/contextSize/cost` from `usage_update` only | usage projection in the timeline + context gauge | **none** | none | patched adapter captures `total_cost_usd` |
| Auth | status detection only; login delegated to the CLI in a terminal | catalog-declared agent dirs; CLI-owned | none (env keys only) | never calls `authenticate` | **own OAuth PKCE + Keychain writes** |
---
## I. Recommendations for the Swift host
Each tied to the evidence above. Ordered by leverage.
**I.1 Spawn the adapter with Swift's own bundled Node, and point it at the user's CLI.**
Set `CLAUDE_CODE_EXECUTABLE` and `CODEX_PATH` to the resolved host binary (emdash `impl/claude/index.ts:148-151`, `impl/codex/index.ts:132`; Gold Band `adapter.rs:59-62`). Two independent implementations do this, and codex-acp **#459** is the bug you avoid. Add Gold Band's strict mode: a setting that makes an unresolved CLI a hard error rather than letting the adapter download a second copy (`adapter.rs:63-72`).
**I.2 Build the child environment from an allowlist, not from `ProcessInfo.processInfo.environment`.**
Port `AGENT_ENV_VARS` (emdash `primitives/agent-env/api/index.ts:1-121`) plus `TERM`, `COLORTERM`, `TERM_PROGRAM`, `HOME`, `USER`, `PATH`, conditional `TMPDIR`/`SSH_AUTH_SOCK`. Set `TERM_PROGRAM` to your app name. Jockey's 11-key list is too small; acp-inspector's wholesale replacement is too blunt.
**I.3 Resolve PATH from a login shell, and append the well-known dirs anyway.**
A Dock-launched `.app` gets a minimal launchd environment. acp-inspector additionally recomputes `USER`/`HOME`/`SHELL` from the passwd DB for exactly this reason (`shell-env.ts:42-58`). Then append Gold Band's list: `~/.local/bin`, `~/.cargo/bin`, `~/.volta/bin`, **every `~/.nvm/versions/node/*/bin`**, `/opt/homebrew/{bin,sbin}`, `/usr/local/{bin,sbin}` (`process.rs:200-262`). Cache the probe once (Gold Band's `OnceLock`, `process.rs:40-41`); budget 5–10 s and a hard timeout.
**I.4 Race `initialize` against process death, always.**
`Promise.race([initialize, processClosed])` in emdash (`acp-agent-connection.ts:114-117`, error text `:168-173`); Jockey's 4-way `tokio::select!` over initialize / 30 s timeout / `child.wait()` / health-watch, with the stderr tail attached to `process_crashed` (`cold_start.rs:298-331`, `:239-261`). In Swift: `withThrowingTaskGroup`, first-to-finish wins, and keep a rolling stderr tail to put in the error. Also stat-validate `cwd` before spawning (acp-inspector `:72-79`) — ENOENT is otherwise indistinguishable from a missing binary.
**I.5 Own the process group and persist the pgid.**
`posix_spawn` with `POSIX_SPAWN_SETPGROUP` (or `setpgid` in a `posix_spawn_file_actions` fork), then SIGTERM the negative pgid → poll `kill(-pgid, 0)` for ~1 s → SIGKILL (emdash `process-tree-terminator.ts:64-141`; Jockey `pool.rs:50-63`). Then add what only Gold Band has: **write the pgid to a per-agent file and kill it on next launch** (`process.rs:603-637`, pid file at `storage/mod.rs:338`). emdash **[issue]** #2153 (21.1 GB of orphans) and claude-agent-acp **#1011** (the adapter leaks `claude` children across resumes) are the two receipts.
**I.6 Model the fold as four pure stages and assert invariants in debug builds.**
decode (stateless, ACP → your enum) → provider enrich → reducer (turn boundaries + session slices) → item fold (merge rules). Port the specific rules that took emdash real bugs to find:
- status `undefined` means "don't change" (`item-fold.ts:44-56, 219-225`);
- a `tool_call_update` for an unknown id **synthesizes** the row (`:736-749`);
- diffs replace the tool row with per-path `create-file`/`modify-file` items keyed `${toolId}:${path}` (`:365-435`); an `edit`-kind call with no diffs yet renders nothing (`:684`);
- open thinking auto-finalizes with a duration when any content arrives (`:349-363`);
- force-settle every `running` tool on turn close (`:785-824`) — this is the defence against claude-agent-acp **#1061** ghost tool calls;
- consecutive `read` calls collapse into a group (`:516-549`);
- rebuild nesting from `parentToolCallId` on every fold (`:551-596`) so late parents work.
In debug, throw on duplicate item id, unsorted/duplicate sibling `seq`, and >1 open thinking row (`reducer.ts:379-437`).
**I.7 Handle a missing `messageId` with synthesized segments.**
`auto:<stream>:<n>` counters that bump when the stream kind changes (`reducer.ts:181-221, 254-268`), plus Claude's thinking-id reuse workaround (`:223-246`). Otherwise separate thoughts merge into one blob.
**I.8 Add a quiescence timer and a lazy agent-initiated turn.**
Agent updates arriving while `ready` open a turn with `initiator: .agent` and arm a ~250 ms debounce that settles with reason `quiesced` (emdash `cell.ts:592-607`, `reducer.ts:536-539`). This is the concrete answer to claude-agent-acp **#864**, and it costs ~30 lines.
**I.9 Permissions: no host-side bypass; render the request as the tool row; publish pending state on the session.**
- Reuse the *already-rendered* tool row as the prompt body (`cell.ts:669-690`) — one renderer, no drift.
- Put `pendingPermissions` on the session model so the composer band, the sidebar count and the tool row's `awaitingPermission` all read one source (`models/session.ts:40, 70`; `tool.def.tsx:43`). emdash **[issue]** #531 is the lesson: a spinner reads as "busy", not "blocked on you".
- Default the primary button to `allow_once`, tone by `kind` prefix, show "1 of N" (`permission-band.tsx:48-60, 95-99`).
- Express risk appetite through the ACP **mode** selector (`config-derive.ts:88-93`), not a bypass flag — and expose mid-session mode changes (emdash **[issue]** #1671, and claude-agent-acp 0.71.0 "expose permission mode kinds" / codex-acp 1.7.0 "ACP v1 permission presentation" make this newly practical).
- If you *do* add "always allow", copy Jockey: an LRU keyed by a derived permission key, written **only** when the user picked an `allow_always` option (`worker/permission.rs:70-86`). Never infer it.
- Consider Gold Band's durability: write the pending request and its response as files so a crash doesn't lose the prompt (`permission.rs:36-48`). In Swift, a small on-disk queue plus a `CheckedContinuation` gives you both.
- Neutralize the settings-override problem: pass the equivalent of `settingSources: []` / an explicit initial mode after session creation (fazm extract §A.6; claude-agent-acp **#1056**).
**I.10 Implement client-side terminals — it is cheap and it is the difference between a chip and a live view.**
Declare `terminal: true`, implement the five methods, and copy emdash's `ManagedAgentTerminal`: byte-capped ring buffer that drops oldest and sets `truncated`, incremental UTF-8 decoding so multibyte chars never split, metadata-only snapshots, and **output on a separate capped channel that republishes the conversation only on lifecycle transitions** (`managed-terminal.ts:6-100`, `terminal-live-registry.ts:21-51`). Then link `execute-tool-call.terminalId` to the live stream (`execute.presenter.ts:23`). Note claude-agent-acp 0.63.0 changed terminal-meta keying to the announced `tool_use` id (#917), and the two codex-acp forks differ (`terminal_output` vs `terminal_output_delta`, **#166**).
**I.11 Resume: try `session/resume` first, then `session/load`, then fall back to `session/new` — and say which happened.**
Gold Band's `SessionRestoreIntent{ContinueOnly, SyncHistory}` × `SessionRestoreMethod{Resume, Load}` with capability gating and typed `RestoreUnsupported`/`HistorySyncUnsupported` errors (`client.rs:2163-2228`) is the right model. Both emdash (`session-materializer.ts:139-150`) and Jockey (`cold_start.rs:382-430`) independently fall back to a new session on load failure. Report the outcome (`resumeOutcome: 'loaded' | 'replaced-by-new'`) — emdash **[issue]** #1229 and #3093 are what happens when you don't. Re-apply model/mode/effort after every load (adapter **#845**, **#343**), and clear a retained value the provider no longer advertises rather than sending it (`session-materializer.ts:305-405`).
**I.12 Persist an explicit, versioned intent — never the environment.**
`{version, conversationId, providerId, cwd, sessionId?, configured{model,mode,effort,collaborationMode}, presentation{lastKnownCapabilities, lastKnownMcpServers, lastKnownUsage, observedAt}}` (`session-intent-schemas.ts:24-32`). "Provider environment, MCP credentials, runtime endpoints, and unknown descriptor fields are never persisted" (`acp-runtime.md:113-115`). On boot restore **index rows only** — never start an agent from disk (`acp-runtime.md:126-129`).
**I.13 Pool one agent process per `(providerId, cwd)` and fence routes by generation.**
Route key `"\(providerId):\(cwdIdentity):\(generation)"` (emdash `connection/source.ts:75-81`, `session-router.ts:144-146`) so a replacement process cannot inherit its predecessor's updates — Gold Band shipped the same fix as `fix(acp): fence stale provider lifecycle writes` (`CHANGELOG.md:42`). Serialize `session/load` handshakes per process (`session-materializer.ts:203-236`) and keep a provisional route for the load in flight so a **rebound session id still resolves** (`session-router.ts:134-141`). Reclaim idle connections (emdash 2 min, Jockey 300 s); idle out sessions after ~60 min without output.
**I.14 Ship a debug view. Capture at the frame layer.**
Insert a tap in the ndjson pipeline, not around typed SDK calls (acp-inspector `acp-connection-manager.ts:118-142`) — the log is then wire truth. Adopt `ProtocolMessage {localId, timestamp, direction, sessionId?, data}` where `data` is `JsonRpcRequest | JsonRpcResponse | ProcessEvent` — **fold stderr and process exit into the same stream** (`shared/types.ts:65-98`). Then fix its four gaps: a real ring buffer (not `slice(-500)`), an id→session map pruned on response and namespaced by direction, **pending-request tracking with elapsed time and unanswered-request surfacing**, and export. For export, copy emdash's `RawAcpLog` — dual entry/byte caps (50 000 / 16 MB), `{meta, events[]}` envelope, and **fixture-compatible so a captured session becomes a snapshot test** (`raw-log.ts:58-128`; fixtures at `impl/{claude,codex}/fixtures/acp-transcript.json`). Add Gold Band's size-rolling on-disk `acp.raw.jsonl` if you want post-crash forensics (`events.rs:1245-1364`).
**I.15 Design the public API around user intent, not lifecycle.**
Copy emdash's contract shape (`api/contract.ts:70-171`): `attach, launch, terminate, sendPrompt, editQueuedPrompt, deleteQueuedPrompt, changeQueuePromptOrder, cancelTurn, setOption, resolvePermission, loadHistory, export*` and observable state `sessions.list` + per-conversation `{state, config, usage, plan, agents, activeTurn, terminals, mcpServers}`. Two stated rules to keep: "there is no public `ensureActivation`, `start`, or `resume`" and "the public identity is always `conversationId`" (`acp-runtime.md:104-123`). Publish the derived booleans `isGenerating` / `canSubmit` / `canCancel` from the state machine so SwiftUI never re-derives them (`models/session.ts:51-56`).
**I.16 Queue prompts instead of rejecting them, and make the queue editable.**
A prompt sent while `working`/`cancelling`/`agentTurnActive`/`backgroundAgentCount > 0` becomes `PromptQueued` (`machine/machine.ts:120-132`), and queued prompts are editable/reorderable/removable API objects (`contract.ts:94-105`). Gold Band shipped the same feature (`CHANGELOG.md:278`).
**I.17 Build the prompt as image blocks first, then text, then hidden context.**
`[{type:"image",data,mimeType}…, {type:"text",text}, {type:"text",text:hiddenContext}]` (`cell.ts:508-519`). Flat image shape — not the Anthropic nested `source` form. Echo the user message into the transcript **before** the RPC.
**I.18 MCP: send `env` as `[{name,value}]`, gate http/sse on the agent's advertised `mcpCapabilities`, and verify your server actually appears.**
(`mcp-servers.ts:37-71`; capability source `acp-agent-connection.ts:119-122`.) claude-agent-acp **#883** is a live bug where a session-scoped stdio server never reaches the model — assert on `tools/list` before relying on injected tools.
**I.19 Auth: detect status, delegate login to the CLI. Do not run your own OAuth.**
Emdash's posture (`impl/claude/auth.ts:17-33`; login declared as `{kind:'cli-login', args:['auth','login']}`) versus fazm's own PKCE flow + Keychain writes (extract §A.9). The former has no policy exposure and no token-format risk. Report three states — authenticated / unauthenticated / **unknown** — and treat `unknown` as "let the agent try".
Pre-seed **trust** separately: write `projects[<cwd>] = {hasTrustDialogAccepted:true, hasCompletedProjectOnboarding:true}` into `.claude.json` (`impl/claude/trust.ts:7-36`), or the agent blocks on a dialog you can't see. Make that write visible and switchable (emdash **[issue]** #1944).
**I.20 Add your own idle watchdog on every await, and classify errors.**
claude-agent-acp **#1023** (dead stream, 26-minute hang with a third-party `ANTHROPIC_BASE_URL`), **#970/#896/#825** (unanswered `session/prompt`). Map the raw failures to user-facing strings the way Jockey does — rate limit, auth, crash, timeout, EPIPE, corrupt npx cache, missing `--experimental-acp`, model incompatibility (`adapter.rs:329-390`) — and give each a retryability flag, which Jockey itself admits it lacks (`docs/acp_references/README.md:41-47`).
**I.21 Adopt the registry schema for the agent catalog, pin versions, and record provenance.**
Gold Band's `resources/agent-catalog.json` is the model: `{schemaVersion, source:{url, registryVersion, fetchedAt}, agents:[{id,label,version,command,args,env,primaryAgentDir,supportsSystemPrompt,supportsExternalSessionSync}]}` compiled in via `include_str!` and validated at load (`agent_catalog.rs:6-70`). Jockey's `acp-registry.latest.json` gives the richer upstream `distribution` union (binary/npx/uvx). **Pin exact versions** — codex-acp **#411** shows `@latest` can lag a release. And check the Gemini flag: Gold Band's 2026-09-01 snapshot says `--acp` for `@google/gemini-cli@0.57.0`, while fazm and Jockey still pass `--experimental-acp`.
**I.22 One actor per connection; one serialized queue per session.**
Jockey's single-threaded `LocalSet` + per-key mutex + user-visible "queue position N" (`worker/mod.rs:35-50`, `worker/handlers.rs:740-773`) maps directly onto a Swift `actor` per connection with an `AsyncStream` of commands. Add cold-start dedup (one shared in-flight connect per key, `pool.rs:145-153`) and, if startup latency matters, prewarm (`session/prewarm.rs:172-206`).
**I.23 Things nobody has built that you could.**
Across all four repos there is **no protocol-log export from the inspector, no replay/re-send of a captured frame, no unanswered-request/timeout surfacing, no per-request latency, no worktree isolation per session (Jockey's is an explicit placeholder), and no real token/cost accounting**. Emdash has the export and the fixtures; acp-inspector has the view; nobody has both. A Swift host with a frame-level tap, a ring buffer, request/response pairing with latency, unanswered-request highlighting, and one-click export-to-fixture would be the best ACP debug surface in the ecosystem — and it is maybe 600 lines.
---
## J. Contradictions and open questions
1. **Gemini's ACP flag.** Gold Band's registry snapshot (2026-09-01) says `npx -y @google/gemini-cli@0.57.0 **--acp**` (`resources/agent-catalog.json`), while Jockey (`adapter.rs:119-127`) and fazm (extract §A.2) both pass `--experimental-acp`, and Jockey even probes `--help` for that string. Version-dependent. **Resolve by running `gemini --help` against the pinned version before wiring it.**
2. **Two different `codex-acp` products.** emdash bundles `@agentclientprotocol/codex-acp/dist/index.js` as CJS with `@openai/codex` external (`impl/codex/adapter.ts:3-8`); fazm shells the Rust binary from `@zed-industries/codex-acp-darwin-arm64` (extract §A.2); Jockey pins `@agentclientprotocol/codex-acp@0.0.40` (`adapter.rs:128-136`) which is a *pre-1.0* version string that does not appear in the current release list. codex-acp **#166** confirms the two forks differ on the terminal wire shape. **Decide which fork, and pin it.**
3. **Terminals: client-side or agent-side?** emdash and Jockey declare `terminal`; Gold Band and fazm do not. Gold Band's product still shows terminal output (via `session/update` content), so declaring the capability is not required to render commands — it is required to *own* them. Unresolved which gives the better UX; emdash's live-log channel is the more impressive artifact.
4. **`session/resume` — spec or extension?** Gold Band treats it as a spec method gated on `sessionCapabilities.resume` in `agent-client-protocol-schema` 1.6.0 (`client.rs:2194-2205`); fazm documented it as a **non-standard** Claude-adapter method (extract §A.3); emdash only ever calls `loadSession`. The schema crate is the more recent evidence, but I did not read the ACP v1 schema directly in this lane. **Open: confirm `sessionCapabilities.resume` in the current published schema.**
5. **Login-shell probe: `-ilc` or `-lc`?** emdash and acp-inspector both use **interactive** login (`-ilc`), Jockey uses `-lic`, fazm uses no shell at all. Interactive sourcing picks up `~/.zshrc` (where most people put nvm) but also runs prompt frameworks and can be slow or emit noise. All three that probe use a 5–10 s timeout. **Unresolved which is right for a signed, sandboxed-off `.app`.**
6. **`TERM`: `xterm-256color` or `dumb`?** emdash sets the former (`agent-env/api/index.ts:183`), acp-inspector the latter (`acp-connection-manager.ts:83`). If a tool's output reaches your UI unfiltered, `dumb` avoids ANSI; if the agent runs a TUI-ish command, `dumb` degrades it. Neither repo justifies its choice.
7. **Is emdash's 60-minute session idle-out right?** `SESSION_IDLE_MS = 60 * 60_000` for sessions vs `ACP_CONNECTION_IDLE_TTL_MS = 120_000` for connections (`worker-spec.ts:14`, `session-lifecycle/api/index.ts:35`). Jockey reclaims at 300 s. fazm's comment says leaving warm sessions alive forever "was the structural cause of the CPU regression reported 2026-05-14" (extract §A.3). Three different answers; the right one probably depends on whether the agent process is shared.
8. **No permission timeout anywhere except fazm.** emdash, Gold Band and acp-inspector will all block a turn indefinitely on an unanswered permission. fazm's upstream gate uses 300 s → `cancelled` (extract §A.6). For an unattended/headless SnappyOS run this matters; for an interactive one, an auto-cancel could silently kill long work. **Product decision, not a technical one.**
9. **Nobody measures cost.** Only fazm's *patched* adapter captures `total_cost_usd`, by monkey-patching the SDK iterator (extract §A.2). Over stock ACP, `usage_update` gives context and an optional cost amount, codex-acp under-reports (**#447**), and `usage_update` never carries the effective model id (**#1021**). **A trustworthy per-run cost number is currently not obtainable through stock ACP.**
10. **Emdash has no Gemini provider.** 23 ACP providers, no Gemini CLI. Whether that is a licensing, quality or priority decision is not recorded in the repo. It means the best-engineered client in this survey offers **no precedent** for the third agent Robert wants to host.
11. **Gold Band's 11 845-line `client.rs`.** I read its `initialize`, restore-plan and external-sync surfaces but not the whole file; there is more there on branch/fork semantics, cancellation convergence and nested-agent transcripts than this report captures. If the Swift host adopts branches/forks, that file is the deepest prior art available.
12. **Zed and Agmente are unread here** (owned by other lanes). Jockey's `docs/acp_references/README.md:22-37` maps its own modules onto Zed's `crates/agent_servers/src/acp.rs` with line numbers — that table is the cheapest way to cross-check this report's conclusions against the reference client.
Date: 2026-09-02. Scope: how a native macOS app (Swift + bundled Node) can host Claude Code, Codex CLI, Gemini CLI as embedded agents with the agent's steps, tool calls and permission prompts rendered in the app's own UI. Source of truth for Section A is fazm's acp-bridge (read-only; nothing was built or run). Web claims in B–F carry a URL and a tag: [docs] = verified in official docs/README, [blog] = third-party write-up, [issue] = GitHub issue/PR, [not-found] = could not verify.
Local paths use the prefix FAZM=/Users/robertboulos/projects/fazm.
Fazm.app (Swift)
└─ spawn: <bundled node> acp-bridge/dist/index.js ← "the bridge" (JSON-lines, custom protocol)
└─ spawn: <same node> dist/patched-acp-entry.mjs ← ACP agent = @agentclientprotocol/claude-agent-acp 0.29.2 (JSON-RPC over stdio)
└─ spawn (inside the adapter, by @anthropic-ai/claude-agent-sdk 0.2.112): the `claude` CLI subprocess
└─ MCP servers spawned by the SDK: fazm_tools (node), playwright (node), macos-use, whatsapp, google-workspace (python), assrt, composio (http)
└─ spawn (lazy): node_modules/@zed-industries/codex-acp-darwin-arm64/bin/codex-acp ← Codex, native binary, ACP over stdio
└─ spawn (lazy, flag-gated): <same node> node_modules/@google/gemini-cli/bundle/gemini.js --experimental-acp ← Gemini, ACP over stdio
└─ listen: unix socket $TMPDIR/fazm-tools-<pid>.sock ← fazm_tools MCP server dials back here to reach Swift
FAZM/acp-bridge/src/index.ts:1-27 ("translates between Fazm's JSON-lines protocol and the Agent Client Protocol (ACP) used by claude-code-acp… Spawn claude-code-acp as subprocess (JSON-RPC over stdio)").@agentclientprotocol/claude-agent-acp 0.29.2, @zed-industries/codex-acp 0.12.0, @google/gemini-cli ^0.42.0, @playwright/mcp 0.0.73 (FAZM/acp-bridge/package.json:16-24). Lockfile resolves the nested SDK to @anthropic-ai/claude-agent-sdk 0.2.112 and @agentclientprotocol/sdk 0.19.0 (FAZM/acp-bridge/package-lock.json:25-49).FAZM/acp-bridge/src/protocol.ts (inbound types query|tool_result|stop|interrupt|force_interrupt|close_session|authenticate|warmup|resetSession|transferSession|cancel_auth|forkSession|codex_*|gemini_init_probe, protocol.ts:207-224; outbound text_delta|tool_use|tool_activity|tool_result_display|thinking_delta|text_block_boundary|result|error|auth_required|…|session_meta_update|session_forked, protocol.ts:700-746). Bridge ⇄ agents is ACP (JSON-RPC 2.0, one JSON object per line) — acpRequest() builds {jsonrpc:"2.0", id, method, params} (index.ts:1513-1544), acpNotify() omits id (index.ts:1546-1555).Claude Code (via the ACP adapter, patched):
spawn(process.execPath, [join(__dirname,"patched-acp-entry.mjs")], { env, stdio:["pipe","pipe","pipe"], detached:true }) — index.ts:1557-1585. process.execPath is the bundled Node that runs the bridge itself, so no PATH lookup is ever done for node.process.env, deletes CLAUDECODE ("so the ACP subprocess (and the Claude Code it spawns) don't inherit the nested-session guard. Without this, --resume silently fails when Claude Code detects it's being launched from inside another Claude Code session"), sets NODE_NO_WARNINGS=1 (index.ts:1558-1567). Three auth modes are inferred purely from env: FAZM_CUSTOM_API_ENDPOINT=true → "Mode C", ANTHROPIC_API_KEY present → "Mode A (Fazm API key)", neither → "Mode B (Your Claude Account / OAuth)" (index.ts:1574-1580). CLAUDE_CODE_USE_VERTEX is allowed to flow through (index.ts:1561).ClaudeAcpAgent, runAcp from @agentclientprotocol/claude-agent-acp/dist/acp-agent.js and monkey-patches ClaudeAcpAgent.prototype.createSession and .prompt before calling runAcp() (FAZM/acp-bridge/src/patched-acp-entry.mjs:17, 54-58, 232-234, 281). The patch (a) wraps the SDK query.next() iterator to capture total_cost_usd, usage, modelUsage, terminal_reason, errors from type:"result" messages (patched-acp-entry.mjs:73-93) and (b) re-emits SDK events the stock adapter drops — compact_boundary, status, task_started, task_notification, api_retry, rate_limit_event, tool_progress, tool_use_summary, and compaction stream_events — as custom session/update kinds (patched-acp-entry.mjs:95-228). prompt() is patched to return usage + _meta.costUsd on the ACP PromptResponse (patched-acp-entry.mjs:236-278).session/new {cwd} (index.ts:3986-3990). Default cwd is homedir() (index.ts:3255).Contents/Resources/Fazm_Fazm.bundle/node and signed with --options runtime --entitlements Desktop/Node.entitlements (FAZM/run.sh:657-663); the bridge's dist/ + full node_modules/ are rsynced into Contents/Resources/acp-bridge/ (run.sh:296-306). Bundled MCP binaries are at Contents/MacOS/mcp-server-macos-use and Contents/MacOS/whatsapp-mcp, resolved relative to process.execPath (index.ts:213-217).Codex (via codex-acp, a native Rust binary shipped in an npm platform package):
node_modules/@zed-industries/codex-acp-darwin-{arm64|x64}/bin/codex-acp (FAZM/acp-bridge/src/codex-provider.ts:62-68).spawn(binaryPath, [], { env, stdio:["pipe","pipe","pipe"], detached:true }) (codex-provider.ts:150-155). Env is process.env as-is (codex-provider.ts:96).initialize is sent with clientCapabilities: { fs: { readTextFile:false, writeTextFile:false } } (codex-provider.ts:219-222) — i.e. fazm declines the client-side FS methods.~/.codex/auth.json; fazm reads auth_mode from it to report chatgpt|api_key|none (index.ts:1232-1243) and reimplements the Codex OAuth PKCE flow itself (client id app_EMoamEEZ73f0CkXaXp7hrann, https://auth.openai.com/oauth/authorize, scopes openid profile email offline_access api.connectors.read api.connectors.invoke) writing ~/.codex/auth.json (FAZM/acp-bridge/src/codex-oauth-flow.ts:1-30). After login it must kill and respawn codex-acp because "the existing subprocess was spawned without auth and won't re-read auth.json on its own" (index.ts:1418-1428).Unhandled error during turn: after stripping ANSI (codex-provider.ts:82-86, 259-274).Gemini CLI (native ACP mode):
spawn(process.execPath, [node_modules/@google/gemini-cli/bundle/gemini.js, "--experimental-acp"], { env: {...env, GEMINI_CLI_TRUST_WORKSPACE:"true"}, stdio: pipes, detached:true }) (FAZM/acp-bridge/src/gemini-provider.ts:63-69, 194-213).GEMINI_CLI_TRUST_WORKSPACE=true is required because "gemini-cli silently skips MCP server registration when the workspace isn't in ~/.gemini/trustedFolders.json" (gemini-provider.ts:196-203).authenticate {methodId} after initialize; fazm picks vertex-ai if GOOGLE_GENAI_USE_VERTEXAI, else gemini-api-key if GEMINI_API_KEY|GOOGLE_API_KEY, else refuses — "OAuth-personal requires an interactive browser flow that's hostile to a background subprocess; refuse rather than hang" (gemini-provider.ts:74-86, 306-317).FAZM_GEMINI_ENABLED=true (index.ts:1325-1344).All three are spawned detached:true so each becomes a process-group leader and the whole tree can be killed with process.kill(-pid, "SIGTERM") (index.ts:1185-1210, codex-provider.ts:188-199, gemini-provider.ts:249-260).
initialize {protocolVersion:1} → stores authMethods from the result (index.ts:2380-2415). An auth error is ACP code -32000, or -32603 whose message matches /401|failed to authenticate/ ("ACP sometimes wraps 401 as a generic -32603 internal error") (index.ts:1802-1811).{type:"warmup", cwd, sessions:[{key, model, systemPrompt, resume?}]} (protocol.ts:107-119). Bridge calls session/new {cwd, mcpServers, _meta:{claudeCode:{options:{disallowedTools:[…]}}, systemPrompt}} (index.ts:3474-3478, buildMeta at index.ts:2966-2980), then session/set_model {sessionId, modelId} (index.ts:3567). Warmup has a 240 s hard ceiling (45 s in custom-endpoint mode) (index.ts:3210-3224). The result's models.availableModels is forwarded to Swift as models_available (index.ts:3530-3531, 3128).session/resume {sessionId, cwd, mcpServers} (index.ts:3517-3521, 3949-3953); Codex and Gemini use the spec's session/load (codex-query.ts:140-144, gemini-query.ts:465-469). After any resume fazm re-sends session/set_model because "without this the session uses the SDK default (possibly Haiku)" (index.ts:3524-3526, 3966-3967).~/.claude/projects/<cwd with non-alphanumerics → '-'>/<sessionId>.jsonl; passing a different cwd than at creation makes resume fail with "Resource not found" (index.ts:2000-2018, encoder at 3271-3273). fazm therefore (1) persists sessionId→cwd in ~/.fazm/acp-sessions.json (index.ts:2019-2030), (2) extracts "cwd": from the JSONL as a backstop (index.ts:2097), (3) pre-checks the JSONL exists to skip "phantom" ids that were handed out but never wrote a turn (index.ts:3292-3348, 3908-3925), and (4) physically moves the JSONL between project dirs when a window's cwd changes (migrateJsonlForCwdChange, index.ts:3366-3387). Codex transcripts live at ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl (index.ts:3323-3345).session/fork {sessionId, cwd, mcpServers} on the Claude adapter (index.ts:5553-5557); protocol note says upstream unstable_forkSession "does not support mid-history anchors" (protocol.ts:158-167).session/close {sessionId} "instructs the SDK to terminate the claude subprocess" (index.ts:6940-6947 in main's close_session case; also on cwd change 3806-3818). Comment: leaving warm sessions alive forever "was the structural cause of the CPU regression reported 2026-05-14".gpt-|codex-|o[0-9] → Codex codex-query.ts:73; gemini-|auto-gemini- → Gemini gemini-query.ts:399); the foreign session is session/closed first (index.ts:3633-3676).session/prompt {sessionId, prompt:[…content blocks]} (index.ts:4515-4518). Attachments become ACP content blocks: images as flat {type:"image", data:<base64>, mimeType} — comment: "ACP expects flat {type, data, mimeType}, NOT the Anthropic API nested {source:…} format" (index.ts:4489-4495); PDFs are not inlined (ACP has no document type) — a text block tells the model to Read the path (index.ts:4496-4503); text files inlined up to 10 MB, images/PDF 20 MB, everything else path-only (index.ts:4443-4444).session/run_command RPC. Slash commands surfaced via available_commands_update execute by sending the literal slash text (e.g. /compact) as the prompt" (protocol.ts:182-186)._meta.systemPrompt on session/new (Claude adapter honours it); for Codex/Gemini fazm also prepends <system_instructions> as a text block on the first prompt "so behavior is consistent regardless of whether codex-acp honors the meta field today" (codex-query.ts:10-13, 81-99, 218-233).usage from PromptResponse.usage (spec, @experimental) with fallback to gemini-cli's _meta.quota.token_count; codex-acp 0.12.0 "surfaces nothing" so tokens are 0 (codex-query.ts:26-30, 256-264; gemini-query.ts:573-582).The bridge's handleSessionUpdate (index.ts:5602-6200) switches on params.update.sessionUpdate:
ACP sessionUpdate |
fazm → Swift | Notes / source |
|---|---|---|
agent_message_chunk |
text_delta (+ text_block_boundary after a tool) |
strips leaked harness prefixes (your turn — …) / <system-reminder> at turn start (index.ts:952-1053, 5640-5670) |
agent_thought_chunk |
thinking_delta |
5710-5717 |
tool_call |
tool_use + tool_activity{status:"started", input:rawInput} |
title recovery from _meta.claudeCode.toolName when title is "unknown"/contains "undefined" (WebSearch/WebFetch) 5725-5738; ToolSearch hidden from UI 5768 |
tool_call_update (status completed/failed/cancelled) |
tool_activity{completed} + tool_result_display{output} (2000-char truncation) |
output extracted from content[] — both direct MCP {type:"text"} and ACP-wrapped {type:"content", content:{type:"text"}} shapes — then rawOutput fallback, images skipped (5889-5925); isError/is_error flag honoured 5883 |
plan |
thinking_delta per entry |
6003-6015 |
available_commands_update |
available_commands_update |
6175-6193 |
usage_update, config_option_update, current_mode_update, session_info_update |
session_meta_update{kind,payload} (late-arrival rescue) |
index.ts:1716-1741 |
custom (patched adapter): compact_boundary, status_change, compaction_start/delta, task_started, task_notification, tool_progress, tool_use_summary, rate_limit, api_retry |
same-named Swift events | 6019-6172 |
Codex and Gemini share a simpler translator, translateCodexUpdate (FAZM/acp-bridge/src/acp-translate.ts:37-160), which the Claude path deliberately does not use yet (acp-translate.ts:9-16).
Diffs and terminals: fazm does not render ACP diff or terminal content blocks natively — tool results are flattened to text; content[].type==="diff" is never referenced in the bridge. Swift-side rendering is covered in A.11.
Local checkout (commit f10c620d, 2026-07-29): session/request_permission is answered in the stdout line handler, never surfaced to Swift: pick the option with kind==="allow_always", else allow_once, else the literal "allow", and reply {outcome:{outcome:"selected", optionId}} (index.ts:1616-1628, comment "Auto-approve all tool permissions (matches agent-bridge's bypassPermissions behavior)"). Codex and Gemini providers use the identical default resolver (codex-provider.ts:108-117, gemini-provider.ts:158-167).
What fazm does gate is its own tools: an ask|act mode env (FAZM_QUERY_MODE) makes execute_sql refuse non-SELECT in ask mode (fazm-tools-stdio.ts:826-842); in "observer" sessions writes are converted into an approval card row (approval_request with pending_operations) that the user approves in-app (fazm-tools-stdio.ts:891-920). The ask_followup tool is the "quick-reply buttons" primitive that blocks the turn until the user clicks (fazm-tools-stdio.ts:449-471, 600 s ceiling 163-172).
Also blocked: SDK tools that need a runtime fazm lacks — ScheduleWakeup, CronCreate/Delete/List, RemoteTrigger, Monitor, PushNotification — via _meta.claudeCode.options.disallowedTools ("Exposing them … produces silent end-of-turn dead-ends") (index.ts:2948-2964).
Upstream mediar-ai/fazm main (d3816032, 2026-09-02, v2.9.89) — verified via raw.githubusercontent.com: a new acp-bridge/src/approval-gate.ts ("cage mode") replaces the blanket auto-approve. Header comment: "Historically every provider (claude / codex / gemini) blanket-auto-approved tool permissions, which let the agent run destructive actions (rm, cache clears, file edits) without the user ever seeing them." FAZM_APPROVAL_MODE = off (default; "Headless runners … never set the var, so they stay on off and can never hang on approval") | destructive (gate only ACP toolCall.kind in edit|delete|move|execute) | always (approval-gate.ts:1-38). A gated request is parked and re-emitted to Swift as {type:"permission_request", id, toolCallId, title, kind, options}; Swift answers with a permission_response stdin command; APPROVAL_TIMEOUT_MS = 300_000 after which the bridge replies {outcome:{outcome:"cancelled"}} and emits permission_timeout (approval-gate.ts:108-175). The gate id is namespaced provider:rpcId:seq because "each provider subprocess has its own JSON-RPC id counter, so raw ids collide across providers" (:140-146). Two more pieces were required to make the gate actually fire against Claude: (1) session/set_mode {modeId:"default"} after every session registration, because "The adapter derives its initial permission mode from the user's Claude Code settings (permissions.defaultMode — often bypassPermissions on dev machines), which would silently allow everything and starve the gate" (upstream index.ts:1940-1951); (2) _meta.claudeCode.options.settingSources = [] for gated sessions so "a permissions.allow: ["Bash"] rule or defaultMode: bypassPermissions" in ~/.claude/settings.json cannot pre-approve tools (upstream index.ts:3029-3038). The same handler in the stdout loop now routes session/request_permission through approvalGate.handleRequest("claude", id, params, reply) (upstream index.ts:1643-1650). The local checkout is therefore ~5 weeks and one significant feature behind upstream; everything else in Section A was read from the local tree.
{type:"interrupt", sessionKey} → ctx.abortController.abort() + acpNotify("session/cancel", {sessionId}). Per ACP, "cancel ends the turn, not the session", and since claude-agent-acp 0.29.2 (fix for ACP #442) the cached session is kept and the next prompt continues in the same session (index.ts:6822-6846).acpRequest does not observe the abort signal, so the prompt is Promise.raced against an abortPromise, an inactivity "finalization-idle" arm, and (after an interrupt) a TTFT watchdog; otherwise a Stop during a 77 s Terminal tool left the await hanging ("May 5 2026 incident") (index.ts:4634-4670, 4715-4720).{type:"force_interrupt"} also SIGKILLs every descendant whose ps command matches /playwright/ because "ACP's session/cancel is cooperative and a wedged playwright tool ignores it" (index.ts:143-198, 6888-6938). Comment admits per-session targeting is impossible: "the SDK doesn't expose which playwright PID belongs to which session".fazm-tools-stdio.ts)#session/new's mcpServers: {name:"fazm_tools", command:process.execPath, args:[dist/fazm-tools-stdio.js], env:[FAZM_BRIDGE_PIPE, FAZM_QUERY_MODE, FAZM_WORKSPACE, FAZM_SESSION_KEY, …]} (index.ts:2495-2523).$TMPDIR/fazm-tools-<bridgePid>.sock, index.ts:1070-1183; client side fazm-tools-stdio.ts:55-120). A tools/call becomes {type:"tool_use", callId, name, input, sessionKey} on the socket (fazm-tools-stdio.ts:178-209); the bridge forwards it to Swift on stdout as tool_use (index.ts:1108-1128); Swift answers {type:"tool_result", callId, result} (protocol.ts:47-51) which flows back through the socket to resolve the MCP call (index.ts:1059-1068, 1131-1146)."2024-11-05", capabilities {tools:{}} (fazm-tools-stdio.ts:787-798). Tools are filtered per session type (onboarding / observer / regular / voice) by env vars (fazm-tools-stdio.ts:213-215, 617-630).fazm-tools-http.ts, did the same over a localhost HTTP MCP endpoint (FAZM/acp-bridge/src/fazm-tools-http.ts:1-8).~/.fazm/mcp-servers.json and — opt-out via FAZM_DISABLE_CLAUDE_CODE_MCP — the user's own ~/.claude.json mcpServers (both stdio and http shapes) (index.ts:2836-2946). HTTP MCP entries take {name, type:"http", url, headers:[{name,value}]} (index.ts:2476-2481); stdio entries take env as an array of {name,value}, not an object (index.ts:2469-2474).9d1c250a-e61b-44d9-88ed-5944d1962f5e, https://claude.ai/oauth/authorize, token URL https://console.anthropic.com/v1/oauth/token, scopes user:inference user:profile user:file_upload user:mcp_servers user:sessions:claude_code) and writes the result into the macOS Keychain generic password Claude Code-credentials as {claudeAiOauth:{accessToken, refreshToken, expiresAt, scopes, storedAt}} via security add-generic-password -U — i.e. the exact item the claude CLI reads (FAZM/acp-bridge/src/oauth-flow.ts:24-31, 380-420). Note: "Do NOT include expires_in in the token-exchange body… HTTP 400 … since mid-May 2026" (oauth-flow.ts:296-301). The callback server binds both 127.0.0.1 and ::1 because "Browsers using Happy Eyeballs often try ::1 first" (oauth-flow.ts:158-189). After OAuth the adapter subprocess is restarted to pick up the Keychain item (index.ts:2251-2270, 2346-2352).initialize or session/prompt triggers the flow with max 2 retries (index.ts:2238, 5161-5205). In "builtin key" mode a 401 instead emits builtin_key_invalid so Swift can refetch the key (index.ts:1823-1827).~/.codex/auth.json (A.2). Gemini: env only (A.2).ANTHROPIC_API_KEY for the Assrt MCP subprocess ("Fazm's policy is 'no API key handed to subprocesses'") (index.ts:2643-2648) but otherwise passes the full bridge env to every agent.process.ppid every 5 s; if it flips to 1 (launchd adopted us) walk pgrep -P descendants, SIGTERM each, kill(-pgid), exit. "Root cause of the 20+ orphan ACP bridges observed Apr 30 2026" (index.ts:110-135, patched-acp-entry.mjs:19-51). The ws-relay uses process.kill(ppid, 0) liveness instead (FAZM/acp-bridge/src/ws-relay.ts:76-84).uncaughtException, stdout.error, stderr.error (index.ts:6317-6350); stdin close → kill tree and exit (index.ts:7000-7006).index.ts:1768-1788).overloaded_error as rate_limit — treating it as credit exhaustion "is exactly what happened in production on 2026-05-14" (FAZM/acp-bridge/src/api-failure.ts:14-30). On genuine exhaustion the whole adapter subprocess is restarted (30 s cooldown) rather than scrubbing state (index.ts:1930-2000). Rate-limit events carry five_hour|seven_day type, utilization, resetsAt (protocol.ts:372-383).index.ts:275-281); a "stall detector" flags mcp__* tools silent > 15 s as tool_stalled without cancelling (index.ts:6650-6700); a "finalization-idle" arm rescues turns whose session/prompt never resolves after streaming (upstream claude-agent-acp #630) (index.ts:4527-4535, 5076-5140); a Task-subagent liveness watchdog inspects /private/tmp/claude-501/<cwd-dashed>/<uuid>/tasks/<task-id>.output file growth (index.ts:498-532, 565-600).index.ts:6278-6316). SIGUSR2 dumps state to /tmp/fazm-bridge-state-<scope>.json (index.ts:6210-6276).~/Library/Logs/Fazm/acp-bridge.log with 10 MB rotation because "In prod, bridge stderr goes to the Swift parent via a pipe and is never persisted" (index.ts:69-108); an 80-line ring of adapter stderr is attached to warmup_complete failures (index.ts:1440-1456); MCP tool audit lines go to /tmp/fazm-mcp-audit.jsonl (index.ts:869-893).cron-runner.mjs spawns the identical bridge (node --max-old-space-size=512 dist/index.js, FAZM_HEADLESS=1, CLAUDECODE deleted) and drives it with init → warmup → query → result (FAZM/acp-bridge/src/cron-runner.mjs:168-185).Swift never speaks ACP. It spawns exactly one long-lived Node process (the bridge) and exchanges the custom newline-JSON envelope of A.1 over plain Pipe()s. Every ACP method name in the Swift tree appears only in comments.
Spawn — ACPBridge.start() (FAZM/Desktop/Sources/Chat/ACPBridge.swift:550-679):
swiftlet proc = Process()
proc.executableURL = URL(fileURLWithPath: nodePath)
proc.arguments = ["--max-old-space-size=256", "--max-semi-space-size=16", bridgePath]
proc.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory()) // ACPBridge.swift:588-597
cwd is pinned to $HOME because LaunchServices "often" hands the app /private/var/folders/... when launched from Finder or a LaunchAgent (ACPBridge.swift:592-596). stdin/stdout/stderr are three Pipe()s (:606-617). No WebSocket/TCP/Unix socket is used between Swift and the bridge.
Node resolution ladder — findNodeBinary() (ACPBridge.swift:2537-2590): (1) bundled node from Bundle.resourceBundle (= Contents/Resources/Fazm_Fazm.bundle, FAZM/Desktop/Sources/BundleExtension.swift:9-32), copied to a temp dir first (below); (2) /opt/homebrew/bin/node, /usr/local/bin/node, /usr/bin/node; (3) ~/.nvm/versions/node/* newest; (4) /usr/bin/which node. No login shell is ever run to recover PATH — there is no zsh -l/-lc anywhere in the Swift sources. The bundled Node is v22.14.0 downloaded at build time (FAZM/build.sh:41-69).
The /tmp-copy workaround — NodeBinaryHelper (FAZM/Desktop/Sources/Chat/NodeBinaryHelper.swift:1-95): "On macOS 26+ (Tahoe), Sparkle auto-updates can silently corrupt the code signing seal of the bundled node binary. The kernel's Code Signing Monitor (CSM) then kills the process with SIGKILL on launch. The binary passes codesign --verify but still gets killed" (:3-13). Node is copied to NSTemporaryDirectory()/fazm-node-<bundleScope> (:34), bundle-scoped because a dev build once clobbered prod's temp node and "the next prod ACP-bridge spawn got SIGKILL'd and the chat hung forever" (:20-27); verified by running node --version (:73-94). Because node may then run from /tmp, Swift passes FAZM_RESOURCES_PATH=Bundle.main.resourcePath so the bridge can still find bundled MCP binaries (ACPBridge.swift:2490-2492, consumed at index.ts:213).
Bridge script resolution — findBridgeScript() (ACPBridge.swift:2653-2686): Bundle.main.resourcePath/acp-bridge/dist/index.js, then dev-tree fallbacks.
Environment — makeBridgeEnvironment() (ACPBridge.swift:2362-2533) starts from ProcessInfo.processInfo.environment and sets: NODE_NO_WARNINGS=1; ANTHROPIC_API_KEY removed in personal-OAuth mode / set in bundled-key mode (:2367-2369); PATH gets node's dir prepended, with fallback default "/usr/bin:/bin" (:2372-2377); FAZM_BUNDLE_SCOPE, FAZM_BROWSER_MODE, FAZM_DISABLE_CLAUDE_CODE_MCP, FAZM_ASSRT_ENABLED, FAZM_SELECTED_MODEL, GEMINI_API_KEY (only when FAZM_GEMINI_ENABLED=true), PLAYWRIGHT_USE_EXTENSION, PLAYWRIGHT_MCP_EXTENSION_TOKEN, ANTHROPIC_BASE_URL/FAZM_CUSTOM_API_ENDPOINT, FAZM_TOOL_TIMEOUT_SECONDS, FAZM_RESOURCES_PATH, FAZM_AUTH_TOKEN (Firebase id token), FAZM_COMPOSIO_TOOLKITS. HOME and TERM are inherited, never set.
Framing — outbound sendLine appends \n (ACPBridge.swift:1535-1545); inbound is a detached Task looping on FileHandle.availableData and splitting on 0x0A by hand (:1547-1577). stderr is read via readabilityHandler and screen-scraped for OOM markers (FatalProcessOutOfMemory, JavaScript heap out of memory) and for the bridge's own Tool started: <name> (id=…, kind=…, session=<key>) log lines to attribute tool activity per session (:620-652, :634-638, parser :2186-2195).
Message types Swift handles — the 43-case enum InboundMessage (ACPBridge.swift:212-277) mirrors protocol.ts exactly; unknown types are logged and dropped (:1851-1853). The outbound authenticate message is defined but never sent (:861-872).
UI model — enum ChatContentBlock = .text | .toolCall(id,name,status,toolUseId,input,output) | .thinking | .discoveryCard | .observerCard | .systemEvent | .browserActivity (FAZM/Desktop/Sources/Providers/ChatProvider.swift:122-157); ToolCallStatus has only .running and .completed — no failed/rejected state (:276-279). There are no Swift types for ACP diff, terminal, or permission options; Bash/Terminal output is a generic chip with a summary string (:208-213).
Permission UX — none for agent tools (zero hits for allow_once|allow_always|reject_once|permissionMode|bypassPermissions|acceptEdits). The only gates are ChatMode.ask|.act sent as mode (ChatProvider.swift:499-503, :4807), the TCC-permission onboarding tool confusingly named request_permission (FAZM/Desktop/Sources/Providers/ChatToolExecutor.swift:71-74), and observer cards that are auto-approved with a Deny-to-rollback affordance (FAZM/Desktop/Sources/MainWindow/Components/ChatUIComponents.swift:648-652).
Lifecycle —
stop() sends {type:"stop"}, closes stdin, then kills the tree before proc.terminate() (ACPBridge.swift:688-715). killProcessTree walks /usr/bin/pgrep -P depth-first and SIGTERMs bottom-up because "The ACP subprocess creates its own process group, so kill(-pid) only reaches direct children — grandchildren (MCP servers) survive and become orphans" (:700-703, :720-753).sweepOrphanedBridges() on every start: ps -axo pid=,ppid=,command=, match acp-bridge/dist/index.js, patched-acp-entry.mjs, codex-acp-darwin, /codex-acp with PPID==1, SIGTERM → 1 s → SIGKILL, because "the patched-acp-entry process and the underlying claude CLI register their own SIGTERM handlers that try to 'gracefully shut down' by flushing IPC. When orphaned to launchd they have no functional parent… 14 of 20 swept orphans survived SIGTERM and only died on SIGKILL" (:763-856, :841-846).ACPBridge.swift:779-782).terminationHandler with a generation counter so a stale process's exit can't clobber a restarted one (:654-665, :2275-2310); exit codes 133/134/5/6 classified as OOM (:2298-2303). deinit resumes any pending continuation to avoid "SWIFT TASK CONTINUATION MISUSE" (:538-545).stop()+start() (:682-685), triggered by settings changes (ChatProvider.swift:1258-1322), mode switch (:1023), OAuth (:2488-2496). applicationWillTerminate → stopBridge() (FAZM/Desktop/Sources/FazmApp.swift:1226).ACPBridge.swift:1130-1168, :1141-1147).ChatProvider.swift:3558-3583).Agent selection is by model-id prefix, mirrored from the bridge regexes (ChatProvider.swift:2406-2434); the model picker merges Claude models_available + Codex/Gemini probe results (FAZM/Desktop/Sources/FloatingControlBar/ShortcutSettings.swift:261-359). Claude credentials are checked by shelling to /usr/bin/security find-generic-password -s "Claude Code-credentials" (ChatProvider.swift:2573-2578) — the only real SecItem* use is the custom-endpoint key (FAZM/Desktop/Sources/Chat/CustomAPIEndpointCredentials.swift:20-81). The app "lacks keychain-access-groups entitlements, so SDK keychain writes fail" for Firebase (FAZM/Desktop/Sources/AuthService.swift:77-78).
Entitlements / signing (FAZM/Desktop/*.entitlements):
Fazm.entitlements / Fazm-Release.entitlements: com.apple.security.app-sandbox = false, automation.apple-events, device.audio-input, device.screen-capture (+ get-task-allow in dev). App Sandbox is OFF.Node.entitlements: com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory (V8 JIT).Python.entitlements: cs.allow-dyld-environment-variables + cs.disable-library-validation.--options runtime (hardened runtime); node signed with Node.entitlements (FAZM/run.sh:657-663, FAZM/codemagic.yaml:800-806); every *.node/*.dylib/*.so/rg under node_modules signed individually (codemagic.yaml:808-816); a 16 K page-size gate on the node binary — "macOS 26 will crash" otherwise (codemagic.yaml:919-924).PTY / ANSI — nothing: no forkpty|openpty|posix_openpt, no TERM, no escape stripping in Swift (word-boundary grep over 110 files). The child sees non-TTY pipes; any ANSI in tool output would reach the UI unfiltered.
| Where | What it says (paraphrased, with the literal marker) | |||
|---|---|---|---|---|
index.ts:110-116 |
Parent-death watchdog: "Root cause of the 20+ orphan ACP bridges observed Apr 30 2026." | |||
index.ts:498-517 |
Subagent watchdog: "known upstream gap — see agentclientprotocol/claude-agent-acp #336 / #497 / #603 / #630 and anthropics/claude-code #44783 / #58637. No upstream fix has shipped (as of May 13 2026). Maintainer's recommended workaround is 'don't use background tasks for long work'." | |||
index.ts:846 |
"creating an infinite uncaughtException loop (see orphan bug)" — send() swallows EPIPE. |
|||
index.ts:950-951 |
stripHarnessPrefix: "fixes the chunk-boundary bug for a lone '(' first delta". |
|||
index.ts:1481-1510 |
Debug flag files that reproduce upstream #630 (/tmp/fazm-debug-drop-prompt-result, …-empty, …-compaction-stall, …-poison-empty-resume). |
|||
index.ts:1562-1565 |
Delete CLAUDECODE env or --resume silently fails inside a nested Claude Code. |
|||
index.ts:1629-1633 |
session/update "can also arrive as a request (with id)" — must be acked. |
|||
index.ts:1673-1690 |
[ROUTE-MISS] — "the signature of the cross-session routing bug we're hunting." |
|||
index.ts:1700-1741 |
available_commands_update fires right after session/new, before any handler exists; late `config_option_update |
current_mode_update | session_info_update | usage_update` from codex-acp are rescued. |
index.ts:1808-1810 |
"ACP sometimes wraps 401 as a generic -32603 internal error." | |||
index.ts:1830-1832 |
Playwright on Retina produces >2000 px screenshots that hit Claude's image limit; a watcher resizes in place. | |||
index.ts:2000-2018 |
Resume fails with "Resource not found" if cwd differs; priorContext replay "path itself has bugs (leaked [Interrupted] turns, stale conversation_history…)". |
|||
index.ts:2125-2190 |
[POISON-FIX-PLAN]: credit exhaustion poisons other sessions (end_turn at 0 ms); fixed by restarting the subprocess; Opus 4.7 pattern-matched User:/Assistant: labels and emitted (your turn — …) literally. |
|||
index.ts:2440-2444 |
Concurrent initializeAcp guard (preWarm + query racing after OAuth restart). |
|||
index.ts:2751-2760 |
Google Workspace MCP registered only when connected — "100+ tool schemas… bloated the prompt prefix (and cost)". | |||
index.ts:2795-2798 |
PYTHONDONTWRITEBYTECODE=1 — .pyc files "invalidate the code signature and break Sparkle auto-updates". |
|||
index.ts:2956-2964 |
Disallowed SDK tools produce "silent end-of-turn dead-ends". | |||
index.ts:3104-3126 |
Model aliases must be canonicalised before session/set_model (adapter's substring resolver). |
|||
index.ts:3203-3222 |
A hung MCP spawn hangs session/new for the whole warmup ceiling. |
|||
index.ts:3806-3818 |
Each cwd change left "an orphaned claude SDK process running at 70-90% CPU forever"; must session/close first. |
|||
index.ts:3882-3893 |
Resuming a previously-interrupted session replayed the cancelled prompt's chunks (Apr 29 2026, ACP #442, fixed 0.29.2). | |||
index.ts:3897-3903 |
"the fix for the resume-after-bridge-restart bug": cwd must match at resume. | |||
index.ts:4489-4490 |
ACP image block is flat {type,data,mimeType}, not Anthropic's nested source. |
|||
index.ts:4527-4535, 4634-4652 |
session/prompt may never resolve (#630); acpRequest doesn't observe AbortSignal; "we can't kill SDK-spawned subprocesses from here". |
|||
index.ts:4960-4966 |
L2a/L2b May 12 2026 incidents. | |||
index.ts:5076-5090 |
On extended-thinking models a mid-thinking cancel leaves an unsigned thinking block; reuse → 400 … thinking … blocks … cannot be modified. |
|||
index.ts:5625-5633 |
NOTE: never clear watchdogs on text chunks — text streams while tools are in flight; caused 180 s inactivity timeouts. | |||
index.ts:5739 |
ToolSearch boundary logic "fixes the onboarding bubble-concatenation bug". | |||
index.ts:6278-6290 |
SIGHUP graceful restart — SIGTERM "self-bricked" a session (2026-05-20). | |||
index.ts:6997-7000 |
NOTE: SIGHUP deliberately not in the kill-list. | |||
patched-acp-entry.mjs:11-15 |
Redirect console.log/info/warn/debug to stderr — stdout is the protocol channel. |
|||
oauth-flow.ts:296-301 |
NOTE: custom expires_in now rejected (HTTP 400) for the Claude Code scopes. |
|||
gemini-provider.ts:88-106, 196-203 |
gemini-cli 0.42.0 emits session/update under a sessionId that doesn't match session/new's; rescued when exactly one prompt is in flight. Trust-folder env var. |
|||
codex-provider.ts:82-86 |
codex-acp reports only "Internal error"; reason lives on stderr (ANSI). | |||
acp-translate.ts:9-16 |
NOTE: Claude path intentionally not unified with the shared translator. | |||
protocol.ts:182-186 |
Note: no session/run_command; slash commands are prompts. |
|||
scripts/patch-playwright-overlay.cjs:1-40 |
Playwright addInitScript doesn't work on CDP-connected contexts; patched at postinstall. |
|||
run.sh:301-306 |
Nested duplicate @anthropic-ai/claude-agent-sdk shadowed the top-level one → SyntaxError … filterEscalatingDefaultMode; use rsync --delete. |
zed-industries/agent-client-protocol URL redirects). Packages: npm @agentclientprotocol/sdk 1.4.0 (2026-08-20); Rust crates agent-client-protocol + agent-client-protocol-schema; also Kotlin/Java/Python SDKs. Apache-2.0. [docs] https://github.com/agentclientprotocol/agent-client-protocol. The old npm names are deprecated redirects: @zed-industries/agent-client-protocol@0.4.5 → @agentclientprotocol/sdk; @zed-industries/claude-code-acp@0.16.2 → @agentclientprotocol/claude-agent-acp [docs] npm registry 2026-09-02.protocolVersion: 1. Schema artifacts: schema/v1/schema.json and a v2 draft (2026-07-20) — "do not ship v2 by default"; TS SDK exposes it only under @agentclientprotocol/sdk/experimental/v2. "Use the negotiated protocolVersion", not the crate/schema version [docs] https://agentclientprotocol.com/announcements/acp-v2-draft, repo README. Docs index: https://agentclientprotocol.com/llms.txt (v1 pages under /protocol/v1/*).\n), and MUST NOT contain embedded newlines" (NDJSON); agent "MAY write UTF-8 strings to its stderr for logging"; "The agent MUST NOT write anything to its stdout that is not a valid ACP message"; "The client MUST NOT write anything to the agent's stdin that is not a valid ACP message" [docs] https://agentclientprotocol.com/protocol/v1/transports. Conventions: absolute paths only; 1-based lines; camelCase keys, snake_case discriminators; Markdown text; _meta everywhere; _-prefixed custom methods [docs] https://agentclientprotocol.com/protocol/overview.-32700 parse, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal, -32800 request cancelled, -32000 authentication required, -32002 resource not found [docs] schema ErrorCode. (fazm's "-32603 wrapping a 401" heuristic, A.3, is a claude-agent-acp quirk, not spec.)schema.json x-method/x-side) [docs] https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/main/schema/v1/schema.json#| Implemented by | Method | Kind |
|---|---|---|
| agent | initialize, authenticate, logout |
request |
| agent | session/new, session/load, session/resume, session/list, session/close, session/delete |
request |
| agent | session/prompt, session/set_mode, session/set_config_option |
request |
| agent | session/cancel |
notification |
| client (you) | session/request_permission |
request (agent→client) |
| client | session/update |
notification (agent→client) |
| client | fs/read_text_file, fs/write_text_file |
request |
| client | terminal/create, terminal/output, terminal/wait_for_exit, terminal/kill, terminal/release |
request |
| client | elicitation/create (request), elicitation/complete (notification) |
|
| either | $/cancel_request |
notification |
session/fork is only an RFD (https://agentclientprotocol.com/rfds/session-fork); the Claude adapter implements it as unstable_forkSession. session/set_model is not in the spec — it is an adapter extension (Gemini calls it unstable_setSessionModel; the spec's replacement is session/set_config_option with a model category). fazm's calls to session/set_model and session/fork (A.3) therefore only work against those specific adapters.
initialize / authenticate [docs] https://agentclientprotocol.com/protocol/initialization, https://agentclientprotocol.com/protocol/v1/authentication#json{"jsonrpc":"2.0","id":0,"method":"initialize","params":{
"protocolVersion":1,
"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true},
"clientInfo":{"name":"snappy-os","title":"SnappyOS","version":"1.0.0"}}}
json{"jsonrpc":"2.0","id":0,"result":{
"protocolVersion":1,
"agentCapabilities":{"loadSession":true,
"promptCapabilities":{"image":true,"audio":true,"embeddedContext":true},
"mcpCapabilities":{"http":true,"sse":true},
"sessionCapabilities":{"list":{},"resume":{},"close":{},"delete":{},"additionalDirectories":{}}},
"agentInfo":{"name":"my-agent","title":"My Agent","version":"1.0.0"},
"authMethods":[]}}
ClientCapabilities: fs{readTextFile,writeTextFile} (default false), terminal (bool = all terminal/*), session{configOptions}, auth{terminal} (default false — gates terminal-type auth methods), elicitation{form,url}, _meta. AgentCapabilities: loadSession, promptCapabilities{image,audio,embeddedContext}, mcpCapabilities{http,sse}, sessionCapabilities{list,delete,additionalDirectories,resume,close} ("{} means supported; omitted/null means not advertised"), auth{logout}.authMethods[]: {id, name, description?} (agent-handled; call authenticate {methodId}) or {type:"terminal", id, name, args?, env?} — "The client runs the configured agent program as a separate interactive process for the user to authenticate via a TUI… A zero exit status signals success… The client MUST NOT pass this method to authenticate." This is how Claude subscription login is exposed (C.1).authenticate {methodId} → {}; -32000 = auth required. logout only if agentCapabilities.auth.logout.json{"jsonrpc":"2.0","id":1,"method":"session/new","params":{
"cwd":"/Users/robert/project",
"mcpServers":[{"name":"snappy_tools","command":"/abs/path/node","args":["/abs/path/tools.js"],"env":[{"name":"SNAPPY_SOCK","value":"/tmp/x.sock"}]}]}}
→ {"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess_abc123def456","modes":{…},"configOptions":[…]}}
cwd "Must be an absolute path"; additionalDirectories? only if advertised; mcpServers[] shapes: stdio (untagged; "All Agents MUST support this transport") {name, command (absolute), args[], env:[{name,value}]} — all four keys required, env is an array, not an object; {"type":"http", name, url, headers:[{name,value}]} if mcpCapabilities.http; {"type":"sse",…} deprecated. fazm's config builder (A.8) matches this exactly.session/load {sessionId, cwd, mcpServers} (needs loadSession): "The Agent MUST replay the entire conversation to the Client in the form of session/update notifications" (user_message_chunk/agent_message_chunk…), then responds.session/resume {sessionId, cwd, mcpServers} (needs sessionCapabilities.resume): same params, "MUST NOT replay the conversation history" — the cheap resume.session/list {cwd?, cursor?} → {sessions:[{sessionId, cwd, title?, updatedAt?}], nextCursor?} (needs sessionCapabilities.list).session/close {sessionId} (agent must cancel work); session/delete {sessionId}.json{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{
"sessionId":"sess_abc123def456",
"prompt":[
{"type":"text","text":"Can you analyze this code for potential issues?"},
{"type":"resource","resource":{"uri":"file:///Users/robert/project/main.py","mimeType":"text/x-python","text":"def process_data(items):\n ..."}}]}}
→ {"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}
text{text}; image{data(base64), mimeType, uri?} (needs promptCapabilities.image) — flat, confirming fazm's note in A.4; audio{data,mimeType}; resource{resource:{uri,text|blob,mimeType?}} (needs embeddedContext); resource_link{uri,name,mimeType?,title?,size?}. There is no PDF/document block (fazm works around this by pointing the agent at the path).stopReason: end_turn | max_tokens | max_turn_requests | refusal ("everything after it won't be included in the next prompt, so this should be reflected in the UI") | cancelled ("MUST be returned when the client sends a session/cancel notification, even if the cancellation causes exceptions").PromptResponse has only stopReason + _meta. Per-turn tokens are an RFD "intentionally kept in Draft" (unstable_end_turn_token_usage) [docs] https://agentclientprotocol.com/rfds/end-turn-token-usage. This is why fazm patched the adapter (A.2) and why the current Claude adapter stuffs usage into _meta.quota.session/update — the 11 variants [docs] schema SessionUpdate; https://agentclientprotocol.com/protocol/v1/tool-calls, /agent-plan, /slash-commands, /session-modes, /v1/session-config-options, /rfds/session-usage#sessionUpdate |
Payload | ||||
|---|---|---|---|---|---|
user_message_chunk / agent_message_chunk / agent_thought_chunk |
{content: ContentBlock, messageId?} — "A change in messageId indicates a new message has started" |
||||
tool_call |
{toolCallId, title, kind?, status?, content?[], locations?[{path,line?}], rawInput?, rawOutput?} |
||||
tool_call_update |
same fields, "only changed fields required" | ||||
plan |
`{entries:[{content, priority: high\ | medium\ | low, status: pending\ | in_progress\ | completed}]}` — "MUST send a complete list… Client MUST replace the current plan completely" |
available_commands_update |
{availableCommands:[{name, description, input?:{hint}}]} — invoke by sending /name … as a normal text prompt |
||||
current_mode_update |
{modeId} |
||||
config_option_update |
{configOptions:[SessionConfigOption]} |
||||
session_info_update |
{title?, updatedAt?} (null clears) |
||||
usage_update |
{used, size, cost?:{amount,currency}} — tokens in context, context window size, cumulative cost |
ToolKind: read | edit | delete | move | search | execute | think | fetch | switch_mode | other. ToolCallStatus: pending ("input is either streaming or we're awaiting approval") | in_progress | completed | failed. ToolCallContent: {"type":"content","content":ContentBlock} | {"type":"diff","path","oldText","newText"} | {"type":"terminal","terminalId"} ("must be added before calling terminal/release"). A native host should render all three; fazm flattens to text (A.5).json{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_abc123def456","update":{
"sessionUpdate":"tool_call_update","toolCallId":"call_001","status":"completed",
"content":[{"type":"diff","path":"/Users/robert/project/src/config.json","oldText":"{\n \"debug\": false\n}","newText":"{\n \"debug\": true\n}"}]}}}
json{"jsonrpc":"2.0","id":5,"method":"session/request_permission","params":{
"sessionId":"sess_abc123def456",
"toolCall":{"toolCallId":"call_001","title":"Reading configuration file","kind":"read","status":"pending"},
"options":[
{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},
{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}}
Reply {"jsonrpc":"2.0","id":5,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}} or {"result":{"outcome":{"outcome":"cancelled"}}}.
PermissionOptionKind: allow_once | allow_always | reject_once | reject_always. Option ids are agent-defined strings — never hard-code "allow" (fazm's fallback literal in A.6 would be rejected by codex-acp, which "fails closed" on unadvertised ids). Only two outcomes exist: selected{optionId} and cancelled. "If the current prompt turn gets cancelled, the Client MUST respond with the cancelled outcome."{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"…"}} (notification, no id). Agent "SHOULD stop all language model requests and tool call invocations as soon as possible" and "MUST respond to the original session/prompt request with the cancelled stop reason". Client "SHOULD preemptively mark all non-finished tool calls as cancelled", "SHOULD still accept tool call updates received after sending session/cancel", and "MUST respond to all pending session/request_permission requests with the cancelled outcome".$/cancel_request {requestId} cancels any single in-flight request (either direction); receiver responds with a result or -32800.fs/read_text_file {sessionId, path, line?, limit?} → {content}; fs/write_text_file {sessionId, path, content} → null (client creates the file). Advertising fs lets the agent see unsaved editor buffers — for a non-editor host, advertise false (as fazm does) and let the CLI read disk.terminal/create {sessionId, command, args?, env?[{name,value}], cwd?, outputByteLimit?} → {terminalId}; terminal/output → {output, truncated, exitStatus?}; terminal/wait_for_exit → {exitCode, signal}; terminal/kill; terminal/release. If you advertise terminal:true, you own the PTY/process for every shell command the agent runs and stream it live via {"type":"terminal","terminalId"} — this is the hook for a SwiftTerm view. If you advertise false, the agent runs commands itself and you get text output in tool_call_update.content.elicitation/create {mode:"form", message, requestedSchema} or {mode:"url", elicitationId, url} → {action: accept|decline|cancel, content?}.session/new result modes:{currentModeId:"ask", availableModes:[{id,name,description}]}; session/set_mode {sessionId, modeId}; agent-initiated → current_mode_update. Docs note dedicated mode methods "will be removed in a future version" in favour of config options.SessionConfigOption {id, name, description?, category?: "mode"|"model"|"model_config"|"thought_level"|…, type:"select", currentValue, options:[{value,name,description?}]} or {type:"boolean", currentValue}; session/set_config_option {sessionId, configId, value} → full configOptions list. Model selection is a config option, not session/set_model._meta: {[key]: unknown}; root keys traceparent/tracestate/baggage reserved; "Implementations MUST NOT add any custom fields at the root of a type"; custom methods start with _ (e.g. _zed.dev/workspace/buffers, _session/steering, _claude/sdkMessage); unknown methods → -32601, unknown notifications ignored; advertise via _meta in capabilities.initialize (id 0) → check protocolVersion, capabilities, authMethods.-32000 on a later call or authMethods non-empty: run terminal-type login in a real PTY, or authenticate {methodId}.session/new {cwd, mcpServers} (id 1) → sessionId (+ modes/configOptions). Expect an immediate available_commands_update notification before you have wired a per-session handler (fazm's [ROUTE-DROP-RESCUED], A.12).session/prompt (id 2) → stream of session/update (plan, agent_thought_chunk, agent_message_chunk, tool_call{pending}…).session/request_permission (id 5) → answer selected{optionId}.tool_call_update{in_progress} … {completed, content:[diff|terminal|content]}, optional usage_update.{stopReason:"end_turn"}. Cancel path: session/cancel → answer pending permissions cancelled → result {stopReason:"cancelled"}.@agentclientprotocol/sdk 1.4.0): fluent client({name}) / agent({name}); ClientSideConnection/AgentSideConnection still exported but deprecated; ndJsonStream(output: WritableStream<Uint8Array>, input: ReadableStream<Uint8Array>) writes JSON.stringify(msg)+"\n". Official child-process wiring [docs] https://github.com/agentclientprotocol/typescript-sdk/blob/main/src/examples/client.ts:tsconst agentProcess = spawn(cmd, args, { stdio: ["pipe","pipe","inherit"] });
const stream = acp.ndJsonStream(Writable.toWeb(agentProcess.stdin!), Readable.toWeb(agentProcess.stdout!));
await acp.client({ name:"snappy-os" })
.onRequest(acp.methods.client.session.requestPermission, (ctx) => ui.requestPermission(ctx.params))
.onRequest(acp.methods.client.fs.readTextFile, (ctx) => fs.read(ctx.params))
.onRequest(acp.methods.client.fs.writeTextFile, (ctx) => fs.write(ctx.params))
.connectWith(stream, async (ctx) => {
await ctx.request(acp.methods.agent.initialize, { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile:true, writeTextFile:true } } });
return ctx.buildSession(cwd).withSession(async (session) => {
session.prompt("Hello, agent!");
for (;;) { const m = await session.nextUpdate(); if (m.kind === "stop") return m.response; await ui.sessionUpdate(m.notification); }
});
});
agent-client-protocol, implement the Client trait; "powers the integration with external agents in the Zed editor" [docs] https://agentclientprotocol.com/libraries/rust.wiedymi/swift-acp (MIT, v0.1.0, macOS 12+, tools 5.9, ~10.7K lines, pushed 2026-07-24, 29★) — the only one covering the full v1 client surface: session/request_permission, every session/update kind incl. usage_update, set_config_option, fs + terminal delegates, local Process spawn with a shell-PATH resolver, cancel; aptove/swift-sdk (Apache-2.0, v0.1.16, Swift 6, macOS 12+, ~9.4K lines, idle since 2026-04-25, 10★) — no usage_update, README claims a "2025-02-07" protocol version that is not in the code (code speaks v1); rebornix/acp-swift-sdk (MIT, untagged, Swift 6, macOS 13+, ~3.4K lines, idle since 2026-02-07, 6★) — minimal: no permission handling in the SDK, no terminal, no usage_update, FileDescriptor transport with no spawning (built for iOS). Shipped native Apple ACP clients: rebornix/Agmente (MIT, 540★, pushed 2026-05-31; iOS + a native macOS target, deployment 15.0; ACP over WebSocket via @rebornix/stdio-to-ws, plus a separate AppServerClient package for Codex app-server; app-layer PermissionRequestParsing, SessionUpdateHandler, ChatRenderDiff, ToolCallRowView, PlanModeViews are the reusable rendering code) and Poolside Desktop Assistant (closed source, "agent agnostic worktree native macOS desktop app"). Nobody in our host-app table spawns adapters locally from Swift; that piece is the api.ts spawn spec ported. (Earlier draft said "[not-found]" — wrong; corrected after a direct check.)https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json = {"version":"1.0.0","agents":[…]}; each <id>/agent.json has id, name, version, description, license, icon?, distribution:{npx:{package,args?,env?} | uvx:{…} | binary:{darwin-aarch64|darwin-x86_64|linux-*|windows-*:{archive,sha256?,cmd,args?,env?}}}; versions auto-bumped hourly; every listed agent must support auth (CI checks authMethods). Claude entry: {"id":"claude-acp","version":"0.73.0","license":"proprietary","distribution":{"npx":{"package":"@agentclientprotocol/claude-agent-acp@0.73.0"}}} (adapter code is Apache-2.0; the bundled Claude Code binary is not) [docs] https://raw.githubusercontent.com/agentclientprotocol/registry/main/claude-acp/agent.json.acpx CLI, LangChain/Mastra/LlamaIndex/Koog frameworks.PromptResponse = stopReason only); only cumulative usage_update; Gemini CLI and codex-acp both had open issues about not populating it ([issue] https://github.com/google-gemini/gemini-cli/issues/24280, https://github.com/zed-industries/codex-acp/issues/209).session/list/resume/close are capability-gated and not every agent advertises them.title/description/subject), makes messageId mandatory, streams tool-call content, and turns diffs into structured file changes [docs] https://agentclientprotocol.com/announcements/acp-v2-draft.Current bits (npm, 2026-09-02): @agentclientprotocol/claude-agent-acp 0.73.0 (deps @anthropic-ai/claude-agent-sdk 0.3.257, @agentclientprotocol/sdk 1.4.0, engines.node >= 22, bin claude-agent-acp); @anthropic-ai/claude-agent-sdk 0.3.258 (node ≥ 18); @anthropic-ai/claude-code 2.1.258. fazm pins adapter 0.29.2 / SDK 0.2.112 — roughly 44 adapter releases behind.
The confirmed process model (both roads end here): "The Agent SDK spawns and supervises a claude CLI subprocess that owns a shell, a working directory, and session files on disk… When your code calls query(), the SDK spawns a separate claude CLI process and talks to it over stdio… One agent session maps to one subprocess." "Both the TypeScript and Python SDKs bundle a native Claude Code binary… pinned to the SDK package version." Sizing: "1 GiB RAM, 5 GiB disk, and 1 CPU per agent" [docs] https://code.claude.com/docs/en/agent-sdk/hosting. npm ci --omit=optional drops the binary → set pathToClaudeCodeExecutable [docs] https://code.claude.com/docs/en/agent-sdk/quickstart.
@agentclientprotocol/claude-agent-acp)#node <…>/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js (or npx @agentclientprotocol/claude-agent-acp), stdio: pipe/pipe/pipe, cwd irrelevant (per-session cwd). Flags: --cli <args…> forwards to the bundled native claude (used for terminal-auth login), --version, --hide-claude-auth (suppresses subscription login). Env: CLAUDE_CODE_EXECUTABLE (override bundled binary; error text "Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE"), CLAUDE_AGENT_LOGS=<dir> (writes agent.log), ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, CLAUDE_CODE_USE_BEDROCK|VERTEX, CLAUDE_MODEL_CONFIG JSON for Bedrock model ids. All console.* go to stderr [docs] repo src/index.ts, README, docs/model-configuration.md https://github.com/agentclientprotocol/claude-agent-acp.query() with pathToClaudeCodeExecutable, includePartialMessages:true, settingSources:["user","project","local"], systemPrompt:{type:"preset",preset:"claude_code"} (overridable via _meta.systemPrompt), canUseTool, extraArgs:{"replay-user-messages":""}, env CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1 [docs] src/acp-agent.ts. So: your app → node adapter → SDK → bundled claude binary (two extra processes per session).initialize result [docs] src/acp-agent.ts: promptCapabilities{image:true, embeddedContext:true} (no audio), mcpCapabilities{http:true, sse:true}, auth{logout:{}}, loadSession:true, sessionCapabilities{additionalDirectories, close, delete, fork, list, resume, subagents}, _meta.claudeCode.promptQueueing:true, _meta.steering{supported:true} (_session/steering injects a message into a running turn), _meta.goal. authMethods (all type:"terminal"): claude-ai-login "Claude Subscription" (args:["--cli","auth","login","--claudeai"]), console-login "Anthropic Console (API usage billing)" (--cli auth login --console); in SSH envs a single claude-login; plus agent-type gateway/gateway-bedrock. Terminal methods are advertised only when the client sets clientCapabilities.auth.terminal; authenticate() accepts only gateway ids, anything else throws "Method not implemented." ⇒ to let a user log in with a subscription you must spawn claude-agent-acp --cli auth login --claudeai in a real PTY and wait for exit 0 (Section E). fazm sidestepped this with its own PKCE flow writing the Keychain item (A.9).src/session-mode.ts): default "Manual", acceptEdits, plan, auto, bypassPermissions (disabled when root). Config options: Mode, model, effort, fast-mode. Slash commands via available_commands_update.src/acp-agent.ts): session/resume, session/close, session/delete, listSessions (backed by SDK listSessions({dir: cwd})), unstable_forkSession, unstable_listProviders|setProvider|disableProvider, logout (runs claude auth logout), _session/steering, _session/async_task/stop. _meta.claudeCode.options on session/new forwards raw SDK Options (ACP owns cwd, includePartialMessages, allowDangerouslySkipPermissions, permissionMode, canUseTool, executable; merges hooks, mcpServers, disallowedTools). _meta.claudeCode.emitRawSDKMessages: true | filter[] streams every raw SDK message as a _claude/sdkMessage notification — the supported replacement for fazm's patched-acp-entry.mjs monkey-patch.usage_update from modelUsage (context size seeded at 200 000 until the first result); since 0.71.0 "report per-model token usage on prompt responses" as PromptResponse._meta.quota {input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, model_usage} [docs] CHANGELOG + source.docs/permission-extension.md): adds _meta.permission{version:1,title,description}; fixed option ids allow-once, allow-with-updates, exit-plan-*, reject; maps SDK PermissionUpdate suggestions into "Yes, and don't ask again for npm test commands"-style options; cancel ≠ reject. Session-failure extension (opt-in clientCapabilities._meta.jetbrains.air.capabilities:["sessionFailure"]): usage-limit/auth failures arrive as _meta.jetbrains.air.sessionFailure{category: connection|access|limit|request|service|unknown, title, actions} on a stopReason:"end_turn" response — the fix for the historic silent end_turn on usage limit ([issue] #146).--hide-claude-auth); #744 (open) ANTHROPIC_API_KEY not picked up (stale key in ~/.claude/settings.json); #338 CLI subprocess death (exit 143) left "ProcessTransport is not ready for writing" — fixed #363; #880 session/new blocked ~100 s by an init-time getContextUsage() through a gateway; #337 ToS thread closed by maintainer 2026-05-16 pointing at https://zed.dev/blog/anthropic-subscription-changes. URLs: https://github.com/agentclientprotocol/claude-agent-acp/issues/{421,744,338,880,337,146}. Raw-CLI alternatives (PTY + transcript JSONL, not SDK): moabualruz/claude-code-cli-acp, Xuanwo/acp-claude-code [blog].Docs moved: docs.claude.com/en/api/agent-sdk/* → platform.claude.com/... → code.claude.com/docs/en/agent-sdk/* (index https://code.claude.com/docs/llms.txt).
query({prompt: string | AsyncIterable<SDKUserMessage>, options}) : Query (an AsyncGenerator<SDKMessage>); startup({options}) pre-warms the subprocess; tool(name, desc, zodShape, handler) + createSdkMcpServer({name, tools}) for in-process tools (no separate MCP server process — this replaces fazm's stdio-MCP + Unix-socket relay, A.8); listSessions/getSessionMessages/getSessionInfo/renameSession/tagSession.Query control methods (streaming-input mode only) [docs] sdk.d.ts 0.3.258: interrupt(), setPermissionMode(mode), setModel(model), setMaxThinkingTokens, supportedCommands(), supportedModels(), mcpServerStatus(), getContextUsage(), accountInfo(), rewindFiles(userMessageId), setMcpServers, reconnectMcpServer, streamInput(), stopTask(taskId), backgroundTasks(), close().Options (full list in sdk.d.ts): cwd, model, permissionMode: 'default'|'acceptEdits'|'bypassPermissions'|'plan'|'dontAsk'|'auto' (bypassPermissions requires allowDangerouslySkipPermissions:true), canUseTool, allowedTools/disallowedTools/tools, mcpServers (stdio{command,args,env} | sse{url,headers} | http{url,headers} | sdk{instance}), hooks, resume/continue/forkSession/resumeSessionAt/sessionId, persistSession/sessionStore, settingSources: ['user'|'project'|'local'], includePartialMessages, maxTurns, maxBudgetUsd, abortController, pathToClaudeCodeExecutable, executable: 'bun'|'deno'|'node', executableArgs, spawnClaudeCodeProcess: (opts) => SpawnedProcess ("Use to run Claude Code in VMs, containers, or remote environments" — you can own the spawn from Swift), env replaces the subprocess environment ("pass { ...process.env, ... }"), stderr: (data) => void, systemPrompt (default is a minimal prompt, not Claude Code's — use {type:'preset', preset:'claude_code'}; CLAUDE.md loads only via settingSources) [docs] https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts.tstype CanUseTool = (toolName, input, { signal, suggestions?: PermissionUpdate[], blockedPath? }) => Promise<
| { behavior:'allow'; updatedInput?; updatedPermissions?: PermissionUpdate[] }
| { behavior:'deny'; message: string; interrupt?: boolean }>;
Evaluation order: Hooks → deny rules → ask rules → permission mode → allow rules → canUseTool; "Auto-approved tools never reach canUseTool"; AskUserQuestion always reaches it (answer with updatedInput.answers), "The callback can stay pending indefinitely"; plan mode routes writes to the callback; dontAsk never calls it. PermissionUpdate lets you persist "always allow" rules (addRules, setMode, addDirectories).
sdk.d.ts, https://code.claude.com/docs/en/agent-sdk/streaming-output, /cost-tracking: system/init{session_id, apiKeySource: 'ANTHROPIC_API_KEY'|'apiKeyHelper'|'/login managed key'|'none', claude_code_version, cwd, tools, mcp_servers[{name,status}], model, permissionMode, slash_commands, capabilities}; assistant{message: BetaMessage(content: text|thinking|tool_use)}; user{tool_use_result}; stream_event{event: text_delta|thinking_delta|input_json_delta} (with includePartialMessages); system/compact_boundary{compact_metadata{trigger, pre_tokens}}; system/api_retry; rate_limit_event; task_started/task_notification; result{subtype:'success'|'error_during_execution'|'error_max_turns'|'error_max_budget_usd', total_cost_usd, usage, modelUsage:{[model]:{inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens, costUSD, contextWindow}}, num_turns, session_id, permission_denials}. total_cost_usd is a running total per session ("read the latest result rather than summing"); "client-side estimates, not authoritative billing data". These are exactly the events fazm's patch re-exported (A.2) — the adapter now exposes them via emitRawSDKMessages.~/.claude/projects/<cwd with non-alphanumerics→'-'>/<session-id>.jsonl (200-char truncation + hash; CLAUDE_CONFIG_DIR relocates); since CLI v2.1.223 --resume "looks for the ID in the current project directory and its git worktrees first, then in every other project on this machine" — which retires fazm's cwd-recovery machinery (A.3) if you're on a current SDK. -p/SDK sessions are hidden from the picker but resumable by id. SDK forkSession:true = new id with copied history.settingSources: [], CLAUDE_CODE_DISABLE_AUTO_MEMORY=1, per-tenant CLAUDE_CONFIG_DIR.SDKMessage plus {type:'control_request', request_id, request:{subtype:'can_use_tool'|'hook_callback'|'mcp_message'|'elicitation'|…}} / {type:'control_response'} / {type:'control_cancel_request'}; host→CLI initialize{hooks?, sdkMcpServers?, systemPrompt?, agents?}, interrupt, set_permission_mode, set_model, get_context_usage, get_session_cost… Shapes are in sdk.d.ts; the framing has no standalone docs page [not-found]. CLI equivalent: claude -p --output-format stream-json --input-format stream-json --verbose --include-partial-messages --replay-user-messages --permission-prompt-tool mcp__x__y --resume <id> --session-id <uuid> --fork-session --permission-mode … --max-turns N --max-budget-usd 5 --mcp-config ./mcp.json --strict-mcp-config --bare [docs] https://code.claude.com/docs/en/headless, /cli-reference. --permission-prompt-tool waits for that MCP server up to MCP_TIMEOUT; the exact tool I/O contract is [not-found] on a current official page (third-party: https://lobehub.com/mcp/user-claude-code-permission-prompt-tool). --bare "will become the default for -p" and does not read OAuth/Keychain (needs ANTHROPIC_API_KEY). SIGTERM → exit 143 with the turn unfinished; SIGINT/interrupt() ends cleanly.ANTHROPIC_AUTH_TOKEN (Bearer) → ANTHROPIC_API_KEY (X-Api-Key; "In non-interactive mode (-p), the key is always used when present") → apiKeyHelper → CLAUDE_CODE_OAUTH_TOKEN (from claude setup-token, 1-year, "requires a Pro, Max, Team, or Enterprise plan… can only make model requests"; not read in --bare) → Anthropic profile/WIF → subscription OAuth from /login.~/.claude/.credentials.json with file mode 0600"; CLAUDE_CONFIG_DIR "keys the macOS Keychain entry to that directory too, so a session with a different CLAUDE_CONFIG_DIR reads a different entry." The item name Claude Code-credentials is not in the docs; fazm reads/writes it via security (A.9) — [blog/source-verified only].CLAUDECODE ("Set to 1 in subprocesses Claude Code spawns… stdio MCP server subprocesses. IDE extensions also set this") — the nested-session guard fazm deletes (A.2); CLAUDE_CODE_CHILD_SESSION ("only set by Claude Code itself… A nested interactive claude TUI started this way is automatically excluded" from --resume/--continue); CLAUDE_CODE_FORCE_SESSION_PERSISTENCE=1; CLAUDE_CONFIG_DIR; DISABLE_AUTOUPDATER=1 (DISABLE_UPDATES blocks manual too); CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC (any non-empty value, even 0); DISABLE_TELEMETRY (same semantics); CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 ("In Agent SDK and claude -p sessions, this also skips the background small/fast-model request that generates the session title"); CLAUDE_CODE_SIMPLE=1 (= --bare; "OAuth tokens and keychain credentials are not read"); CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS=1; CLAUDE_AGENT_SDK_MCP_NO_PREFIX=1; CLAUDE_CODE_SESSION_ID (set in tool/hook/MCP subprocesses); MAX_THINKING_TOKENS; CLAUDE_CODE_MAX_OUTPUT_TOKENS. Nested-session error text: "Claude Code cannot be launched inside another Claude Code session" [issue] https://github.com/anthropics/claude-code/issues/25803.claude -p, and third-party app usage still draw from your subscription's usage limits." [docs] https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan; corroborated [blog] https://zed.dev/blog/anthropic-subscription-changes. (Press reported Max 20x as $400 — conflicts with the support article; trust the support article.)claude.ai/oauth/authorize with Claude Code's client id and writing the Keychain item (A.9) — is precisely "intermediating Claude.ai credentials" and sits on the wrong side of the legal page. The compliant pattern is the one claude-agent-acp and Zed use: hand the unmodified claude binary a real terminal for claude auth login --claudeai (terminal-auth method) and never touch the token. Get written confirmation from Anthropic before shipping; default to API key / CLAUDE_CODE_OAUTH_TOKEN supplied by the user.| Concern | ACP adapter | SDK direct | |||||
|---|---|---|---|---|---|---|---|
| Uniform multi-agent | one client for Claude + Codex + Gemini + 40 others (B.14) | Claude only | |||||
| UI primitives | native tool_call{kind,status}, diff, terminal, plan, modes, commands |
you rebuild them from tool_use/tool_result/stream_event/TaskCreate |
|||||
| Permissions | standard request + option list; adapter maps SDK suggestions into "always allow" options; cancel ≠ reject | full canUseTool incl. updatedInput, PermissionUpdate rules, interrupt; hooks run even in bypass |
|||||
| Cost/usage | usage_update (cumulative) + _meta.quota per turn (0.71+) |
authoritative per-turn result.modelUsage, maxBudgetUsd, getContextUsage(), accountInfo() |
|||||
| Sessions | `session/list | load | resume | close | delete | unstable_forkSession` | listSessions, resume, forkSession, resumeSessionAt, sessionStore, persistSession:false |
| Auth UX | terminal-auth method → you must spawn a PTY login | you set env; subscription login still needs the CLI's own flow | |||||
| Process count | app → node adapter → SDK → claude |
app → node SDK → claude (or spawnClaudeCodeProcess from Swift) |
|||||
| Escape hatch | _meta.claudeCode.options + emitRawSDKMessages → _claude/sdkMessage |
n/a | |||||
| Stability | ACP v1 stable; v2 draft changes shapes | semver; .d.ts is the contract; binary pinned per SDK version |
Current bits (npm registry, 2026-09-02): @openai/codex 0.152.1 (bin codex → bin/codex.js, real binary in per-platform optionalDependencies @openai/codex-darwin-arm64 etc. at vendor/<target-triple>/bin/codex), @openai/codex-sdk 0.152.1, @agentclientprotocol/codex-acp 1.8.0 (depends on @openai/codex ^0.152.0). @zed-industries/codex-acp (0.16.0, what fazm pins at 0.12.0) is archived: "Development migrated to agentclientprotocol/codex-acp on the new Codex App Server." [docs] https://github.com/zed-industries/codex-acp, https://registry.npmjs.org/@openai/codex/latest, https://registry.npmjs.org/@agentclientprotocol/codex-acp/latest. Note developers.openai.com/codex/* now 308-redirects to learn.chatgpt.com/docs/* and the GitHub docs/*.md files are stubs [docs] https://developers.openai.com/codex/noninteractive.
Three ways in, ranked for a GUI host:
| Surface | Spawn | Approvals? | Verdict | |
|---|---|---|---|---|
codex app-server (stdio JSON-RPC) |
codex app-server (default --listen stdio://; also ws://IP:PORT, unix://) |
Yes — server→client requests | The surface OpenAI's own VS Code extension and desktop app use. "enables deep integration inside your own product: authentication, conversation history, approvals, and streamed agent events" [docs] https://learn.chatgpt.com/docs/app-server; README https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md. Caveat: "experimental and aren't supported for production workloads" (same page). | |
@agentclientprotocol/codex-acp (ACP over stdio) |
npx -y @agentclientprotocol/codex-acp |
Yes — mapped to ACP session/request_permission |
"starts the Codex App Server, translates ACP requests into Codex operations, and maps Codex events back" [docs] https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/README.md. Use this if the app speaks ACP for every agent. | |
codex exec --json / @openai/codex-sdk |
codex exec --experimental-json … |
No — approval_policy: Never forced; approval requests auto-rejected ("file change approval is not supported in exec mode") |
Fire-and-forget automation only [docs] https://raw.githubusercontent.com/openai/codex/main/codex-rs/exec/src/lib.rs. The TS SDK "spawns the CLI and exchanges JSONL events over stdin/stdout" [docs] https://raw.githubusercontent.com/openai/codex/main/sdk/typescript/README.md. | |
codex mcp-server |
stdio MCP, tools codex / codex-reply |
execCommandApproval / applyPatchApproval → `{decision: allow |
deny}` | "experimental and subject to change" [docs] https://raw.githubusercontent.com/openai/codex/main/codex-rs/docs/codex_mcp_interface.md. Legacy. codex proto no longer exists [not-found] in codex-rs/cli/src/main.rs. |
app-server wire format & lifecycle [docs, README above]: JSON-RPC 2.0 "with the "jsonrpc":"2.0" header omitted on the wire", newline-delimited. Sequence: initialize {clientInfo:{name,title,version}} → initialized notification → thread/start {model, cwd, approvalPolicy: "never"|"unlessTrusted"|…, sandbox: "workspaceWrite"|"readOnly"|"dangerFullAccess", ephemeral?, baseInstructions?, developerInstructions?} → turn/start {threadId, input:[{type:"text"|"image"|"localImage"|"audio"|"localAudio",…}], cwd?, approvalPolicy?, sandboxPolicy?, model?, effort?, outputSchema?}; turn/interrupt, turn/steer; thread/resume {threadId}, thread/fork, thread/list (cursor + cwd/archived/searchTerm filters), thread/read, thread/archive|unarchive|delete. Backpressure error -32001 "Server overloaded; retry later." capabilities.experimentalApi: true unlocks gated fields; capabilities.optOutNotificationMethods suppresses noisy deltas. Side effect: a thread/start with cwd under workspace-write/full access marks that project trusted in config.toml.
Official Node sketch [docs] https://learn.chatgpt.com/docs/llms-full.txt:
tsconst proc = spawn("codex", ["app-server"], { stdio: ["pipe","pipe","inherit"] });
send({ method:"initialize", id:0, params:{ clientInfo:{ name:"my_product", title:"My Product", version:"0.1.0" } } });
send({ method:"initialized", params:{} });
send({ method:"thread/start", id:1, params:{ model:"gpt-5.4" } });
send({ method:"turn/start", id:2, params:{ threadId, input:[{ type:"text", text:"Summarize this repo." }] } });
What app-server streams [docs, README]: turn/started, turn/completed {turn.status: completed|interrupted|failed, error?.codexErrorInfo: ContextWindowExceeded|UsageLimitExceeded|rateLimitExceeded|Unauthorized|SandboxError|…}, turn/diff/updated {diff} (aggregated unified diff after every file change), turn/plan/updated {plan:[{step,status}]}, thread/tokenUsage/updated, item/started → deltas → item/completed where deltas are item/agentMessage/delta, item/reasoning/summaryTextDelta, item/reasoning/textDelta, item/commandExecution/outputDelta, item/fileChange/patchUpdated, item/mcpToolCall/progress. Item types: userMessage, agentMessage, plan, reasoning, commandExecution{command,cwd,status,aggregatedOutput,exitCode,durationMs}, fileChange{changes:[{path,kind,diff}]}, mcpToolCall, collabToolCall, subAgentActivity, webSearch, imageGeneration, imageView, contextCompaction, ….
Approvals (server→client JSON-RPC requests) [docs, README]:
item/commandExecution/requestApproval {itemId, threadId, turnId, kind: command|writeStdin, reason, command, cwd, commandActions, availableDecisions?, proposedExecpolicyAmendment?, networkApprovalContext?} → reply { "decision": "accept" | "acceptForSession" | {"acceptWithExecpolicyAmendment":{…}} | {"applyNetworkPolicyAmendment":{…}} | "decline" | "cancel" }.item/fileChange/requestApproval {itemId, threadId, turnId, reason?, grantRoot?} → { "decision": "accept"|"acceptForSession"|"decline"|"cancel" }.item/permissions/requestApproval {…, permissions:{fileSystem:{write:[…]}, network?}} → { "scope": "session"|"turn", "permissions": {granted subset} }.item/tool/requestUserInput, mcpServer/elicitation/request, item/tool/call (client dynamicTools).approvalsReviewer: "auto_review" delegates approvals to a subagent.Auth via app-server [docs, README "Auth endpoints"]: account/read → {account:{type:"chatgpt", email, planType}}; account/login/start {type:"chatgpt"} → {loginId, authUrl} (app-server hosts the localhost callback — open authUrl); type:"chatgptDeviceCode" → {verificationUrl:"https://auth.openai.com/codex/device", userCode}; account/login/completed, account/updated {authMode: apikey|chatgpt|…}, account/logout, account/rateLimits/read + account/rateLimits/updated, account/usage/read. Credentials: ~/.codex/auth.json ("treat like a password") or keyring via cli_auth_credentials_store = file|keyring|auto; CODEX_HOME defaults to ~/.codex [docs] https://learn.chatgpt.com/docs/auth. Env: CODEX_API_KEY (one-shot), CODEX_ACCESS_TOKEN (automation) [docs] llms-full.txt. codex login --api-key is deprecated → printenv OPENAI_API_KEY | codex login --with-api-key; codex login --device-auth [docs] https://learn.chatgpt.com/docs/auth. This makes fazm's hand-rolled OAuth (codex-oauth-flow.ts) unnecessary on the app-server road.
ACP adapter mapping (@agentclientprotocol/codex-acp) [docs] https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/docs/permission-extension.md: accept→allow_once, acceptForSession→allow_always, acceptWithExecpolicyAmendment→allow_always, decline/cancel→reject_once; network allow→allow_always, deny→reject_always; unadvertised option ids fail closed. Env: CODEX_API_KEY/OPENAI_API_KEY, CODEX_PATH (custom binary), CODEX_CONFIG (JSON merged into session config), INITIAL_AGENT_MODE = read-only | agent | agent-full-access, NO_BROWSER, APP_SERVER_LOGS [docs] README. Slash commands /status /mcp /skills /goal /review /compact /logout. Registry manifest: {"id":"codex-acp","version":"1.8.0","distribution":{"npx":{"package":"@agentclientprotocol/codex-acp@1.8.0"}}} [docs] https://raw.githubusercontent.com/agentclientprotocol/registry/main/codex-acp/agent.json.
codex exec flags (if you only need batch) [docs] https://learn.chatgpt.com/docs/non-interactive-mode + codex-rs/exec/src/cli.rs: --json (alias --experimental-json), --output-last-message <path>, --output-schema <path>, --sandbox read-only|workspace-write|danger-full-access, --full-auto (deprecated), --dangerously-bypass-approvals-and-sandbox/--yolo, -C/--cd, -m/--model, -c key=value, -p/--profile, --skip-git-repo-check, --ephemeral, --ignore-user-config, -i/--image, prompt - = stdin, codex exec resume <id|--last|--all>, codex exec fork. Exits 1 with "Not inside a trusted directory and --skip-git-repo-check was not specified." outside a git repo [docs] exec lib.rs. JSONL events: thread.started{thread_id}, turn.started, turn.completed{usage:{input_tokens,cached_input_tokens,cache_write_input_tokens,output_tokens,reasoning_output_tokens}}, turn.failed{error}, item.started|updated|completed{item:{id,type: agent_message|reasoning|command_execution{command,aggregated_output,exit_code,status}|file_change{changes:[{path,kind}]}|mcp_tool_call|collab_tool_call|web_search|todo_list|error}} [docs] https://raw.githubusercontent.com/openai/codex/main/codex-rs/exec/src/exec_events.rs.
Codex config keys a host cares about [docs] https://learn.chatgpt.com/docs/config-file/config-reference: approval_policy = "untrusted"|"on-request"|"never" (on-failure deprecated), approvals_reviewer, sandbox_mode, [sandbox_workspace_write] writable_roots/network_access, model_reasoning_effort = minimal|low|medium|high|xhigh, hide_agent_reasoning, show_raw_agent_reasoning, notify = [cmd], web_search, [history] persistence = "save-all"|"none", [projects."<path>"] trust_level, [mcp_servers.<id>] command/args/env/cwd/url/http_headers/bearer_token_env_var/startup_timeout_sec, profiles at $CODEX_HOME/<name>.config.toml.
macOS sandbox [docs] https://raw.githubusercontent.com/openai/codex/main/codex-rs/core/README.md: "Expects /usr/bin/sandbox-exec to be present"; workspace-write keeps .git and .codex read-only; "the sandbox applies to spawned commands, not just built-in file operations" https://learn.chatgpt.com/docs/sandboxing.
Non-TTY hazards [issue]: codex exec hangs at 0 % CPU when stdin is an inherited-but-never-closed pipe — workaround < /dev/null (https://github.com/openai/codex/issues/20919); silently exits 0 with empty stdout when detached from a controlling TTY with a long prompt (https://github.com/openai/codex/issues/19945); Codex.app hangs after the Homebrew CLI is upgraded while the app runs (https://github.com/openai/codex/issues/23695). Desktop/VS Code "bundle a platform-specific binary, launch it as a child process, and keep a bidirectional stdio channel open" [blog] https://www.infoq.com/news/2026/02/opanai-codex-app-server/ (OpenAI's original post returned 403).
Current bits: @google/gemini-cli 0.58.0, bin gemini → bundle/gemini.js, Node ≥ 20 [docs] https://registry.npmjs.org/@google/gemini-cli/latest. ACP registry manifest: {"id":"gemini","version":"0.58.0","distribution":{"npx":{"package":"@google/gemini-cli@0.58.0","args":["--acp"]}}} [docs] https://raw.githubusercontent.com/agentclientprotocol/registry/main/gemini/agent.json.
Spawn: gemini --acp (or node bundle/gemini.js --acp). --experimental-acp still parses but is "deprecated, use --acp instead" [docs] packages/cli/src/config/config.ts. Official page: "ACP mode is a special operational mode of Gemini CLI designed for programmatic control, primarily for IDE and other developer tool integrations. It uses a JSON-RPC protocol over stdio" [docs] https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/cli/acp-mode.md. Transport is acp.ndJsonStream(stdout, stdin) + AgentSideConnection from @agentclientprotocol/sdk [docs] packages/cli/src/acp/acpStdioTransport.ts.
Env for an embedded host: GEMINI_API_KEY (or GOOGLE_API_KEY; Vertex via GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION), GEMINI_CLI_TRUST_WORKSPACE="true" ("Useful for headless environments"), GEMINI_SANDBOX=false, NO_COLOR, GEMINI_CLI_HOME, GEMINI_TELEMETRY_ENABLED=false; settings general.enableAutoUpdate=false, general.enableAutoUpdateNotification=false, privacy.usageStatisticsEnabled=false, security.auth.selectedType="gemini-api-key" [docs] https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/reference/configuration.md. fazm's GEMINI_CLI_TRUST_WORKSPACE requirement (A.2) is confirmed by the docs.
Handshake [docs] packages/cli/src/acp/acpRpcDispatcher.ts: initialize returns authMethods = [{id: oauth-personal ("Log in with Google")}, {id:"gemini-api-key", _meta:{"api-key":{provider:"google"}}}, {id:"vertex-ai"}, {id:"gateway", _meta:{gateway:{protocol:"google"}}}] and agentCapabilities: { loadSession:true, promptCapabilities:{image:true, audio:true, embeddedContext:true}, mcpCapabilities:{http:true, sse:true} }. Then authenticate {methodId, _meta?:{"api-key":"<key>"}} (errors → -32000), session/new {cwd, mcpServers} (throws -32000 "Authentication required." if not authed; auth runs before MCP servers start), session/load {sessionId, cwd, mcpServers} via resumeChat [docs] acpSessionManager.ts. session/set_mode ids: default ("Prompts for approval"), auto_edit, yolo, plan [docs] acpUtils.ts; unstable_setSessionModel exists [docs] acp-mode.md.
Permissions [docs] acpSession.ts: session/request_permission {sessionId, options, toolCall:{toolCallId, status:"pending", title, content, locations, kind}} with options allow_always "Allow for this session", allow_always "…in all future sessions" (only if security.enablePermanentToolApproval), MCP "Allow all server tools for this session", allow_once, reject_once; outcome:"cancelled" maps to Cancel; a separate allow/reject prompt fires for reads outside the workspace.
Streams: agent_message_chunk, agent_thought_chunk, tool_call + tool_call_update, available_commands_update; per-turn tokens in PromptResponse._meta.quota.token_count (fazm reads exactly this, A.4) [docs] acpSession.ts; fazm source.
Headless alternative (no approvals): gemini -p "…" --output-format stream-json → {type, timestamp} events init{session_id, model}, message{role, content, delta?}, tool_use{tool_name, tool_id, parameters}, tool_result{tool_id, status, output?, error?}, error, result{status, stats:{total_tokens,input_tokens,output_tokens,cached,duration_ms,tool_calls,models}}; exit codes 0/1/42/53 [docs] https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/cli/headless.md, packages/core/src/output/types.ts. Non-TTY stdin is read to EOF and prepended to the prompt; setRawMode(true) only when interactive and isTTY [docs] packages/cli/src/gemini.tsx. Flags: --approval-mode default|auto_edit|yolo|plan, --allowed-tools, --include-directories, -r/--resume [id|index|latest], --list-sessions, --session-id [docs] cli-reference.md. Gotcha: "Sandbox is enabled when using --yolo or --approval-mode=yolo by default" (Docker/Seatbelt) [docs] configuration.md.
Sessions: ~/.gemini/tmp/<project_hash>/chats/session-*.jsonl; retention general.sessionRetention [docs] https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/cli/session-management.md.
Gemini ACP issues [issue]: #23959 "ACP server does not start when sandboxing is enabled and stdin is not a TTY" (https://github.com/google-gemini/gemini-cli/issues/23959); #17952 ~30 s delay before session/request_permission (fixed PR #17955); #12042 ACP prompts for login when spawned from a script despite cached OAuth — closed not-planned, workaround API key (https://github.com/google-gemini/gemini-cli/issues/12042); #10855 GEMINI_API_KEY still prompts for auth method; #24916 repeated permission for same file; #7880 Windows EOL parsing; PR #23673 stdin resume() fix for Ink hang in headless. fazm's "sessionId mismatch on session/update" (A.12) was [not-found] as a public issue.
Zed's config for any ACP agent [docs] https://raw.githubusercontent.com/zed-industries/zed/main/docs/src/ai/external-agents.md:
json{ "agent_servers": { "my-agent": { "type": "custom", "command": "node", "args": ["~/projects/agent/index.js", "--acp"], "env": {} } } }
Zed passes its own Google key to Gemini as GEMINI_API_KEY if the process lacks one; it does not do so for Claude/Codex (same page). Whether Zed uses bundled Node or system npx for registry agents: [not-found] on that page (see Section D).
Method tags: (a) raw PTY + terminal emulator · (b) claude -p --output-format stream-json (or equivalent CLI JSON) · (c) vendor SDK (Claude Agent SDK / Codex SDK) · (d) ACP · (e) own agent loop / own daemon.
| Host | Method | Stack | What it taught |
|---|---|---|---|
| Zed | (d) | Rust editor; agents as subprocesses | The reference ACP client. Registry-installed agents (Claude, Codex, OpenCode, Copilot, Cursor, Pi) launch via npx <pkg> per agent.json; custom agents via agent_servers.{name}:{type:"custom", command, args, env}; "External agents are separate processes communicating over ACP" and "usually own their own runtime, auth, model selection, tools"; Zed's own MCP servers "may be forwarded to External Agents over ACP"; debug with dev::OpenAcpLogs [docs] https://raw.githubusercontent.com/zed-industries/zed/main/docs/src/ai/external-agents.md. Design rationale: "Just as the Language Server Protocol unbundled language intelligence from monolithic IDEs, our goal with the Agent Client Protocol is to enable you to switch between multiple agents without switching your editor" [blog] https://zed.dev/blog/bring-your-own-agent-to-zed. Zed downloads and manages its own Node runtime (Linux path ~/.local/share/zed/node/node-v*/bin/node; "Zed currently tries to download an upstream Node runtime no matter what") with node.path / node.npm_path / node.ignore_system_version settings [issue] https://github.com/zed-industries/zed/issues/12631, https://github.com/zed-industries/zed/issues/46162, [docs] https://zed.dev/docs/configuring-zed. Permission button labels and "always allow" persistence: [not-found] on the fetched pages. Zed passes its Google key as GEMINI_API_KEY but never its Anthropic/OpenAI keys to Claude/Codex (external-agents.md). |
| Anthropic Claude Code Desktop (Code tab) | (e) first-party | Electron-style desktop app; spawns its own Claude Code | Parallel sessions each get a git worktree at <project-root>/.claude/worktrees/ (configurable; .worktreeinclude for gitignored files); permission-mode selector (Manual/Accept edits/Plan/Auto/Bypass; dontAsk is CLI-only); usage ring shows context + plan usage; "The desktop app does not always inherit your full shell environment. On macOS, when you launch the app from the Dock or Finder, it reads your shell profile, such as ~/.zshrc or ~/.bashrc, to extract PATH and a fixed set of Claude Code variables, but other variables you export there are not picked up"; SSH sessions "install Claude Code on the remote machine automatically" [docs] https://code.claude.com/docs/en/desktop. Auth: "Claude Desktop and cloud sessions do not call apiKeyHelper or read these environment variables: they use OAuth" [docs] https://code.claude.com/docs/en/authentication. Whether it ships a bundled claude binary separate from the CLI install: [not-found] in the docs. |
| Conductor (conductor.build) | (c) Agent SDK | Tauri shell + Rust core + Bun [blog] | First-party: "Conductor uses native Claude Code, but we do so through the Claude Agent SDK" (2026-06-15 post on the paused subscription change; "No action is required") [docs] https://conductor.build/blog/claude-subscription-update. "Run parallel Claude Code, Codex, and Cursor agents in isolated workspaces on your Mac" [docs] https://conductor.build/; "Each task gets its own workspace, branch, files, terminal, diff, and review path" https://conductor.build/docs. Third-party write-up: Tauri, "Rust core spawning agent CLIs", Node→Bun runtime, --resume <uuid> [blog] https://performance.dev/the-conductor-rewrite. Per-agent auth is the CLI's own (claude /login, codex login) [docs] https://www.conductor.build/docs/installation (per lane report). Lesson: a native-feeling Mac app can be Tauri+Rust around the SDK, and the SDK road keeps the user's subscription (after Anthropic's pause). |
| Xum (ex-Mux, coder) | (e) own loop + PTY; ACP server | Electron 40, Vercel AI SDK, node-pty, xterm/ghostty-web, @agentclientprotocol/sdk |
"Xum has a custom agent loop but much of the core UX is inspired by Claude Code"; providers via API keys (no Claude subscription OAuth); runtimes Local / Worktree / SSH; Costs tab [docs] https://github.com/coder/mux (README), https://xum.coder.com. Exposes itself as an ACP agent (xum acp) so Zed can drive it [docs] https://xum.coder.com/integrations/acp.md (per lane). Lesson: if you write your own loop you lose the vendor's tools/skills/hooks/subscription; Xum's answer is ACP-server mode, not hosting the CLIs. |
| Emdash (generalaction) | (a) PTY + (d) ACP | Electron, node-pty, @agentclientprotocol/claude-agent-acp + @agentclientprotocol/codex-acp |
The closest open-source analogue to what Robert wants. Claude ACP spawn: command: process.execPath, args:[claude-agent-acp/dist/index.js], env:{ELECTRON_RUN_AS_NODE:'1', CLAUDE_CODE_EXECUTABLE: ctx.cli} — "Point the adapter's Claude Agent SDK at the host-installed claude binary instead of the SDK's auto-downloaded native binary" [docs] https://raw.githubusercontent.com/generalaction/emdash/main/packages/plugins/src/agents/impl/claude/index.ts (lines 140-160), adapter specifier '@agentclientprotocol/claude-agent-acp/dist/index.js' (…/claude/adapter.ts:5). Terminal-mode command uses --dangerously-skip-permissions as the auto-approve flag, --resume, --session-id, --model (same file). Architecture doc: "Desktop relies on Electron's child_process.fork behavior, which runs children with ELECTRON_RUN_AS_NODE. The packaged app must keep the RunAsNode fuse enabled" [docs] https://raw.githubusercontent.com/generalaction/emdash/main/agents/architecture/acp-runtime.md. Pre-seeds ~/.claude.json projects[<worktree>].hasTrustDialogAccepted=true so the CLI never blocks on the trust dialog (…/claude/trust.ts, per lane); installs marker-tagged UserPromptSubmit/Notification/Stop hooks and notes "Claude's Notification events carry no notification_type field" (…/claude/hooks.ts, per lane). Packaging: hardenedRuntime: true, entitlements app-sandbox=false, cs.allow-jit, cs.allow-unsigned-executable-memory, cs.disable-library-validation; asarUnpack for node-pty/**, **/*.node; "upstream node-pty tarballs ship the darwin spawn-helper prebuild without the exec bit" [docs] https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/electron-builder.config.ts, .scratch/dev-setup-overhaul-build/issues/04-native-dependency-rework.md (per lane). Changelog notes "Orphaned processes are cleaned up properly: detached descendants on kill" [docs] https://emdash.com/changelog (per lane). |
| Superconductor (superconductor.com) | (e) cloud VMs | web + native clients | "each implementation" runs in a full cloud VM; bring "your Claude Pro, Max, or Team plan, your ChatGPT subscription, your SuperGrok plan, or your own API keys"; shows 5-hour/7-day plan usage next to the agent and "Estimated API spend on each implementation card" [docs] https://www.superconductor.com/ , https://www.superconductor.com/docs/agents (per lane). Rationale for cloud: "juggling the worktrees added significant mental overhead and actually began straining our laptops" [blog] https://www.superconductor.com/blog/why-we-built-superconductor (per lane). A separate super.engineering ("No Electron. No Tauri. 100% Rust", agents "as local subprocesses on your own subscriptions") is closed alpha [blog] https://x.com/superdoteng/status/2042335263154978868 (per lane; site 403). |
| Piebald (piebald.ai) | (a)-like: drives the real interactive claude in the background |
closed source, desktop + web | Provider docs: uses claude from PATH / manual path / managed install, "internally use Claude Code to communicate with Anthropic's API so there's no risk of your account getting banned"; changelog v0.4.0 (2026-06-01): "we now run Claude Code interactively in the background without relying on the Agent SDK or claude -p" — explicitly to keep usage on the main subscription bucket after Anthropic's (later paused) split [docs] https://docs.piebald.ai/providers/claude-max.md , https://docs.piebald.ai/changelog.md (per lane). Reimplements the Claude hooks contract (SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/Stop/PreCompact/…) [docs] https://docs.piebald.ai/features/agentic/claude-code-hooks-compatibility.md. Permission modes Read-only / Auto-accept / Plan / YOLO; "persists all sessions (including pending tool-call approvals) across machine reboots" [blog] https://github.com/Jamie-BitFlight/claude_skills/blob/main/research/developer-tools/piebald.md. Piebald's tweakcc documents that the native claude is "a large platform-specific native executable containing the same minified/compiled JavaScript… packaged up in a Bun binary" [docs] https://github.com/Piebald-AI/tweakcc. Lesson: driving the interactive TUI over a PTY is the only road with zero policy ambiguity about subscriptions, at the cost of screen-scraping. "The Companion": [not-found]. |
| Claudia / opcode (getAsterisk) | (b) | Tauri 2 + Rust + React | Requires "claude is available in your PATH"; browses ~/.claude/projects/; "Cost Tracking: Monitor your Claude API usage and costs in real-time"; "Process Isolation: Agents run in separate processes" [docs] https://github.com/getAsterisk/claudia. Exact spawn flags [not-found] in the README. |
| CodeLayer / humanlayer | (b) via Go daemon | hld daemon (Go) + Tauri WUI + claudecode-go |
claudecode-go launches sessions with SessionConfig{ MCPConfig, PermissionPromptTool: "mcp__approvals__request_permission", AllowedTools: []string{"mcp__approvals__*"}, SessionID (resume) } and an MCP server humanlayer mcp claude_approvals — i.e. approvals via --permission-prompt-tool, not a PTY [docs] https://raw.githubusercontent.com/humanlayer/humanlayer/main/claudecode-go/README.md (lines 68-120). Lesson: the pre-SDK way to get a permission callback out of claude -p is an MCP tool the CLI calls. |
| Vibe Kanban (BloopAI) | (b)/(a) spawns each CLI | Rust backend + React, npx vibe-kanban |
"Switch between 10+ coding agents — Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, and Qwen Code"; "each workspace gives an agent a branch, a terminal, and a dev server"; project is sunsetting per README banner [docs] https://github.com/BloopAI/vibe-kanban. Spawn mechanics [not-found] in README. |
| Crystal (stravu) | — | Electron | "has been deprecated and replaced by Nimbalyst" [docs] https://github.com/stravu/crystal. |
| fazm | (d) via bundled-Node bridge | Swift + bundled Node 22 + adapters | Section A. Public positioning: "runs the real Claude Code, Codex, and Gemini CLI agent loop in a native Mac app, on your own Claude Pro or Max account"; "Sessions survive restarts, fork any chat in one click, nothing gets auto-compacted" [docs] https://fazm.ai/cc. Source: https://github.com/mediar-ai/fazm. |
| Workshop | — | — | No host app by that name found (Canonical's workshop is a container runner) [not-found] (lane). Other native-Mac hosts that surfaced but were not researched: Mosaic https://github.com/defyus/mosaic, diri https://github.com/cristicretu/diri, cmux https://github.com/manaflow-ai/cmux (lane, [blog]). |
| Happy Coder, Cline "Claude Code provider", Kilo | — | — | [not-found] — not reached in this pass. |
D.2 Additional host detail (from the prior-art lane; primary sources):
ShellBuilder::new(&Shell::System, …).non_interactive() → build_std_command(path, args) → envs(env) → current_dir(first worktree root) → Child::spawn(cmd, piped, piped, piped); stderr lines are logged as agent stderr: … into the ACP debug log; exit surfaces as LoadError with trailing stderr [docs] https://github.com/zed-industries/zed/blob/main/crates/agent_servers/src/acp.rs. util::process::Child::spawn does pre_exec(|| { libc::setsid(); Ok(()) }) and kill() = libc::killpg(pid, SIGKILL) — the rationale being a spawned shell stealing the foreground process group [docs] https://github.com/zed-industries/zed/blob/main/crates/util/src/process.rs, [blog] https://registerspill.thorstenball.com/p/how-to-lose-control-of-your-shell. Env passed = shell-resolved project env + registry distribution.env + per-agent settings_env + proxy vars (agent_servers.rs). Registry npx agents are npm installed into ~/Library/Application Support/Zed/external_agents/registry/npx/<id>/ with Zed's managed Node v24.11.0 and then run as node <resolved bin>; a cmd == "node" in a binary manifest is rewritten to the managed node [docs] https://github.com/zed-industries/zed/blob/main/crates/project/src/agent_server_store.rs, https://github.com/zed-industries/zed/blob/main/crates/node_runtime/src/node_runtime.rs. That node <bin> rewrite breaks packages whose bin is a native Mach-O [issue] https://github.com/zed-industries/zed/issues/62716. Zed's Gemini special-case adds a terminal auth method spawn-gemini-cli ("gemini /auth") (acp.rs). Cost: usage_update → TokenUsage + SessionCost{amount,currency} and a context ring since 1.7.2 [docs] https://github.com/zed-industries/zed/blob/main/crates/acp_thread/src/acp_thread.rs. Zed's ACP-host bug tracker is a preview of yours: agent processes never terminated (55 procs / 3.1 GB) #61303; archived sessions keep MCP servers alive #56747; wrong node picked from a workspace #45241; ERR_MODULE_NOT_FOUND zod #43675; Claude Code gets a different env than the editor (direnv) #38988; terminal.shell leaking into ACP spawns #46551; usage-limit exit 143 leaves a dead session #55501; dead connection never respawned #62828; permission prompts never render #62788 [issue] https://github.com/zed-industries/zed/issues/{61303,56747,45241,43675,38988,46551,55501,62828,62788}.npx -y @anthropic-ai/claude-code@2.1.119 -p --permission-prompt-tool=stdio --permission-mode=bypassPermissions --verbose --output-format=stream-json --input-format=stream-json --include-partial-messages --replay-user-messages, follow-ups with --resume <id> --resume-session-at <uuid>, env_remove("ANTHROPIC_API_KEY") when on subscription, tokio kill_on_drop + command_group [docs] https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/claude.rs. The control protocol it implements: host→CLI {"type":"control_request","request_id":"<uuid>","request":{"subtype":"initialize","hooks":{…}}}, {"subtype":"set_permission_mode","mode":"…"}, {"subtype":"interrupt"}; CLI→host control_request with subtype:"can_use_tool" (tool_name, input, permission_suggestions, tool_use_id) and "hook_callback"; host replies {"type":"control_response",…} [docs] https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/claude/protocol.rs. Gemini via --experimental-acp, Codex via codex app-server (gemini.rs, codex.rs). PATH refresh: $SHELL [-l] -c 'source ~/.zshrc; printf "%s" "$PATH"' with TERM=dumb, 5 s timeout (crates/utils/src/shell.rs). OpenCode zombies after sleep/wake under launchd [issue] https://github.com/BloopAI/vibe-kanban/issues/3205. This is the proof that a non-Node host can drive Claude Code's own SDK protocol directly over stdio (see the final recommendation).exec.Command(claudePath, args…), --resume <id> (+--fork-session), --output-format stream-json (auto --verbose), --mcp-config <inline JSON>, --permission-prompt-tool mcp__codelayer__request_permission, then --print -- <query>; binary discovery LookPath, ~/.claude/local/claude, ~/.npm/bin, ~/.bun/bin, ~/.local/bin, /usr/local/bin, /opt/homebrew/bin, then zsh -lc "which claude"; interrupt = SIGINT, kill = SIGKILL [docs] https://github.com/humanlayer/humanlayer/blob/main/claudecode-go/client.go. Daemon JSON-RPC over ~/.humanlayer/daemon.sock (0600) + SQLite; "restarting the daemon breaks active Claude sessions" [docs] https://github.com/humanlayer/humanlayer/blob/main/hld/PROTOCOL.md, DEVELOPMENT.md.pty.spawn(command, args, {name:'xterm-color', cols:80, rows:30}) and parsed stream-json off the PTY; on env: node: shebang failure re-spawned node --no-warnings --enable-source-maps <cli.js>; PATH via ${shell} -l -i -c 'echo $PATH'; teardown SIGTERM → kill -TERM -<pgid> → 200 ms → SIGKILL [docs] https://github.com/stravu/crystal/blob/main/main/src/services/panels/cli/AbstractCliManager.ts, main/src/utils/shellPath.ts. Nimbalyst persists "Always" as Bash(git:*) rules into .claude/settings.local.json [docs] https://docs.nimbalyst.com/open-safe-private-secure/permissions-and-safety.md.--dangerously-skip-permissions; env whitelist PATH, HOME, USER, SHELL, LANG, LC_*, NODE_PATH, NVM_DIR, NVM_BIN, HOMEBREW_PREFIX, HOMEBREW_CELLAR; discovery which, NVM_BIN, ~/.nvm/versions/node/*/bin, /usr/local/bin, /opt/homebrew/bin, ~/.claude/local, ~/.local/bin [docs] https://github.com/winfunc/opcode/blob/main/src-tauri/src/commands/claude.rs; classic GUI failure env: node: No such file or directory from the npm shebang #!/usr/bin/env -S node … [issue] https://github.com/winfunc/opcode/issues/94, #58.claude with stdio: ['inherit','inherit','inherit','pipe'] (fd 3 side channel) plus --settings <hookSettingsPath>; remote mode uses SDK query() + canUseTool → phone. Gotchas: Ink leaves stdin O_NONBLOCK (fix process.stdin._handle.setBlocking(true)); SDK sets CLAUDE_CODE_ENTRYPOINT=sdk-ts which hides sessions from claude --resume (#1202); launchd agents outside the Aqua session can't reach the Keychain → API Error: 401 (workaround: export CLAUDE_CODE_OAUTH_TOKEN) [docs] https://github.com/slopus/happy/blob/main/packages/happy-cli/src/claude/claudeLocal.ts, README, [issue] https://github.com/slopus/happy/issues/{80,1202}.claude -p --output-format stream-json --max-turns 1 and deleted ANTHROPIC_API_KEY; the current provider uses the Agent SDK with permissionMode:"acceptEdits" and no canUseTool [docs] https://github.com/cline/cline/blob/v3.20.0/src/integrations/claude-code/run.ts, sdk/packages/llms/src/providers/vendors/community.ts. Kilo: "As of January 2026… Claude Code credentials cannot be used in Kilo Code or other third-party harnesses"; Kilo CLI is itself an ACP agent (kilo acp) [docs] https://github.com/Kilo-Org/kilocode-legacy/blob/main/docs/legacy-ides/ai-providers/claude-code.md, https://zed.dev/acp/agent/kilo.--print/--output-format "Not available" [docs] https://code.claude.com/docs/en/desktop-quickstart. The VS Code extension "bundles its own copy of the CLI" and exposes claudeProcessWrapper ("Executable used to launch the Claude process. The bundled binary path is passed as an argument") [docs] https://code.claude.com/docs/en/vs-code.Cross-cutting lessons the hosts converged on:
.claude/worktrees/, Conductor, Emdash ~/emdash/worktrees, Xum ~/.xum/src/<project>/<workspace>, Vibe Kanban) — and all of them hit the "worktree lacks .env/node_modules" problem (.worktreeinclude, setup scripts).session/request_permission, Codex app-server item/*/requestApproval, SDK canUseTool, or the older --permission-prompt-tool MCP trick) — none scrape the TUI for "Allow?" prompts except Piebald.usage_update; Superconductor shows estimated API spend + plan windows; fazm had to patch the adapter; Codex app-server gives thread/tokenUsage/updated and account/rateLimits/*.E.1 App Sandbox is off the table for this design. A sandboxed app's children inherit its sandbox; per Apple's entitlement guide, "If your app employs a child process created with either the posix_spawn function or the NSTask class, you can configure the child process to inherit the sandbox of its parent… a child target must use exactly two App Sandbox entitlement keys: com.apple.security.app-sandbox and com.apple.security.inherit. If you specify any other App Sandbox entitlement, the system aborts the child process" [docs] https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html. A user-installed claude/codex/gemini is not signed with inherit, and the agents need $HOME-wide file access anyway. fazm (Fazm.entitlements: app-sandbox=false) and Emdash (entitlements.mac.plist: app-sandbox=false) both ship un-sandboxed Developer-ID builds. Consequence: no Mac App Store; distribute with Developer ID + notarization (fazm: Sparkle; AGENTS.md:224 "Signs with Developer ID, notarizes with Apple").
E.2 Hardened runtime + entitlements for the bundled runtime. Notarization requires --options runtime on every executable ("The executable does not have the hardened runtime enabled") and a secure timestamp ("The signature does not include a secure timestamp"; only timestamp.apple.com; "Generating a secure timestamp requires internet access"), and no com.apple.security.get-task-allow in shipping builds [docs] https://developer.apple.com/documentation/security/resolving-common-notarization-issues. V8 needs com.apple.security.cs.allow-jit ("Without the Allow execution of JIT-compiled code entitlement, frameworks that rely on just-in-time (JIT) compilation may fall back to an interpreter. Other code using JIT compilation may crash") [docs] https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-jit; fazm and Emdash additionally set cs.allow-unsigned-executable-memory ("Including this entitlement exposes your app to common vulnerabilities in memory-unsafe code languages") https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-unsigned-executable-memory; native .node addons / Python need cs.disable-library-validation ("prevents a program from loading frameworks, plug-ins, or libraries unless they're either signed by Apple or signed with the same Team ID… Gatekeeper runs extra security checks on programs that have it disabled") https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.disable-library-validation. Practice from fazm: sign node with --options runtime --entitlements Desktop/Node.entitlements (run.sh:657-663), sign every *.node/*.dylib/*.so/rg under node_modules individually (codemagic.yaml:808-816), keep Python from writing .pyc into the bundle (PYTHONDONTWRITEBYTECODE=1, else "invalidates the code signature and breaks Sparkle auto-updates", index.ts:2795-2798), rsync --delete node_modules so stale nested duplicates don't shadow (run.sh:301-306), and gate on a 16 K-page node binary ("macOS 26 will crash", codemagic.yaml:919-924).
E.3 The macOS 26 Code-Signing-Monitor trap. "On macOS 26+ (Tahoe), Sparkle auto-updates can silently corrupt the code signing seal of the bundled node binary. The kernel's Code Signing Monitor (CSM) then kills the process with SIGKILL on launch. The binary passes codesign --verify but still gets killed" — fazm copies node to $TMPDIR/fazm-node-<scope> and verifies it with node --version before every spawn (NodeBinaryHelper.swift:1-95). Bundle-scope the temp copy or dev and prod builds clobber each other (:20-27). [source; no Apple doc found].
E.4 PATH in a GUI app. Apps launched from Finder/Dock/LaunchAgent get /usr/bin:/bin:/usr/sbin:/sbin and never read ~/.zshrc [blog] https://github.com/sindresorhus/fix-path, https://www.bounga.org/tips/2020/04/07/instructs-mac-os-gui-apps-about-path-environment-variable/. Three viable strategies: (1) bundle everything and never look up PATH — fazm spawns every Node child with process.execPath and every binary by absolute path (index.ts:1585, 2517, 2557); (2) resolve the user's shell PATH once via $SHELL -ilc 'echo $PATH' (what fix-path/shell-env do; what Claude Desktop itself does: "reads your shell profile… to extract PATH" https://code.claude.com/docs/en/desktop); (3) a hard-coded ladder /opt/homebrew/bin, /usr/local/bin, ~/.nvm/versions/node/*, /usr/bin/which (fazm ACPBridge.swift:2537-2590, which also does no login shell). Also note LaunchServices may hand you /private/var/folders/... as cwd — fazm pins currentDirectoryURL to $HOME (ACPBridge.swift:588-597). Zed sidesteps the problem by downloading its own Node [issue] https://github.com/zed-industries/zed/issues/12631.
E.5 Bundling the runtimes. Claude: @agentclientprotocol/claude-agent-acp requires Node ≥ 22 (npm engines); the SDK "bundle[s] a native Claude Code binary… pinned to the SDK package version" (a Bun-compiled executable per tweakcc), dropped by npm ci --omit=optional, overridable with CLAUDE_CODE_EXECUTABLE / pathToClaudeCodeExecutable [docs] https://code.claude.com/docs/en/agent-sdk/hosting, quickstart; Emdash points the adapter at the host's claude so the user keeps one login. Codex: a Rust binary shipped in @openai/codex-darwin-arm64 at vendor/<triple>/bin/codex, or run codex app-server from the user's install [docs] https://registry.npmjs.org/@openai/codex/latest, sdk/typescript/src/exec.ts. Gemini: pure Node (bundle/gemini.js, Node ≥ 20) [docs] npm. fazm ships Node v22.14.0 in Contents/Resources/Fazm_Fazm.bundle/node (build.sh:41-69) plus the bridge's full node_modules (run.sh:296-306) — hundreds of MB, and every Mach-O inside must be signed (E.2). Alternative not researched: Node SEA / bun-compiled bridge [not-found].
E.6 PTY vs pipes, per CLI. Claude interactive needs a TTY; -p, the SDK and the ACP adapter run over plain pipes (stdio: pipe, fazm and Emdash). Codex: codex app-server is TTY-free by design; codex exec hangs at 0 % CPU when stdin is an inherited-but-never-closed pipe (fix: < /dev/null or close stdin) [issue] https://github.com/openai/codex/issues/20919 and can exit 0 with empty stdout when detached from a controlling TTY [issue] https://github.com/openai/codex/issues/19945. Gemini: setRawMode(true) only "if config.isInteractive() && process.stdin.isTTY", non-TTY stdin is read to EOF [docs] packages/cli/src/gemini.tsx; --acp with sandbox on and non-TTY stdin never starts [issue] https://github.com/google-gemini/gemini-cli/issues/23959. If you want a real terminal (E.10), SwiftTerm's LocalProcess "uses forkpty for pseudo-terminal support" with startProcess(executable:args:environment:execName:currentDirectory:) and a killEscalationDelay [docs] https://raw.githubusercontent.com/migueldeicaza/SwiftTerm/main/Sources/SwiftTerm/LocalProcess.swift (lines 183-212, 485). node-pty's darwin spawn-helper must be executable and signed (Emdash note, D).
E.7 ANSI / TERM / colour. Protocol channels (ACP stdout, app-server stdout, stream-json) are clean JSON; stderr is not — codex-acp's real error text arrives ANSI-coloured on stderr and fazm strips \x1b[[0-9;]*m before surfacing it (codex-provider.ts:259-274). Set NO_COLOR=1 for Gemini ("Set to any value to disable all color output") [docs] configuration.md; CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1 also skips the background title-generation model call in SDK/-p sessions [docs] env-vars. fazm's Swift side does no ANSI stripping at all (A.11) — tool output with escapes reaches the UI.
E.8 Process lifecycle: what actually kills the tree.
detached:true + kill(-pid) reaches only the direct child's group; MCP servers and the claude binary create their own groups and survive — fazm's Swift killProcessTree walks pgrep -P depth-first and SIGTERMs bottom-up (ACPBridge.swift:700-753); both bridge and adapter run a PPID watchdog (every 5 s, exit when PPID flips to 1) because "20+ orphan ACP bridges" were observed (index.ts:110-135, patched-acp-entry.mjs:19-51); sweepOrphanedBridges() on every start found "14 of 20 swept orphans survived SIGTERM and only died on SIGKILL" because the CLI's SIGTERM handler tries to flush IPC to a dead parent (ACPBridge.swift:841-846). macOS has no prctl(PR_SET_PDEATHSIG); the PPID poll or a pipe-closure watchdog (ws-relay.ts:76-84 uses kill(ppid, 0)) is the substitute.waitUntilExit or a >16 KB write deadlocks the child and your actor (ACPBridge.swift:779-782).index.ts:6317-6350); resume pending Swift continuations in deinit to avoid "SWIFT TASK CONTINUATION MISUSE" (ACPBridge.swift:538-545); use a generation counter so a stale terminationHandler can't clobber a restarted process (ACPBridge.swift:654-665).tool_use never gets a tool_result, "the Anthropic API parked waiting on that tool_use_id … the session was unrecoverable"; fazm added a SIGHUP drain-then-exit path (index.ts:6278-6316). SDK docs: SIGTERM → exit 143 with the turn unfinished; interrupt()/SIGINT ends cleanly (C.1.b). ACP session/cancel and Codex turn/interrupt are cooperative — a wedged browser tool needs SIGKILL of the MCP child (index.ts:143-198).--max-old-space-size=256 and screen-scrapes stderr for FatalProcessOutOfMemory / exit codes 133/134/5/6 (ACPBridge.swift:588, :2298-2303).E.9 Credentials and env hygiene.
~/.claude/.credentials.json with file mode 0600"; CLAUDE_CONFIG_DIR "keys the macOS Keychain entry to that directory too" [docs] https://code.claude.com/docs/en/authentication. The generic-password item is Claude Code-credentials (fazm oauth-flow.ts:31; read via /usr/bin/security find-generic-password -s "Claude Code-credentials" -w, ChatProvider.swift:2573-2578). Whether a differently-signed app reading that item triggers a Keychain ACL prompt: [not-found] in docs — treat as a risk and prefer letting the claude binary own its token.-p/SDK mode ANTHROPIC_API_KEY "is always used when present" and silently overrides the subscription [docs] env-vars — fazm removes it from the child env in personal mode (ACPBridge.swift:2367-2369) and blanks it for third-party MCP children ("no API key handed to subprocesses", index.ts:2643-2648). The SDK's env option replaces the environment (spread process.env yourself) [docs] typescript reference.CLAUDECODE from the child env or a nested launch refuses to start / --resume silently fails (index.ts:1562-1565; [docs] env-vars; [issue] https://github.com/anthropics/claude-code/issues/25803). CLAUDE_CODE_CHILD_SESSION-marked children are excluded from --resume/--continue unless CLAUDE_CODE_FORCE_SESSION_PERSISTENCE=1 [docs] env-vars.~/.codex/auth.json ("treat like a password") or keyring; CODEX_HOME; app-server account/login/start runs the browser flow for you [docs] https://learn.chatgpt.com/docs/auth, app-server README. Gemini: GEMINI_API_KEY; OAuth-personal needs an interactive browser flow that "is hostile to a background subprocess" (gemini-provider.ts:84-86; [issue] #12042).DISABLE_AUTOUPDATER=1, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1, DISABLE_TELEMETRY=1 (Claude; note "any non-empty value including 0 turns the behavior on") [docs] env-vars; general.enableAutoUpdate=false, privacy.usageStatisticsEnabled=false, GEMINI_TELEMETRY_ENABLED=false (Gemini) [docs] configuration.md; Codex [history] persistence, notify, hide_agent_reasoning [docs] config reference.E.10 Trust dialogs and first-run prompts block headless children. Claude asks "trust this folder?" on first use of a cwd — Emdash pre-seeds ~/.claude.json projects[<path>].hasTrustDialogAccepted=true (D); Gemini silently skips MCP registration for untrusted folders unless GEMINI_CLI_TRUST_WORKSPACE=true (gemini-provider.ts:196-203, [docs] configuration.md); Codex thread/start with a writable sandbox marks the project trusted in config.toml and codex exec refuses non-git dirs without --skip-git-repo-check (C.2). Claude's --bare/CLAUDE_CODE_SIMPLE=1 skips hooks/skills/MCP discovery and the Keychain — good for cheap background jobs, wrong for the user's main session [docs] env-vars.
E.11 Sessions, resume and crash recovery. Transcript locations: Claude ~/.claude/projects/<cwd, non-alnum→'-'>/<id>.jsonl (200-char truncation + hash) [docs] https://code.claude.com/docs/en/sessions; Codex ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl (index.ts:3323-3345; SDK README "Threads are persisted in ~/.codex/sessions"); Gemini ~/.gemini/tmp/<project_hash>/chats/session-*.jsonl [docs] session-management.md. Gotchas fazm paid for: resume is cwd-addressed (fixed upstream in CLI ≥ 2.1.223 cross-project search), "phantom" ids that never wrote a turn, resuming a cancelled session replays stale chunks (ACP #442, fixed in adapter 0.29.2), a mid-thinking cancel leaves an unsigned thinking block that 400s on resume (index.ts:5076-5090), session/prompt can never resolve (#630) so race it against an idle timer, and a hung MCP spawn hangs session/new for the whole warmup (index.ts:3203-3222). Bank the session id before the first prompt (session_started, protocol.ts:560-580) so a rate-limit on turn 1 doesn't orphan the conversation.
E.12 Concurrency and rate limits. "One agent session maps to one subprocess"; budget "1 GiB RAM, 5 GiB disk, and 1 CPU per agent" [docs] hosting page. Subscription limits are 5-hour and 7-day windows, surfaced as rate_limit_event {rateLimitType: five_hour|seven_day, utilization, resetsAt} (SDK; protocol.ts:372-383), and Codex account/rateLimits/updated. A credit-exhausted turn poisoned other sessions on the same adapter process (0 ms end_turn), so fazm restarts the whole adapter with a 30 s cooldown (index.ts:1930-2000). Per-tenant isolation: CLAUDE_CONFIG_DIR, settingSources: [], CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 [docs] hosting page. Anthropic's separate "Agent SDK credits" for -p/SDK/third-party apps were announced then paused on 2026-06-15 ("nothing has changed") [docs] https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan — but the legal page still forbids intermediating claude.ai credentials (C.1.c).
E.13 Terminal-in-app. Either advertise ACP terminal:true and implement terminal/create|output|wait_for_exit|kill|release yourself (then every shell command the agent runs is a PTY you own and can render in SwiftTerm, streamed via {"type":"terminal","terminalId"}), or advertise false and render tool_call_update.content text. Advertising fs capabilities means the agent will ask you for file contents (intended for unsaved editor buffers) — say false unless you are an editor (B.9; fazm sets both false, codex-provider.ts:219-222).
E.14 More verified gotchas (prior-art lane, primary sources).
Process spawns with POSIX_SPAWN_SETPGROUP|POSIX_SPAWN_CLOEXEC_DEFAULT and terminate() = kill(pid, SIGTERM) on the direct child only [docs] https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/Process.swift, https://developer.apple.com/documentation/foundation/process/terminate(). For a real tree kill from Swift use posix_spawn yourself with POSIX_SPAWN_SETSID (0x0400, in xnu spawn.h, not the man page) and killpg [docs] https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/spawn.h, https://keith.github.io/xcode-man-pages/posix_spawnattr_setflags.3.html — or copy Zed (setsid in pre_exec, killpg(SIGKILL)). Orphan detection without prctl: kqueue EVFILT_PROC NOTE_EXIT on the parent pid (what Bun's --no-orphans does on macOS) [docs] https://keith.github.io/xcode-man-pages/kqueue.2.html, [issue] https://github.com/oven-sh/bun/pull/29930.relaunchAppInChildProcess) and the bootstrap parent doesn't forward termination → child reparented to PID 1 holding the OAuth session [issue] https://github.com/google-gemini/gemini-cli/issues/25590 (fazm sets GEMINI_CLI_NO_RELAUNCH-adjacent env; see gemini.tsx).CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1 strips Anthropic/cloud credentials from Bash/hook/MCP children — useful for a host that injects the user's tools; a 2.1.251 regression also stripped CLAUDE_CONFIG_DIR [docs] env-vars, [issue] https://github.com/anthropics/claude-code/issues/91020. Interactive claude with piped stdin dies with Error: Raw mode is not supported on the current process.stdin, which Ink uses [issue] https://github.com/anthropics/claude-code/issues/5925. Codex TUI refuses TERM=dumb / non-terminal stdin outright (codex-rs/tui/src/tui.rs).posix_spawn + a spawn-helper binary, not forkpty (helper acquires the controlling TTY then execvp; exit via kqueue NOTE_EXIT) because hardened-runtime fork cost ~300 ms/spawn; prebuilt spawn-helper shipped without +x → posix_spawnp failed.; app.asar → app.asar.unpacked path rewrite bugs [docs] https://github.com/microsoft/node-pty/blob/main/src/unix/pty.cc, spawn-helper.cc, [issue] https://github.com/microsoft/node-pty/issues/{476,923,850,919,863,950}. If you go Swift-native, SwiftTerm's forkpty path avoids all of that — but its default env sets TERM=xterm-256color, COLORTERM=truecolor, LANG=en_US.UTF-8 and deliberately omits PATH, so always pass your own environment: [docs] https://github.com/migueldeicaza/SwiftTerm/blob/main/Sources/SwiftTerm/Terminal.swift.Claude Code-credentials item carries the partition list apple-tool: only, so a Swift app reading it via SecItem gets the "wants to use your confidential information" prompt, and "Always Allow" is reset every time Claude rewrites the item on token refresh (CodexBar #624/#458) [docs] https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains, https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md, [issue] https://github.com/steipete/CodexBar/issues/{624,458}. Historical Claude bug: setting CLAUDE_CODE_OAUTH_TOKEN deleted the Keychain item on exit [issue] https://github.com/anthropics/claude-code/issues/37512. Rule: let the claude binary own its credential; never read it from Swift.~/.claude.json. Multiple concurrent instances race on the config file ("JSON Parse error: Unexpected EOF", backups in ~/.claude/backups/; reported 8+ times, no documented lock) and race on OAuth refresh across many processes → spurious /login prompts [issue] https://github.com/anthropics/claude-code/issues/{28847,28922,3117,2593,24317,54443}. Mitigation: one CLAUDE_CONFIG_DIR per hosted session (plus CLAUDE_CODE_PROJECT_DIR_NAME, v2.1.234+: "This suits a host that embeds Claude Code and gives each session its own config directory") [docs] https://code.claude.com/docs/en/sessions. Resuming the same session in two processes without forking interleaves messages (same page).<shell> -l -i -c 'cd $HOME; <zed> --printenv >&0' under setsid and parses JSON out of the noisy rc output (fd 0 because >2 "can't be used in interactive zsh/old bash"); fails on .bashrc that exec fish #35759 [docs] https://github.com/zed-industries/zed/blob/main/crates/util/src/shell_env.rs. VS Code: $SHELL -i -l -c "'<execPath>' -p '…JSON.stringify(process.env)…'" with a 10 s timeout (application.shellEnvironmentResolutionTimeout) [docs] https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts. launchctl config user path <value> is the only launchd-level knob and is "intentionally scoped to the PATH environment variable" [docs] https://keith.github.io/xcode-man-pages/launchctl.1.html.claude shim is the wrong binary to spawn from a GUI: its shebang #!/usr/bin/env -S node --no-warnings --enable-source-maps fails without node on PATH (opcode #94); the native install (curl -fsSL https://claude.ai/install.sh | bash, ~/.local/bin/claude → ~/.local/share/claude/versions/, a Bun single-file executable "signed by 'Anthropic PBC' and notarized by Apple") and the npm @anthropic-ai/claude-code package both resolve to that native binary now [docs] https://code.claude.com/docs/en/setup, https://claude.ai/install.sh, CHANGELOG 2.1.181.com.apple.security.app-sandbox + com.apple.security.inherit ("Adding other entitlements to the tool can cause problems") [docs] https://developer.apple.com/documentation/xcode/embedding-a-helper-tool-in-a-sandboxed-app. Electron's @electron/osx-sign default entitlement is allow-jit only; electron-builder's template adds allow-unsigned-executable-memory + disable-library-validation; @electron/notarize says Electron ≥ 12 "should not" need unsigned-executable-memory [docs] https://github.com/electron/osx-sign/blob/main/entitlements/default.darwin.plist, https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/templates/entitlements.mac.plist, https://github.com/electron/notarize. Bun-compiled binaries need the JIT entitlements too [docs] https://bun.com/docs/bundler/executables.cli_auth_credentials_store = file|keyring|auto; keyring service "Codex Auth", account cli|<sha256(CODEX_HOME)[:16]> [docs] https://github.com/openai/codex/blob/main/codex-rs/login/src/auth/storage.rs. Gemini optional keychain gemini-cli-oauth when GEMINI_FORCE_ENCRYPTED_FILE_STORAGE=true; NO_BROWSER=true; a stray GOOGLE_CLOUD_PROJECT forces an org-subscription check [docs] https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/authentication.mdx, packages/core/src/config/storage.ts.libghostty-vt public alpha) [docs] https://ghostty-org-ghostty.mintlify.app/api/overview; SwiftTerm issues to know: processTerminated before final data (#370 → drainTimeout), macOS 15 Subprocess path (#472) [issue] https://github.com/migueldeicaza/SwiftTerm/issues/{370,472,534}.claude and parse its output" — the interactive TUI needs a PTY and emits Ink frames, not events; the machine surface is -p --output-format stream-json, the SDK, or the ACP adapter [docs] https://code.claude.com/docs/en/headless.claude -p is a different engine from the SDK" — the SDK "spawns a separate claude CLI process and talks to it over stdio"; they are the same binary [docs] https://code.claude.com/docs/en/agent-sdk/hosting.session/prompt returns token usage" — v1 returns only stopReason; per-turn usage is a draft RFD, adapters stuff it into _meta [docs] https://agentclientprotocol.com/rfds/end-turn-token-usage.session/set_model is an ACP method" — it is not in the spec; model choice is a session/set_config_option category (Gemini calls it unstable_setSessionModel) [docs] https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/main/schema/v1/schema.json.env for MCP servers is an object" — it is an array of {name,value}, and command must be absolute [docs] https://agentclientprotocol.com/protocol/v1/session-setup.allow/deny" — ids are agent-defined; kinds are allow_once|allow_always|reject_once|reject_always, and codex-acp fails closed on unknown ids (fazm's literal "allow" fallback is wrong) [docs] https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/docs/permission-extension.md.session/cancel kills the session" — it ends the turn; the session survives and must be reused, and the prompt must still return stopReason:"cancelled" [docs] https://agentclientprotocol.com/protocol/v1/prompt-turn.cancelled on turn cancel [docs] tool-calls page.source:{...} blocks" — ACP image blocks are flat {type:"image", data, mimeType} and there is no PDF block at all (index.ts:4489-4503).codex exec will prompt for approvals" — exec forces approval_policy: Never and auto-rejects every approval request; use codex app-server (or the ACP adapter) for interactive approvals [docs] https://raw.githubusercontent.com/openai/codex/main/codex-rs/exec/src/lib.rs."jsonrpc":"2.0" header is omitted on the wire [docs] https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md.@zed-industries/codex-acp / @zed-industries/claude-code-acp" — both are archived; the live packages are @agentclientprotocol/codex-acp 1.8.0 and @agentclientprotocol/claude-agent-acp 0.73.0 [docs] https://github.com/zed-industries/codex-acp, npm.gemini --experimental-acp" — deprecated in favour of gemini --acp; and it refuses to start with sandbox on and a non-TTY stdin [docs] packages/cli/src/config/config.ts, [issue] https://github.com/google-gemini/gemini-cli/issues/23959.authenticate {methodId:"gemini-api-key"} (or set security.auth.selectedType) or session/new fails with -32000 "Authentication required." [docs] packages/cli/src/acp/acpSessionManager.ts.GEMINI_CLI_TRUST_WORKSPACE=true; Claude blocks on a trust dialog unless hasTrustDialogAccepted is pre-seeded (gemini-provider.ts:196-203; Emdash trust.ts).kill(-pgid) cleans up the tree" — grandchildren (MCP servers, the claude binary) start their own process groups; walk pgrep -P and poll PPID for orphan detection (ACPBridge.swift:700-753, index.ts:110-135).tool_use without a tool_result and the API parks on it forever; drain first (index.ts:6278-6316); the SDK exits 143 with the turn unfinished [docs] typescript reference.Pipe after waitUntilExit deadlocks on >16 KB of output (ACPBridge.swift:779-782)./usr/bin:/bin:/usr/sbin:/sbin; bundle absolute paths or resolve via a login shell [blog] https://github.com/sindresorhus/fix-path; even Claude Desktop reads your shell profile to recover PATH [docs] https://code.claude.com/docs/en/desktop.app-sandbox + inherit; real hosts ship app-sandbox=false with Developer ID [docs] https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html.*.node, dylibs, rg) needs --options runtime --timestamp; node needs allow-jit; missing any one fails notarization [docs] https://developer.apple.com/documentation/security/resolving-common-notarization-issues.codesign --verify passing means it will run" — on macOS 26 the Code Signing Monitor SIGKILLs a binary whose seal Sparkle corrupted even though verify passes; copy the runtime out of the bundle before launch (NodeBinaryHelper.swift:3-13).ANTHROPIC_API_KEY alongside the user's login is harmless" — in -p/SDK mode the key "is always used when present" and silently bills the API instead of the subscription [docs] https://code.claude.com/docs/en/env-vars.Local (read-only): /Users/robertboulos/projects/fazm/acp-bridge/{package.json, package-lock.json, tsconfig.json, src/index.ts, src/protocol.ts, src/patched-acp-entry.mjs, src/acp-translate.ts, src/fazm-tools-stdio.ts, src/fazm-tools-http.ts, src/codex-provider.ts, src/codex-query.ts, src/codex-oauth-flow.ts, src/gemini-provider.ts, src/gemini-query.ts, src/oauth-flow.ts, src/api-failure.ts, src/ws-relay.ts, src/cron-runner.mjs, scripts/patch-playwright-overlay.cjs}; /Users/robertboulos/projects/fazm/Desktop/Sources/{Chat/ACPBridge.swift, Chat/NodeBinaryHelper.swift, Providers/ChatProvider.swift, Providers/ChatToolExecutor.swift, BundleExtension.swift, AuthService.swift, FazmApp.swift, FloatingControlBar/ShortcutSettings.swift, MainWindow/Components/ChatUIComponents.swift, Chat/CustomAPIEndpointCredentials.swift}; /Users/robertboulos/projects/fazm/Desktop/{Fazm.entitlements, Fazm-Release.entitlements, Node.entitlements, Python.entitlements}; /Users/robertboulos/projects/fazm/{run.sh, build.sh, codemagic.yaml, AGENTS.md, CLAUDE.md, README.md}. Upstream: https://raw.githubusercontent.com/mediar-ai/fazm/main/acp-bridge/src/{approval-gate.ts,index.ts} (commit d3816032).
ACP: https://agentclientprotocol.com/llms.txt · /overview/introduction · /protocol/overview · /protocol/v1/transports · /protocol/initialization · /protocol/v1/authentication · /protocol/v1/session-setup · /protocol/v1/session-list · /protocol/v1/prompt-turn · /protocol/content · /protocol/v1/tool-calls · /protocol/agent-plan · /protocol/slash-commands · /protocol/session-modes · /protocol/v1/session-config-options · /protocol/file-system · /protocol/terminals · /protocol/v1/elicitation · /protocol/v1/cancellation · /protocol/extensibility · /rfds/session-fork · /rfds/end-turn-token-usage · /rfds/session-usage · /announcements/acp-v2-draft · /libraries/typescript · /libraries/rust · /overview/agents · /overview/clients · https://github.com/agentclientprotocol/agent-client-protocol · https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/main/schema/v1/schema.json · https://github.com/agentclientprotocol/typescript-sdk/blob/main/src/examples/client.ts · https://github.com/agentclientprotocol/registry/blob/main/FORMAT.md · https://raw.githubusercontent.com/agentclientprotocol/registry/main/{claude-acp,codex-acp,gemini}/agent.json · https://github.com/newioapp/acp-inspector · npm registry views of @agentclientprotocol/sdk, @zed-industries/agent-client-protocol.
Claude: https://github.com/agentclientprotocol/claude-agent-acp (README, src/index.ts, src/acp-agent.ts, src/session-mode.ts, docs/permission-extension.md, docs/model-configuration.md, CHANGELOG) · issues #146 #337 #338 #363 #421 #744 #880 · https://code.claude.com/docs/llms.txt · /en/agent-sdk/overview · /quickstart · /typescript · /hosting · /permissions · /user-input · /sessions · /streaming-output · /cost-tracking · /modifying-system-prompts · /en/headless · /en/cli-reference · /en/sessions · /en/authentication · /en/env-vars · /en/legal-and-compliance · /en/desktop · https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan · https://zed.dev/blog/anthropic-subscription-changes · https://github.com/anthropics/claude-code/issues/25803 · npm @anthropic-ai/claude-agent-sdk 0.3.258 sdk.d.ts, @anthropic-ai/claude-code 2.1.258 · https://lobehub.com/mcp/user-claude-code-permission-prompt-tool (third-party) · https://github.com/Piebald-AI/tweakcc.
Codex: https://learn.chatgpt.com/docs/{non-interactive-mode, app-server, codex-sdk, auth, sandboxing, agent-approvals-security, config-file/config-reference, llms-full.txt} · https://developers.openai.com/codex/noninteractive (redirect) · https://raw.githubusercontent.com/openai/codex/main/codex-rs/{app-server/README.md, docs/codex_mcp_interface.md, exec/src/cli.rs, exec/src/lib.rs, exec/src/exec_events.rs, cli/src/main.rs, core/README.md, config/src/types.rs} · https://raw.githubusercontent.com/openai/codex/main/sdk/typescript/{README.md, src/codexOptions.ts, src/threadOptions.ts, src/events.ts, src/exec.ts} · https://registry.npmjs.org/@openai/{codex,codex-sdx}/latest · issues #20919 #19945 #18578 #39970 #7144 #23695 · https://github.com/zed-industries/codex-acp · https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/{README.md, readme-dev.md, docs/permission-extension.md} · https://www.infoq.com/news/2026/02/opanai-codex-app-server/ · https://codex.danielvaughan.com/2026/04/15/codex-app-server-complete-guide/ · https://github.com/openabdev/openab/issues/1352.
Gemini: https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/{cli/acp-mode.md, cli/headless.md, cli/cli-reference.md, reference/configuration.md, cli/session-management.md, cli/checkpointing.md, ide-integration/index.md} · https://geminicli.com/docs/cli/acp-mode/ · packages/cli/src/{config/config.ts, gemini.tsx, acp/README.md, acp/acpStdioTransport.ts, acp/acpRpcDispatcher.ts, acp/acpSessionManager.ts, acp/acpSession.ts, acp/acpUtils.ts, utils/errors.ts} · packages/core/src/{output/types.ts, services/chatRecordingTypes.ts} · issues #7880 #10855 #12042 #17952 #15502 #16504 #23959 #24916 #13924 #27466 #24280 · PRs #23673 #23680 #23818 · https://registry.npmjs.org/@google/gemini-cli/latest.
Hosts: https://raw.githubusercontent.com/zed-industries/zed/main/docs/src/ai/external-agents.md · https://zed.dev/docs/configuring-zed · https://zed.dev/blog/bring-your-own-agent-to-zed · https://github.com/zed-industries/zed/issues/{12631,46162,55283} · https://conductor.build/ · https://conductor.build/docs · https://conductor.build/blog/claude-subscription-update · https://www.conductor.build/docs/installation · https://performance.dev/the-conductor-rewrite · https://github.com/coder/mux · https://xum.coder.com/integrations/acp.md · https://github.com/generalaction/emdash (README; packages/plugins/src/agents/impl/claude/{index,adapter,trust,hooks}.ts; agents/architecture/acp-runtime.md; apps/emdash-desktop/electron-builder.config.ts; build/entitlements.mac.plist) · https://emdash.com/changelog · https://www.superconductor.com/ · https://www.superconductor.com/docs/agents · https://www.superconductor.com/blog/why-we-built-superconductor · https://x.com/superdoteng/status/2042335263154978868 · https://piebald.ai/ · https://docs.piebald.ai/{introduction, llms.txt, changelog.md, providers/claude-max.md, features/agentic/claude-code-hooks-compatibility.md} · https://github.com/Jamie-BitFlight/claude_skills/blob/main/research/developer-tools/piebald.md · https://github.com/getAsterisk/claudia · https://raw.githubusercontent.com/humanlayer/humanlayer/main/claudecode-go/README.md · https://github.com/BloopAI/vibe-kanban · https://github.com/stravu/crystal · https://fazm.ai/cc · https://github.com/mediar-ai/fazm · https://github.com/{defyus/mosaic, cristicretu/diri, manaflow-ai/cmux}.
Hosts (lane-verified, additional): https://github.com/zed-industries/zed/blob/main/crates/{agent_servers/src/acp.rs, agent_servers/src/agent_servers.rs, project/src/agent_server_store.rs, util/src/process.rs, util/src/shell_env.rs, node_runtime/src/node_runtime.rs, acp_thread/src/acp_thread.rs} · https://zed.dev/blog/claude-code-via-acp · https://zed.dev/docs/ai/{agent-panel, tool-permissions, terminal-threads, parallel-agents} · https://github.com/zed-industries/zed/issues/{61303,46474,56747,45241,43675,53309,38988,46551,55501,62828,62788,62716,51597,47910,35759,39506} · https://registerspill.thorstenball.com/p/how-to-lose-control-of-your-shell · https://github.com/BloopAI/vibe-kanban/blob/main/crates/{executors/src/executors/claude.rs, executors/src/executors/claude/protocol.rs, executors/src/executors/gemini.rs, executors/src/executors/codex.rs, utils/src/shell.rs} · https://github.com/BloopAI/vibe-kanban/issues/3205 · https://github.com/humanlayer/humanlayer/blob/main/{claudecode-go/client.go, hld/PROTOCOL.md, hld/session/manager.go, humanlayer-wui/src-tauri/src/daemon.rs, DEVELOPMENT.md} · https://github.com/stravu/crystal/blob/main/main/src/{services/panels/cli/AbstractCliManager.ts, utils/shellPath.ts} · https://docs.nimbalyst.com/open-safe-private-secure/permissions-and-safety.md · https://github.com/winfunc/opcode/blob/main/src-tauri/src/commands/{claude.rs, usage.rs} · https://github.com/winfunc/opcode/issues/{94,58} · https://github.com/slopus/happy (packages/happy-cli/README.md, src/claude/claudeLocal.ts) · https://github.com/slopus/happy/issues/{80,1202} · https://github.com/cline/cline/blob/v3.20.0/src/integrations/claude-code/run.ts · https://github.com/Kilo-Org/kilocode-legacy/blob/main/docs/legacy-ides/ai-providers/claude-code.md · https://zed.dev/acp/agent/kilo · https://code.claude.com/docs/en/{desktop-quickstart, vs-code, setup, iam, costs, errors, worktrees} · https://claude.ai/install.sh · https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md.
macOS / lifecycle (lane-verified, additional): https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/Process.swift · https://developer.apple.com/documentation/foundation/process/{terminate(),interrupt()} · https://keith.github.io/xcode-man-pages/{kill.2, posix_spawnattr_setflags.3, kqueue.2, launchctl.1, path_helper.8, xcode-select.1}.html · https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/spawn.h · https://man7.org/linux/man-pages/man2/pr_set_pdeathsig.2const.html · https://github.com/oven-sh/bun/pull/29930 · https://nodejs.org/api/child_process.html · https://github.com/pkrumins/node-tree-kill · https://developer.apple.com/documentation/xcode/embedding-a-helper-tool-in-a-sandboxed-app · https://developer.apple.com/documentation/security/{protecting-user-data-with-app-sandbox, hardened-runtime, notarizing-macos-software-before-distribution} · https://www.electronjs.org/docs/latest/tutorial/mac-app-store-submission-guide · https://github.com/electron/osx-sign/blob/main/entitlements/default.darwin.plist · https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/templates/entitlements.mac.plist · https://github.com/electron/notarize · https://v2.tauri.app/{develop/sidecar/, distribute/sign/macos/} · https://github.com/tauri-apps/tauri/issues/11992 · https://bun.com/docs/bundler/executables · https://nodejs.org/api/single-executable-applications.html · https://github.com/microsoft/node-pty/blob/main/src/unix/{pty.cc, spawn-helper.cc} · https://github.com/microsoft/node-pty/issues/{476,923,850,919,863,950} · https://github.com/chalk/{strip-ansi, ansi-regex, supports-color} · https://no-color.org/ · https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts · https://github.com/sindresorhus/shell-env · https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains · https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md · https://github.com/steipete/CodexBar/issues/{624,458} · https://github.com/anthropics/claude-code/issues/{5925,48375,59585,91020,29096,45717,19900,25442,37512,70697,20553,1757,5515,24317,54443,28847,28922,3117,2593} · https://github.com/openai/codex/issues/{15379,27758} · https://github.com/openai/codex/blob/main/codex-rs/{tui/src/tui.rs, login/src/auth/storage.rs, rollout/src/rollout_file_name.rs} · https://github.com/google-gemini/gemini-cli/issues/{25590,27290,25583} · https://github.com/google-gemini/gemini-cli/blob/main/{docs/get-started/authentication.mdx, packages/core/src/config/storage.ts} · https://github.com/migueldeicaza/SwiftTerm/blob/main/Sources/SwiftTerm/{Terminal.swift, Pty.swift, Mac/MacLocalTerminalView.swift} · https://github.com/migueldeicaza/SwiftTerm/issues/{370,472,534} · https://ghostty-org-ghostty.mintlify.app/api/overview · https://support.claude.com/en/articles/{11049741-what-is-the-max-plan, 11145838-using-claude-code-with-your-pro-or-max-plan}.
macOS: https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html · https://developer.apple.com/documentation/security/app-sandbox · https://developer.apple.com/documentation/security/resolving-common-notarization-issues · https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.{allow-jit, allow-unsigned-executable-memory, disable-library-validation} · https://indiestack.com/2017/09/sandbox-inheritance-tax/ · https://developer.apple.com/forums/thread/120647 · https://github.com/sindresorhus/fix-path · https://www.bounga.org/tips/2020/04/07/instructs-mac-os-gui-apps-about-path-environment-variable/ · https://raw.githubusercontent.com/migueldeicaza/SwiftTerm/main/{README.md, Sources/SwiftTerm/LocalProcess.swift}.
# Hosting coding-agent CLIs inside a native Mac app — extraction + research
Date: 2026-09-02. Scope: how a native macOS app (Swift + bundled Node) can host Claude Code, Codex CLI, Gemini CLI as embedded agents with the agent's steps, tool calls and permission prompts rendered in the app's own UI. Source of truth for Section A is fazm's `acp-bridge` (read-only; nothing was built or run). Web claims in B–F carry a URL and a tag: **[docs]** = verified in official docs/README, **[blog]** = third-party write-up, **[issue]** = GitHub issue/PR, **[not-found]** = could not verify.
Local paths use the prefix `FAZM=/Users/robertboulos/projects/fazm`.
---
## Section A — What fazm actually does (cited to source)
### A.1 Topology: three processes deep, two protocols
```
Fazm.app (Swift)
└─ spawn: <bundled node> acp-bridge/dist/index.js ← "the bridge" (JSON-lines, custom protocol)
└─ spawn: <same node> dist/patched-acp-entry.mjs ← ACP agent = @agentclientprotocol/claude-agent-acp 0.29.2 (JSON-RPC over stdio)
└─ spawn (inside the adapter, by @anthropic-ai/claude-agent-sdk 0.2.112): the `claude` CLI subprocess
└─ MCP servers spawned by the SDK: fazm_tools (node), playwright (node), macos-use, whatsapp, google-workspace (python), assrt, composio (http)
└─ spawn (lazy): node_modules/@zed-industries/codex-acp-darwin-arm64/bin/codex-acp ← Codex, native binary, ACP over stdio
└─ spawn (lazy, flag-gated): <same node> node_modules/@google/gemini-cli/bundle/gemini.js --experimental-acp ← Gemini, ACP over stdio
└─ listen: unix socket $TMPDIR/fazm-tools-<pid>.sock ← fazm_tools MCP server dials back here to reach Swift
```
- Header comment describing the flow: `FAZM/acp-bridge/src/index.ts:1-27` ("translates between Fazm's JSON-lines protocol and the Agent Client Protocol (ACP) used by claude-code-acp… Spawn claude-code-acp as subprocess (JSON-RPC over stdio)").
- Dependencies pinned: `@agentclientprotocol/claude-agent-acp 0.29.2`, `@zed-industries/codex-acp 0.12.0`, `@google/gemini-cli ^0.42.0`, `@playwright/mcp 0.0.73` (`FAZM/acp-bridge/package.json:16-24`). Lockfile resolves the nested SDK to `@anthropic-ai/claude-agent-sdk 0.2.112` and `@agentclientprotocol/sdk 0.19.0` (`FAZM/acp-bridge/package-lock.json:25-49`).
- **Two protocols, not one.** Swift ⇄ bridge is a *custom* newline-delimited JSON protocol defined in `FAZM/acp-bridge/src/protocol.ts` (inbound types `query|tool_result|stop|interrupt|force_interrupt|close_session|authenticate|warmup|resetSession|transferSession|cancel_auth|forkSession|codex_*|gemini_init_probe`, `protocol.ts:207-224`; outbound `text_delta|tool_use|tool_activity|tool_result_display|thinking_delta|text_block_boundary|result|error|auth_required|…|session_meta_update|session_forked`, `protocol.ts:700-746`). Bridge ⇄ agents is **ACP** (JSON-RPC 2.0, one JSON object per line) — `acpRequest()` builds `{jsonrpc:"2.0", id, method, params}` (`index.ts:1513-1544`), `acpNotify()` omits `id` (`index.ts:1546-1555`).
### A.2 How each CLI is spawned (binary, args, env, cwd)
**Claude Code (via the ACP adapter, patched):**
- `spawn(process.execPath, [join(__dirname,"patched-acp-entry.mjs")], { env, stdio:["pipe","pipe","pipe"], detached:true })` — `index.ts:1557-1585`. `process.execPath` is the bundled Node that runs the bridge itself, so no PATH lookup is ever done for node.
- Env: copies `process.env`, **deletes `CLAUDECODE`** ("so the ACP subprocess (and the Claude Code it spawns) don't inherit the nested-session guard. Without this, `--resume` silently fails when Claude Code detects it's being launched from inside another Claude Code session"), sets `NODE_NO_WARNINGS=1` (`index.ts:1558-1567`). Three auth modes are inferred purely from env: `FAZM_CUSTOM_API_ENDPOINT=true` → "Mode C", `ANTHROPIC_API_KEY` present → "Mode A (Fazm API key)", neither → "Mode B (Your Claude Account / OAuth)" (`index.ts:1574-1580`). `CLAUDE_CODE_USE_VERTEX` is allowed to flow through (`index.ts:1561`).
- The adapter is not launched via its own bin; fazm imports `ClaudeAcpAgent, runAcp` from `@agentclientprotocol/claude-agent-acp/dist/acp-agent.js` and monkey-patches `ClaudeAcpAgent.prototype.createSession` and `.prompt` before calling `runAcp()` (`FAZM/acp-bridge/src/patched-acp-entry.mjs:17, 54-58, 232-234, 281`). The patch (a) wraps the SDK `query.next()` iterator to capture `total_cost_usd`, `usage`, `modelUsage`, `terminal_reason`, `errors` from `type:"result"` messages (`patched-acp-entry.mjs:73-93`) and (b) re-emits SDK events the stock adapter drops — `compact_boundary`, `status`, `task_started`, `task_notification`, `api_retry`, `rate_limit_event`, `tool_progress`, `tool_use_summary`, and compaction `stream_event`s — as custom `session/update` kinds (`patched-acp-entry.mjs:95-228`). `prompt()` is patched to return `usage` + `_meta.costUsd` on the ACP `PromptResponse` (`patched-acp-entry.mjs:236-278`).
- cwd is *not* set on the process; it is passed per session in `session/new {cwd}` (`index.ts:3986-3990`). Default cwd is `homedir()` (`index.ts:3255`).
- The Node runtime is bundled at `Contents/Resources/Fazm_Fazm.bundle/node` and signed with `--options runtime --entitlements Desktop/Node.entitlements` (`FAZM/run.sh:657-663`); the bridge's `dist/` + full `node_modules/` are rsynced into `Contents/Resources/acp-bridge/` (`run.sh:296-306`). Bundled MCP binaries are at `Contents/MacOS/mcp-server-macos-use` and `Contents/MacOS/whatsapp-mcp`, resolved relative to `process.execPath` (`index.ts:213-217`).
**Codex (via `codex-acp`, a native Rust binary shipped in an npm platform package):**
- Binary path: `node_modules/@zed-industries/codex-acp-darwin-{arm64|x64}/bin/codex-acp` (`FAZM/acp-bridge/src/codex-provider.ts:62-68`).
- `spawn(binaryPath, [], { env, stdio:["pipe","pipe","pipe"], detached:true })` (`codex-provider.ts:150-155`). Env is `process.env` as-is (`codex-provider.ts:96`).
- `initialize` is sent with `clientCapabilities: { fs: { readTextFile:false, writeTextFile:false } }` (`codex-provider.ts:219-222`) — i.e. fazm declines the client-side FS methods.
- Auth is implicit via `~/.codex/auth.json`; fazm reads `auth_mode` from it to report `chatgpt|api_key|none` (`index.ts:1232-1243`) and reimplements the Codex OAuth PKCE flow itself (client id `app_EMoamEEZ73f0CkXaXp7hrann`, `https://auth.openai.com/oauth/authorize`, scopes `openid profile email offline_access api.connectors.read api.connectors.invoke`) writing `~/.codex/auth.json` (`FAZM/acp-bridge/src/codex-oauth-flow.ts:1-30`). After login it **must kill and respawn codex-acp** because "the existing subprocess was spawned without auth and won't re-read auth.json on its own" (`index.ts:1418-1428`).
- codex-acp's JSON-RPC error for a failed prompt is a bare "Internal error"; the real reason ("You've hit your usage limit") is only on stderr, so fazm scrapes stderr for `Unhandled error during turn:` after stripping ANSI (`codex-provider.ts:82-86, 259-274`).
**Gemini CLI (native ACP mode):**
- `spawn(process.execPath, [node_modules/@google/gemini-cli/bundle/gemini.js, "--experimental-acp"], { env: {...env, GEMINI_CLI_TRUST_WORKSPACE:"true"}, stdio: pipes, detached:true })` (`FAZM/acp-bridge/src/gemini-provider.ts:63-69, 194-213`).
- `GEMINI_CLI_TRUST_WORKSPACE=true` is required because "gemini-cli silently skips MCP server registration when the workspace isn't in `~/.gemini/trustedFolders.json`" (`gemini-provider.ts:196-203`).
- Requires an explicit ACP `authenticate {methodId}` after `initialize`; fazm picks `vertex-ai` if `GOOGLE_GENAI_USE_VERTEXAI`, else `gemini-api-key` if `GEMINI_API_KEY|GOOGLE_API_KEY`, else **refuses** — "OAuth-personal requires an interactive browser flow that's hostile to a background subprocess; refuse rather than hang" (`gemini-provider.ts:74-86, 306-317`).
- Feature-flagged off by default: `FAZM_GEMINI_ENABLED=true` (`index.ts:1325-1344`).
**All three are spawned `detached:true`** so each becomes a process-group leader and the whole tree can be killed with `process.kill(-pid, "SIGTERM")` (`index.ts:1185-1210`, `codex-provider.ts:188-199`, `gemini-provider.ts:249-260`).
### A.3 Session lifecycle: create, resume, fork, close
- **Handshake:** `initialize {protocolVersion:1}` → stores `authMethods` from the result (`index.ts:2380-2415`). An auth error is ACP code `-32000`, *or* `-32603` whose message matches `/401|failed to authenticate/` ("ACP sometimes wraps 401 as a generic -32603 internal error") (`index.ts:1802-1811`).
- **Warmup** (pre-create sessions before the user types): Swift sends `{type:"warmup", cwd, sessions:[{key, model, systemPrompt, resume?}]}` (`protocol.ts:107-119`). Bridge calls `session/new {cwd, mcpServers, _meta:{claudeCode:{options:{disallowedTools:[…]}}, systemPrompt}}` (`index.ts:3474-3478`, `buildMeta` at `index.ts:2966-2980`), then `session/set_model {sessionId, modelId}` (`index.ts:3567`). Warmup has a 240 s hard ceiling (45 s in custom-endpoint mode) (`index.ts:3210-3224`). The result's `models.availableModels` is forwarded to Swift as `models_available` (`index.ts:3530-3531`, `3128`).
- **Resume:** the Claude adapter exposes a non-standard `session/resume {sessionId, cwd, mcpServers}` (`index.ts:3517-3521`, `3949-3953`); Codex and Gemini use the spec's `session/load` (`codex-query.ts:140-144`, `gemini-query.ts:465-469`). After any resume fazm re-sends `session/set_model` because "without this the session uses the SDK default (possibly Haiku)" (`index.ts:3524-3526`, `3966-3967`).
- **Resume is cwd-addressed and fragile.** The SDK stores transcripts at `~/.claude/projects/<cwd with non-alphanumerics → '-'>/<sessionId>.jsonl`; passing a different cwd than at creation makes resume fail with "Resource not found" (`index.ts:2000-2018`, encoder at `3271-3273`). fazm therefore (1) persists `sessionId→cwd` in `~/.fazm/acp-sessions.json` (`index.ts:2019-2030`), (2) extracts `"cwd":` from the JSONL as a backstop (`index.ts:2097`), (3) pre-checks the JSONL exists to skip "phantom" ids that were handed out but never wrote a turn (`index.ts:3292-3348`, `3908-3925`), and (4) physically moves the JSONL between project dirs when a window's cwd changes (`migrateJsonlForCwdChange`, `index.ts:3366-3387`). Codex transcripts live at `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl` (`index.ts:3323-3345`).
- **Fork:** `session/fork {sessionId, cwd, mcpServers}` on the Claude adapter (`index.ts:5553-5557`); protocol note says upstream `unstable_forkSession` "does not support mid-history anchors" (`protocol.ts:158-167`).
- **Close:** `session/close {sessionId}` "instructs the SDK to terminate the claude subprocess" (`index.ts:6940-6947` in main's `close_session` case; also on cwd change `3806-3818`). Comment: leaving warm sessions alive forever "was the structural cause of the CPU regression reported 2026-05-14".
- **Cross-provider switch**: same session key can move Claude↔Codex↔Gemini by model id prefix (`gpt-|codex-|o[0-9]` → Codex `codex-query.ts:73`; `gemini-|auto-gemini-` → Gemini `gemini-query.ts:399`); the foreign session is `session/close`d first (`index.ts:3633-3676`).
### A.4 Sending prompts
- `session/prompt {sessionId, prompt:[…content blocks]}` (`index.ts:4515-4518`). Attachments become ACP content blocks: images as **flat** `{type:"image", data:<base64>, mimeType}` — comment: "ACP expects flat {type, data, mimeType}, NOT the Anthropic API nested {source:…} format" (`index.ts:4489-4495`); PDFs are *not* inlined (ACP has no document type) — a text block tells the model to `Read` the path (`index.ts:4496-4503`); text files inlined up to 10 MB, images/PDF 20 MB, everything else path-only (`index.ts:4443-4444`).
- **Slash commands** are just prompts: "ACP has no separate `session/run_command` RPC. Slash commands surfaced via `available_commands_update` execute by sending the literal slash text (e.g. `/compact`) as the prompt" (`protocol.ts:182-186`).
- The system prompt goes in `_meta.systemPrompt` on `session/new` (Claude adapter honours it); for Codex/Gemini fazm *also* prepends `<system_instructions>` as a text block on the first prompt "so behavior is consistent regardless of whether codex-acp honors the meta field today" (`codex-query.ts:10-13, 81-99, 218-233`).
- Codex/Gemini paths report `usage` from `PromptResponse.usage` (spec, `@experimental`) with fallback to gemini-cli's `_meta.quota.token_count`; codex-acp 0.12.0 "surfaces nothing" so tokens are 0 (`codex-query.ts:26-30, 256-264`; `gemini-query.ts:573-582`).
### A.5 Parsing and rendering the stream
The bridge's `handleSessionUpdate` (`index.ts:5602-6200`) switches on `params.update.sessionUpdate`:
| ACP `sessionUpdate` | fazm → Swift | Notes / source |
|---|---|---|
| `agent_message_chunk` | `text_delta` (+ `text_block_boundary` after a tool) | strips leaked harness prefixes `(your turn — …)` / `<system-reminder>` at turn start (`index.ts:952-1053`, `5640-5670`) |
| `agent_thought_chunk` | `thinking_delta` | `5710-5717` |
| `tool_call` | `tool_use` + `tool_activity{status:"started", input:rawInput}` | title recovery from `_meta.claudeCode.toolName` when title is "unknown"/contains "undefined" (WebSearch/WebFetch) `5725-5738`; `ToolSearch` hidden from UI `5768` |
| `tool_call_update` (status completed/failed/cancelled) | `tool_activity{completed}` + `tool_result_display{output}` (2000-char truncation) | output extracted from `content[]` — both direct MCP `{type:"text"}` and ACP-wrapped `{type:"content", content:{type:"text"}}` shapes — then `rawOutput` fallback, images skipped (`5889-5925`); `isError`/`is_error` flag honoured `5883` |
| `plan` | `thinking_delta` per entry | `6003-6015` |
| `available_commands_update` | `available_commands_update` | `6175-6193` |
| `usage_update`, `config_option_update`, `current_mode_update`, `session_info_update` | `session_meta_update{kind,payload}` (late-arrival rescue) | `index.ts:1716-1741` |
| custom (patched adapter): `compact_boundary`, `status_change`, `compaction_start/delta`, `task_started`, `task_notification`, `tool_progress`, `tool_use_summary`, `rate_limit`, `api_retry` | same-named Swift events | `6019-6172` |
Codex and Gemini share a simpler translator, `translateCodexUpdate` (`FAZM/acp-bridge/src/acp-translate.ts:37-160`), which the Claude path deliberately does *not* use yet (`acp-translate.ts:9-16`).
**Diffs and terminals:** fazm does **not** render ACP `diff` or `terminal` content blocks natively — tool results are flattened to text; `content[].type==="diff"` is never referenced in the bridge. Swift-side rendering is covered in A.11.
### A.6 Permissions — the local checkout auto-approves everything; upstream has since added a gate
**Local checkout (commit f10c620d, 2026-07-29):** `session/request_permission` is answered in the stdout line handler, never surfaced to Swift: pick the option with `kind==="allow_always"`, else `allow_once`, else the literal `"allow"`, and reply `{outcome:{outcome:"selected", optionId}}` (`index.ts:1616-1628`, comment "Auto-approve all tool permissions (matches agent-bridge's bypassPermissions behavior)"). Codex and Gemini providers use the identical default resolver (`codex-provider.ts:108-117`, `gemini-provider.ts:158-167`).
What fazm *does* gate is its **own** tools: an `ask|act` mode env (`FAZM_QUERY_MODE`) makes `execute_sql` refuse non-SELECT in ask mode (`fazm-tools-stdio.ts:826-842`); in "observer" sessions writes are converted into an approval card row (`approval_request` with `pending_operations`) that the user approves in-app (`fazm-tools-stdio.ts:891-920`). The `ask_followup` tool is the "quick-reply buttons" primitive that blocks the turn until the user clicks (`fazm-tools-stdio.ts:449-471`, 600 s ceiling `163-172`).
Also blocked: SDK tools that need a runtime fazm lacks — `ScheduleWakeup, CronCreate/Delete/List, RemoteTrigger, Monitor, PushNotification` — via `_meta.claudeCode.options.disallowedTools` ("Exposing them … produces silent end-of-turn dead-ends") (`index.ts:2948-2964`).
**Upstream `mediar-ai/fazm` main (d3816032, 2026-09-02, v2.9.89) — verified via raw.githubusercontent.com:** a new `acp-bridge/src/approval-gate.ts` ("cage mode") replaces the blanket auto-approve. Header comment: "Historically every provider (claude / codex / gemini) blanket-auto-approved tool permissions, which let the agent run destructive actions (rm, cache clears, file edits) without the user ever seeing them." `FAZM_APPROVAL_MODE` = `off` (default; "Headless runners … never set the var, so they stay on `off` and can never hang on approval") | `destructive` (gate only ACP `toolCall.kind` in `edit|delete|move|execute`) | `always` (`approval-gate.ts:1-38`). A gated request is parked and re-emitted to Swift as `{type:"permission_request", id, toolCallId, title, kind, options}`; Swift answers with a `permission_response` stdin command; `APPROVAL_TIMEOUT_MS = 300_000` after which the bridge replies `{outcome:{outcome:"cancelled"}}` and emits `permission_timeout` (`approval-gate.ts:108-175`). The gate id is namespaced `provider:rpcId:seq` because "each provider subprocess has its own JSON-RPC id counter, so raw ids collide across providers" (`:140-146`). Two more pieces were required to make the gate actually fire against Claude: (1) `session/set_mode {modeId:"default"}` after every session registration, because "The adapter derives its initial permission mode from the user's Claude Code settings (`permissions.defaultMode` — often bypassPermissions on dev machines), which would silently allow everything and starve the gate" (upstream `index.ts:1940-1951`); (2) `_meta.claudeCode.options.settingSources = []` for gated sessions so "a `permissions.allow: ["Bash"]` rule or `defaultMode: bypassPermissions`" in `~/.claude/settings.json` cannot pre-approve tools (upstream `index.ts:3029-3038`). The same handler in the stdout loop now routes `session/request_permission` through `approvalGate.handleRequest("claude", id, params, reply)` (upstream `index.ts:1643-1650`). The local checkout is therefore ~5 weeks and one significant feature behind upstream; everything else in Section A was read from the local tree.
### A.7 Cancellation — three tiers
1. **Cooperative:** Swift `{type:"interrupt", sessionKey}` → `ctx.abortController.abort()` + `acpNotify("session/cancel", {sessionId})`. Per ACP, "cancel ends the *turn*, not the *session*", and since claude-agent-acp 0.29.2 (fix for ACP #442) the cached session is kept and the next prompt continues in the same session (`index.ts:6822-6846`).
2. **Race the await:** `acpRequest` does not observe the abort signal, so the prompt is `Promise.race`d against an `abortPromise`, an inactivity "finalization-idle" arm, and (after an interrupt) a TTFT watchdog; otherwise a Stop during a 77 s Terminal tool left the await hanging ("May 5 2026 incident") (`index.ts:4634-4670`, `4715-4720`).
3. **Force:** `{type:"force_interrupt"}` also `SIGKILL`s every descendant whose `ps command` matches `/playwright/` because "ACP's `session/cancel` is cooperative and a wedged playwright tool ignores it" (`index.ts:143-198`, `6888-6938`). Comment admits per-session targeting is impossible: "the SDK doesn't expose which playwright PID belongs to which session".
### A.8 Injecting the app's own tools into the agent (`fazm-tools-stdio.ts`)
- fazm's tools are exposed as a **stdio MCP server** listed in every `session/new`'s `mcpServers`: `{name:"fazm_tools", command:process.execPath, args:[dist/fazm-tools-stdio.js], env:[FAZM_BRIDGE_PIPE, FAZM_QUERY_MODE, FAZM_WORKSPACE, FAZM_SESSION_KEY, …]}` (`index.ts:2495-2523`).
- The MCP server process is spawned by the *agent* (SDK), not by fazm, so it dials **back** to the bridge over a Unix socket (`$TMPDIR/fazm-tools-<bridgePid>.sock`, `index.ts:1070-1183`; client side `fazm-tools-stdio.ts:55-120`). A `tools/call` becomes `{type:"tool_use", callId, name, input, sessionKey}` on the socket (`fazm-tools-stdio.ts:178-209`); the bridge forwards it to Swift on stdout as `tool_use` (`index.ts:1108-1128`); Swift answers `{type:"tool_result", callId, result}` (`protocol.ts:47-51`) which flows back through the socket to resolve the MCP call (`index.ts:1059-1068`, `1131-1146`).
- MCP protocol version advertised: `"2024-11-05"`, capabilities `{tools:{}}` (`fazm-tools-stdio.ts:787-798`). Tools are filtered per session type (onboarding / observer / regular / voice) by env vars (`fazm-tools-stdio.ts:213-215, 617-630`).
- A retired variant, `fazm-tools-http.ts`, did the same over a localhost HTTP MCP endpoint (`FAZM/acp-bridge/src/fazm-tools-http.ts:1-8`).
- Other MCP servers are merged from `~/.fazm/mcp-servers.json` and — opt-out via `FAZM_DISABLE_CLAUDE_CODE_MCP` — the user's own `~/.claude.json` `mcpServers` (both stdio and http shapes) (`index.ts:2836-2946`). HTTP MCP entries take `{name, type:"http", url, headers:[{name,value}]}` (`index.ts:2476-2481`); stdio entries take `env` as an **array of `{name,value}`**, not an object (`index.ts:2469-2474`).
### A.9 Auth / keys
- **Claude:** three modes (A.2). In OAuth mode the bridge runs its *own* PKCE flow (client id `9d1c250a-e61b-44d9-88ed-5944d1962f5e`, `https://claude.ai/oauth/authorize`, token URL `https://console.anthropic.com/v1/oauth/token`, scopes `user:inference user:profile user:file_upload user:mcp_servers user:sessions:claude_code`) and writes the result into the **macOS Keychain generic password `Claude Code-credentials`** as `{claudeAiOauth:{accessToken, refreshToken, expiresAt, scopes, storedAt}}` via `security add-generic-password -U` — i.e. the exact item the `claude` CLI reads (`FAZM/acp-bridge/src/oauth-flow.ts:24-31, 380-420`). Note: "Do NOT include `expires_in` in the token-exchange body… HTTP 400 … since mid-May 2026" (`oauth-flow.ts:296-301`). The callback server binds both `127.0.0.1` and `::1` because "Browsers using Happy Eyeballs often try ::1 first" (`oauth-flow.ts:158-189`). After OAuth the adapter subprocess is restarted to pick up the Keychain item (`index.ts:2251-2270`, `2346-2352`).
- Auth-required detection during `initialize` or `session/prompt` triggers the flow with max 2 retries (`index.ts:2238`, `5161-5205`). In "builtin key" mode a 401 instead emits `builtin_key_invalid` so Swift can refetch the key (`index.ts:1823-1827`).
- **Codex:** `~/.codex/auth.json` (A.2). **Gemini:** env only (A.2).
- Env hygiene: fazm blanks `ANTHROPIC_API_KEY` for the Assrt MCP subprocess ("Fazm's policy is 'no API key handed to subprocesses'") (`index.ts:2643-2648`) but otherwise passes the full bridge env to every agent.
### A.10 Error / reconnect / lifecycle handling
- **Parent-death watchdog** in both bridge and adapter: poll `process.ppid` every 5 s; if it flips to 1 (launchd adopted us) walk `pgrep -P` descendants, SIGTERM each, `kill(-pgid)`, exit. "Root cause of the 20+ orphan ACP bridges observed Apr 30 2026" (`index.ts:110-135`, `patched-acp-entry.mjs:19-51`). The ws-relay uses `process.kill(ppid, 0)` liveness instead (`FAZM/acp-bridge/src/ws-relay.ts:76-84`).
- **EPIPE = parent gone → exit 0**, on `uncaughtException`, `stdout.error`, `stderr.error` (`index.ts:6317-6350`); stdin close → kill tree and exit (`index.ts:7000-7006`).
- **Adapter exit** rejects all pending RPCs and clears all sessions ("All sessions are lost when ACP process dies") (`index.ts:1768-1788`).
- **Credit-exhausted / rate-limit:** classify with HTTP status first because the SDK tags 529 `overloaded_error` as `rate_limit` — treating it as credit exhaustion "is exactly what happened in production on 2026-05-14" (`FAZM/acp-bridge/src/api-failure.ts:14-30`). On genuine exhaustion the whole adapter subprocess is restarted (30 s cooldown) rather than scrubbing state (`index.ts:1930-2000`). Rate-limit events carry `five_hour|seven_day` type, `utilization`, `resetsAt` (`protocol.ts:372-383`).
- **Stall detection:** per-tool timeouts (MCP 300 s, Bash 900 s, Task 1800 s, interactive 1800 s) (`index.ts:275-281`); a "stall detector" flags `mcp__*` tools silent > 15 s as `tool_stalled` without cancelling (`index.ts:6650-6700`); a "finalization-idle" arm rescues turns whose `session/prompt` never resolves after streaming (upstream claude-agent-acp #630) (`index.ts:4527-4535`, `5076-5140`); a Task-subagent liveness watchdog inspects `/private/tmp/claude-501/<cwd-dashed>/<uuid>/tasks/<task-id>.output` file growth (`index.ts:498-532, 565-600`).
- **Graceful restart on SIGHUP** drains active queries (max 5 min) before exit; immediate SIGTERM previously "died mid-tool-call and the in-flight tool_use never received its tool_result — the Anthropic API parked waiting on that tool_use_id" (`index.ts:6278-6316`). `SIGUSR2` dumps state to `/tmp/fazm-bridge-state-<scope>.json` (`index.ts:6210-6276`).
- **Logging:** stderr is tee'd to `~/Library/Logs/Fazm/acp-bridge.log` with 10 MB rotation because "In prod, bridge stderr goes to the Swift parent via a pipe and is never persisted" (`index.ts:69-108`); an 80-line ring of adapter stderr is attached to `warmup_complete` failures (`index.ts:1440-1456`); MCP tool audit lines go to `/tmp/fazm-mcp-audit.jsonl` (`index.ts:869-893`).
- **Headless reuse:** `cron-runner.mjs` spawns the identical bridge (`node --max-old-space-size=512 dist/index.js`, `FAZM_HEADLESS=1`, `CLAUDECODE` deleted) and drives it with `init → warmup → query → result` (`FAZM/acp-bridge/src/cron-runner.mjs:168-185`).
### A.11 Swift side (Desktop/Sources) — how the app spawns and talks to the bridge
Swift **never speaks ACP**. It spawns exactly one long-lived Node process (the bridge) and exchanges the custom newline-JSON envelope of A.1 over plain `Pipe()`s. Every ACP method name in the Swift tree appears only in comments.
**Spawn** — `ACPBridge.start()` (`FAZM/Desktop/Sources/Chat/ACPBridge.swift:550-679`):
```swift
let proc = Process()
proc.executableURL = URL(fileURLWithPath: nodePath)
proc.arguments = ["--max-old-space-size=256", "--max-semi-space-size=16", bridgePath]
proc.currentDirectoryURL = URL(fileURLWithPath: NSHomeDirectory()) // ACPBridge.swift:588-597
```
cwd is pinned to `$HOME` because LaunchServices "often" hands the app `/private/var/folders/...` when launched from Finder or a LaunchAgent (`ACPBridge.swift:592-596`). stdin/stdout/stderr are three `Pipe()`s (`:606-617`). No WebSocket/TCP/Unix socket is used between Swift and the bridge.
**Node resolution ladder** — `findNodeBinary()` (`ACPBridge.swift:2537-2590`): (1) bundled `node` from `Bundle.resourceBundle` (= `Contents/Resources/Fazm_Fazm.bundle`, `FAZM/Desktop/Sources/BundleExtension.swift:9-32`), **copied to a temp dir first** (below); (2) `/opt/homebrew/bin/node`, `/usr/local/bin/node`, `/usr/bin/node`; (3) `~/.nvm/versions/node/*` newest; (4) `/usr/bin/which node`. **No login shell is ever run to recover PATH** — there is no `zsh -l`/`-lc` anywhere in the Swift sources. The bundled Node is v22.14.0 downloaded at build time (`FAZM/build.sh:41-69`).
**The /tmp-copy workaround** — `NodeBinaryHelper` (`FAZM/Desktop/Sources/Chat/NodeBinaryHelper.swift:1-95`): "On macOS 26+ (Tahoe), Sparkle auto-updates can silently corrupt the code signing seal of the bundled node binary. The kernel's Code Signing Monitor (CSM) then kills the process with SIGKILL on launch. The binary passes `codesign --verify` but still gets killed" (`:3-13`). Node is copied to `NSTemporaryDirectory()/fazm-node-<bundleScope>` (`:34`), bundle-scoped because a dev build once clobbered prod's temp node and "the next prod ACP-bridge spawn got SIGKILL'd and the chat hung forever" (`:20-27`); verified by running `node --version` (`:73-94`). Because node may then run from `/tmp`, Swift passes `FAZM_RESOURCES_PATH=Bundle.main.resourcePath` so the bridge can still find bundled MCP binaries (`ACPBridge.swift:2490-2492`, consumed at `index.ts:213`).
**Bridge script resolution** — `findBridgeScript()` (`ACPBridge.swift:2653-2686`): `Bundle.main.resourcePath/acp-bridge/dist/index.js`, then dev-tree fallbacks.
**Environment** — `makeBridgeEnvironment()` (`ACPBridge.swift:2362-2533`) starts from `ProcessInfo.processInfo.environment` and sets: `NODE_NO_WARNINGS=1`; `ANTHROPIC_API_KEY` **removed** in personal-OAuth mode / set in bundled-key mode (`:2367-2369`); `PATH` gets node's dir prepended, with fallback default `"/usr/bin:/bin"` (`:2372-2377`); `FAZM_BUNDLE_SCOPE`, `FAZM_BROWSER_MODE`, `FAZM_DISABLE_CLAUDE_CODE_MCP`, `FAZM_ASSRT_ENABLED`, `FAZM_SELECTED_MODEL`, `GEMINI_API_KEY` (only when `FAZM_GEMINI_ENABLED=true`), `PLAYWRIGHT_USE_EXTENSION`, `PLAYWRIGHT_MCP_EXTENSION_TOKEN`, `ANTHROPIC_BASE_URL`/`FAZM_CUSTOM_API_ENDPOINT`, `FAZM_TOOL_TIMEOUT_SECONDS`, `FAZM_RESOURCES_PATH`, `FAZM_AUTH_TOKEN` (Firebase id token), `FAZM_COMPOSIO_TOOLKITS`. `HOME` and `TERM` are inherited, never set.
**Framing** — outbound `sendLine` appends `\n` (`ACPBridge.swift:1535-1545`); inbound is a detached `Task` looping on `FileHandle.availableData` and splitting on `0x0A` by hand (`:1547-1577`). stderr is read via `readabilityHandler` and **screen-scraped** for OOM markers (`FatalProcessOutOfMemory`, `JavaScript heap out of memory`) and for the bridge's own `Tool started: <name> (id=…, kind=…, session=<key>)` log lines to attribute tool activity per session (`:620-652`, `:634-638`, parser `:2186-2195`).
**Message types Swift handles** — the 43-case `enum InboundMessage` (`ACPBridge.swift:212-277`) mirrors `protocol.ts` exactly; unknown types are logged and dropped (`:1851-1853`). The outbound `authenticate` message is defined but never sent (`:861-872`).
**UI model** — `enum ChatContentBlock` = `.text | .toolCall(id,name,status,toolUseId,input,output) | .thinking | .discoveryCard | .observerCard | .systemEvent | .browserActivity` (`FAZM/Desktop/Sources/Providers/ChatProvider.swift:122-157`); `ToolCallStatus` has only `.running` and `.completed` — **no failed/rejected state** (`:276-279`). There are **no Swift types for ACP `diff`, `terminal`, or permission options**; Bash/Terminal output is a generic chip with a summary string (`:208-213`).
**Permission UX** — none for agent tools (zero hits for `allow_once|allow_always|reject_once|permissionMode|bypassPermissions|acceptEdits`). The only gates are `ChatMode.ask|.act` sent as `mode` (`ChatProvider.swift:499-503`, `:4807`), the TCC-permission onboarding tool confusingly named `request_permission` (`FAZM/Desktop/Sources/Providers/ChatToolExecutor.swift:71-74`), and observer cards that are auto-approved with a Deny-to-rollback affordance (`FAZM/Desktop/Sources/MainWindow/Components/ChatUIComponents.swift:648-652`).
**Lifecycle** —
- `stop()` sends `{type:"stop"}`, closes stdin, then kills the tree *before* `proc.terminate()` (`ACPBridge.swift:688-715`). `killProcessTree` walks `/usr/bin/pgrep -P` depth-first and SIGTERMs bottom-up because "The ACP subprocess creates its own process group, so kill(-pid) only reaches direct children — grandchildren (MCP servers) survive and become orphans" (`:700-703`, `:720-753`).
- `sweepOrphanedBridges()` on every start: `ps -axo pid=,ppid=,command=`, match `acp-bridge/dist/index.js`, `patched-acp-entry.mjs`, `codex-acp-darwin`, `/codex-acp` with `PPID==1`, SIGTERM → 1 s → SIGKILL, because "the patched-acp-entry process and the underlying claude CLI register their own SIGTERM handlers that try to 'gracefully shut down' by flushing IPC. When orphaned to launchd they have no functional parent… 14 of 20 swept orphans survived SIGTERM and only died on SIGKILL" (`:763-856`, `:841-846`).
- **CRITICAL pipe rule:** "read pipe BEFORE waitUntilExit. ps -axo against the whole system emits >16KB which overflows the default pipe buffer; if we wait first, ps blocks writing, we block waiting, and the actor's start() deadlocks (observed Apr 30 2026 — caused 200%+ CPU and stuck bridge launch)" (`ACPBridge.swift:779-782`).
- `terminationHandler` with a generation counter so a stale process's exit can't clobber a restarted one (`:654-665`, `:2275-2310`); exit codes 133/134/5/6 classified as OOM (`:2298-2303`). `deinit` resumes any pending continuation to avoid "SWIFT TASK CONTINUATION MISUSE" (`:538-545`).
- No backoff for bridge restarts; restart is `stop()+start()` (`:682-685`), triggered by settings changes (`ChatProvider.swift:1258-1322`), mode switch (`:1023`), OAuth (`:2488-2496`). `applicationWillTerminate` → `stopBridge()` (`FAZM/Desktop/Sources/FazmApp.swift:1226`).
- No steady-state inactivity timeout on the query loop except a 120 s guard for custom endpoints, added after "a design partner lost two weeks to a silent spinner" (`ACPBridge.swift:1130-1168`, `:1141-1147`).
- Stop must be locally effective: "the bridge's interrupt is fire-and-forget and can take seconds to minutes when the upstream Claude/Codex call is hung… root cause of the 6-minute freeze reported 2026-05-27" (`ChatProvider.swift:3558-3583`).
**Agent selection** is by model-id prefix, mirrored from the bridge regexes (`ChatProvider.swift:2406-2434`); the model picker merges Claude `models_available` + Codex/Gemini probe results (`FAZM/Desktop/Sources/FloatingControlBar/ShortcutSettings.swift:261-359`). Claude credentials are checked by shelling to `/usr/bin/security find-generic-password -s "Claude Code-credentials"` (`ChatProvider.swift:2573-2578`) — the only real `SecItem*` use is the custom-endpoint key (`FAZM/Desktop/Sources/Chat/CustomAPIEndpointCredentials.swift:20-81`). The app "lacks `keychain-access-groups` entitlements, so SDK keychain writes fail" for Firebase (`FAZM/Desktop/Sources/AuthService.swift:77-78`).
**Entitlements / signing** (`FAZM/Desktop/*.entitlements`):
- `Fazm.entitlements` / `Fazm-Release.entitlements`: `com.apple.security.app-sandbox = false`, `automation.apple-events`, `device.audio-input`, `device.screen-capture` (+ `get-task-allow` in dev). **App Sandbox is OFF.**
- `Node.entitlements`: `com.apple.security.cs.allow-jit` + `com.apple.security.cs.allow-unsigned-executable-memory` (V8 JIT).
- `Python.entitlements`: `cs.allow-dyld-environment-variables` + `cs.disable-library-validation`.
- Everything signed with `--options runtime` (hardened runtime); node signed with `Node.entitlements` (`FAZM/run.sh:657-663`, `FAZM/codemagic.yaml:800-806`); every `*.node`/`*.dylib`/`*.so`/`rg` under `node_modules` signed individually (`codemagic.yaml:808-816`); a 16 K page-size gate on the node binary — "macOS 26 will crash" otherwise (`codemagic.yaml:919-924`).
**PTY / ANSI** — nothing: no `forkpty|openpty|posix_openpt`, no `TERM`, no escape stripping in Swift (word-boundary grep over 110 files). The child sees non-TTY pipes; any ANSI in tool output would reach the UI unfiltered.
### A.12 Every workaround / hack / bug / NOTE / IMPORTANT comment (bridge)
| Where | What it says (paraphrased, with the literal marker) |
|---|---|
| `index.ts:110-116` | Parent-death watchdog: "Root cause of the 20+ orphan ACP bridges observed Apr 30 2026." |
| `index.ts:498-517` | Subagent watchdog: "known upstream gap — see agentclientprotocol/claude-agent-acp #336 / #497 / #603 / #630 and anthropics/claude-code #44783 / #58637. No upstream fix has shipped (as of May 13 2026). Maintainer's recommended **workaround** is 'don't use background tasks for long work'." |
| `index.ts:846` | "creating an infinite uncaughtException loop (see orphan **bug**)" — `send()` swallows EPIPE. |
| `index.ts:950-951` | `stripHarnessPrefix`: "fixes the chunk-boundary **bug** for a lone '(' first delta". |
| `index.ts:1481-1510` | Debug flag files that reproduce upstream #630 (`/tmp/fazm-debug-drop-prompt-result`, `…-empty`, `…-compaction-stall`, `…-poison-empty-resume`). |
| `index.ts:1562-1565` | Delete `CLAUDECODE` env or `--resume` silently fails inside a nested Claude Code. |
| `index.ts:1629-1633` | `session/update` "can also arrive as a request (with id)" — must be acked. |
| `index.ts:1673-1690` | `[ROUTE-MISS]` — "the signature of the cross-session routing **bug** we're hunting." |
| `index.ts:1700-1741` | `available_commands_update` fires right after `session/new`, before any handler exists; late `config_option_update|current_mode_update|session_info_update|usage_update` from codex-acp are rescued. |
| `index.ts:1808-1810` | "ACP sometimes wraps 401 as a generic -32603 internal error." |
| `index.ts:1830-1832` | Playwright on Retina produces >2000 px screenshots that hit Claude's image limit; a watcher resizes in place. |
| `index.ts:2000-2018` | Resume fails with "Resource not found" if cwd differs; priorContext replay "path itself has **bugs** (leaked `[Interrupted]` turns, stale conversation_history…)". |
| `index.ts:2125-2190` | `[POISON-FIX-PLAN]`: credit exhaustion poisons *other* sessions (end_turn at 0 ms); fixed by restarting the subprocess; Opus 4.7 pattern-matched `User:/Assistant:` labels and emitted `(your turn — …)` literally. |
| `index.ts:2440-2444` | Concurrent `initializeAcp` guard (preWarm + query racing after OAuth restart). |
| `index.ts:2751-2760` | Google Workspace MCP registered only when connected — "100+ tool schemas… bloated the prompt prefix (and cost)". |
| `index.ts:2795-2798` | `PYTHONDONTWRITEBYTECODE=1` — .pyc files "invalidate the code signature and break Sparkle auto-updates". |
| `index.ts:2956-2964` | Disallowed SDK tools produce "silent end-of-turn dead-ends". |
| `index.ts:3104-3126` | Model aliases must be canonicalised before `session/set_model` (adapter's substring resolver). |
| `index.ts:3203-3222` | A hung MCP spawn hangs `session/new` for the whole warmup ceiling. |
| `index.ts:3806-3818` | Each cwd change left "an orphaned claude SDK process running at 70-90% CPU forever"; must `session/close` first. |
| `index.ts:3882-3893` | Resuming a previously-interrupted session replayed the cancelled prompt's chunks (Apr 29 2026, ACP #442, fixed 0.29.2). |
| `index.ts:3897-3903` | "the fix for the resume-after-bridge-restart **bug**": cwd must match at resume. |
| `index.ts:4489-4490` | ACP image block is flat `{type,data,mimeType}`, not Anthropic's nested `source`. |
| `index.ts:4527-4535`, `4634-4652` | `session/prompt` may never resolve (#630); `acpRequest` doesn't observe AbortSignal; "we can't kill SDK-spawned subprocesses from here". |
| `index.ts:4960-4966` | L2a/L2b May 12 2026 incidents. |
| `index.ts:5076-5090` | On extended-thinking models a mid-thinking cancel leaves an unsigned thinking block; reuse → `400 … thinking … blocks … cannot be modified`. |
| `index.ts:5625-5633` | **NOTE:** never clear watchdogs on text chunks — text streams while tools are in flight; caused 180 s inactivity timeouts. |
| `index.ts:5739` | ToolSearch boundary logic "fixes the onboarding bubble-concatenation **bug**". |
| `index.ts:6278-6290` | SIGHUP graceful restart — SIGTERM "self-bricked" a session (2026-05-20). |
| `index.ts:6997-7000` | **NOTE:** SIGHUP deliberately not in the kill-list. |
| `patched-acp-entry.mjs:11-15` | Redirect `console.log/info/warn/debug` to stderr — stdout is the protocol channel. |
| `oauth-flow.ts:296-301` | **NOTE:** custom `expires_in` now rejected (HTTP 400) for the Claude Code scopes. |
| `gemini-provider.ts:88-106, 196-203` | gemini-cli 0.42.0 emits `session/update` under a sessionId that doesn't match `session/new`'s; rescued when exactly one prompt is in flight. Trust-folder env var. |
| `codex-provider.ts:82-86` | codex-acp reports only "Internal error"; reason lives on stderr (ANSI). |
| `acp-translate.ts:9-16` | **NOTE:** Claude path intentionally not unified with the shared translator. |
| `protocol.ts:182-186` | **Note:** no `session/run_command`; slash commands are prompts. |
| `scripts/patch-playwright-overlay.cjs:1-40` | Playwright `addInitScript` doesn't work on CDP-connected contexts; patched at `postinstall`. |
| `run.sh:301-306` | Nested duplicate `@anthropic-ai/claude-agent-sdk` shadowed the top-level one → `SyntaxError … filterEscalatingDefaultMode`; use `rsync --delete`. |
---
## Section B — ACP (Agent Client Protocol): enough to implement a client
### B.1 Where it lives, versions, transport
- Canonical org is now **github.com/agentclientprotocol** (the `zed-industries/agent-client-protocol` URL redirects). Packages: npm `@agentclientprotocol/sdk` **1.4.0** (2026-08-20); Rust crates `agent-client-protocol` + `agent-client-protocol-schema`; also Kotlin/Java/Python SDKs. Apache-2.0. **[docs]** https://github.com/agentclientprotocol/agent-client-protocol. The old npm names are deprecated redirects: `@zed-industries/agent-client-protocol@0.4.5` → `@agentclientprotocol/sdk`; `@zed-industries/claude-code-acp@0.16.2` → `@agentclientprotocol/claude-agent-acp` **[docs]** npm registry 2026-09-02.
- **Stable wire version is `protocolVersion: 1`.** Schema artifacts: `schema/v1/schema.json` and a **v2 draft** (2026-07-20) — "do not ship v2 by default"; TS SDK exposes it only under `@agentclientprotocol/sdk/experimental/v2`. "Use the negotiated `protocolVersion`", not the crate/schema version **[docs]** https://agentclientprotocol.com/announcements/acp-v2-draft, repo README. Docs index: https://agentclientprotocol.com/llms.txt (v1 pages under `/protocol/v1/*`).
- **Transport:** "Local agents run as sub-processes of the code editor, communicating via JSON-RPC over stdio"; remote (HTTP/WebSocket) is "a work in progress" **[docs]** https://agentclientprotocol.com/overview/introduction. Rules: "The client launches the agent as a subprocess"; messages are UTF-8 JSON-RPC 2.0, "delimited by newlines (`\n`), and MUST NOT contain embedded newlines" (NDJSON); agent "MAY write UTF-8 strings to its stderr for logging"; "The agent MUST NOT write anything to its stdout that is not a valid ACP message"; "The client MUST NOT write anything to the agent's stdin that is not a valid ACP message" **[docs]** https://agentclientprotocol.com/protocol/v1/transports. Conventions: absolute paths only; 1-based lines; camelCase keys, snake_case discriminators; Markdown text; `_meta` everywhere; `_`-prefixed custom methods **[docs]** https://agentclientprotocol.com/protocol/overview.
- **Error codes:** `-32700` parse, `-32600` invalid request, `-32601` method not found, `-32602` invalid params, `-32603` internal, **`-32800` request cancelled**, **`-32000` authentication required**, `-32002` resource not found **[docs]** schema `ErrorCode`. (fazm's "-32603 wrapping a 401" heuristic, A.3, is a claude-agent-acp quirk, not spec.)
### B.2 Method catalog (v1 `schema.json` `x-method`/`x-side`) **[docs]** https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/main/schema/v1/schema.json
| Implemented by | Method | Kind |
|---|---|---|
| agent | `initialize`, `authenticate`, `logout` | request |
| agent | `session/new`, `session/load`, `session/resume`, `session/list`, `session/close`, `session/delete` | request |
| agent | `session/prompt`, `session/set_mode`, `session/set_config_option` | request |
| agent | `session/cancel` | notification |
| **client (you)** | `session/request_permission` | request (agent→client) |
| **client** | `session/update` | notification (agent→client) |
| **client** | `fs/read_text_file`, `fs/write_text_file` | request |
| **client** | `terminal/create`, `terminal/output`, `terminal/wait_for_exit`, `terminal/kill`, `terminal/release` | request |
| **client** | `elicitation/create` (request), `elicitation/complete` (notification) | |
| either | `$/cancel_request` | notification |
`session/fork` is only an RFD (https://agentclientprotocol.com/rfds/session-fork); the Claude adapter implements it as `unstable_forkSession`. `session/set_model` is **not** in the spec — it is an adapter extension (Gemini calls it `unstable_setSessionModel`; the spec's replacement is `session/set_config_option` with a `model` category). fazm's calls to `session/set_model` and `session/fork` (A.3) therefore only work against those specific adapters.
### B.3 `initialize` / `authenticate` **[docs]** https://agentclientprotocol.com/protocol/initialization, https://agentclientprotocol.com/protocol/v1/authentication
```json
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{
"protocolVersion":1,
"clientCapabilities":{"fs":{"readTextFile":true,"writeTextFile":true},"terminal":true},
"clientInfo":{"name":"snappy-os","title":"SnappyOS","version":"1.0.0"}}}
```
```json
{"jsonrpc":"2.0","id":0,"result":{
"protocolVersion":1,
"agentCapabilities":{"loadSession":true,
"promptCapabilities":{"image":true,"audio":true,"embeddedContext":true},
"mcpCapabilities":{"http":true,"sse":true},
"sessionCapabilities":{"list":{},"resume":{},"close":{},"delete":{},"additionalDirectories":{}}},
"agentInfo":{"name":"my-agent","title":"My Agent","version":"1.0.0"},
"authMethods":[]}}
```
- Version negotiation: agent echoes the version if supported, else "MUST respond with the latest version it supports"; client "SHOULD close the connection and inform the user" on mismatch.
- `ClientCapabilities`: `fs{readTextFile,writeTextFile}` (default false), `terminal` (bool = all `terminal/*`), `session{configOptions}`, `auth{terminal}` (default false — gates terminal-type auth methods), `elicitation{form,url}`, `_meta`. `AgentCapabilities`: `loadSession`, `promptCapabilities{image,audio,embeddedContext}`, `mcpCapabilities{http,sse}`, `sessionCapabilities{list,delete,additionalDirectories,resume,close}` ("`{}` means supported; omitted/null means not advertised"), `auth{logout}`.
- `authMethods[]`: `{id, name, description?}` (agent-handled; call `authenticate {methodId}`) or **`{type:"terminal", id, name, args?, env?}`** — "The client runs the configured agent program as a separate interactive process for the user to authenticate via a TUI… A zero exit status signals success… The client MUST NOT pass this method to `authenticate`." This is how Claude subscription login is exposed (C.1).
- `authenticate {methodId}` → `{}`; `-32000` = auth required. `logout` only if `agentCapabilities.auth.logout`.
### B.4 Session lifecycle **[docs]** https://agentclientprotocol.com/protocol/v1/session-setup, https://agentclientprotocol.com/protocol/v1/session-list
```json
{"jsonrpc":"2.0","id":1,"method":"session/new","params":{
"cwd":"/Users/robert/project",
"mcpServers":[{"name":"snappy_tools","command":"/abs/path/node","args":["/abs/path/tools.js"],"env":[{"name":"SNAPPY_SOCK","value":"/tmp/x.sock"}]}]}}
```
→ `{"jsonrpc":"2.0","id":1,"result":{"sessionId":"sess_abc123def456","modes":{…},"configOptions":[…]}}`
- `cwd` "Must be an absolute path"; `additionalDirectories?` only if advertised; `mcpServers[]` shapes: **stdio** (untagged; "All Agents MUST support this transport") `{name, command (absolute), args[], env:[{name,value}]}` — all four keys required, `env` is an **array**, not an object; `{"type":"http", name, url, headers:[{name,value}]}` if `mcpCapabilities.http`; `{"type":"sse",…}` deprecated. fazm's config builder (A.8) matches this exactly.
- `session/load {sessionId, cwd, mcpServers}` (needs `loadSession`): "The Agent MUST replay the entire conversation to the Client in the form of `session/update` notifications" (`user_message_chunk`/`agent_message_chunk`…), then responds.
- `session/resume {sessionId, cwd, mcpServers}` (needs `sessionCapabilities.resume`): same params, "MUST NOT replay the conversation history" — the cheap resume.
- `session/list {cwd?, cursor?}` → `{sessions:[{sessionId, cwd, title?, updatedAt?}], nextCursor?}` (needs `sessionCapabilities.list`).
- `session/close {sessionId}` (agent must cancel work); `session/delete {sessionId}`.
### B.5 Prompt turn, content blocks, stop reasons **[docs]** https://agentclientprotocol.com/protocol/v1/prompt-turn, https://agentclientprotocol.com/protocol/content
```json
{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{
"sessionId":"sess_abc123def456",
"prompt":[
{"type":"text","text":"Can you analyze this code for potential issues?"},
{"type":"resource","resource":{"uri":"file:///Users/robert/project/main.py","mimeType":"text/x-python","text":"def process_data(items):\n ..."}}]}}
```
→ `{"jsonrpc":"2.0","id":2,"result":{"stopReason":"end_turn"}}`
- Content blocks: `text{text}`; `image{data(base64), mimeType, uri?}` (needs `promptCapabilities.image`) — flat, confirming fazm's note in A.4; `audio{data,mimeType}`; `resource{resource:{uri,text|blob,mimeType?}}` (needs `embeddedContext`); `resource_link{uri,name,mimeType?,title?,size?}`. There is **no PDF/document block** (fazm works around this by pointing the agent at the path).
- `stopReason`: `end_turn` | `max_tokens` | `max_turn_requests` | `refusal` ("everything after it won't be included in the next prompt, so this should be reflected in the UI") | `cancelled` ("MUST be returned when the client sends a `session/cancel` notification, even if the cancellation causes exceptions").
- **v1 `PromptResponse` has only `stopReason` + `_meta`.** Per-turn tokens are an RFD "intentionally kept in Draft" (`unstable_end_turn_token_usage`) **[docs]** https://agentclientprotocol.com/rfds/end-turn-token-usage. This is why fazm patched the adapter (A.2) and why the current Claude adapter stuffs usage into `_meta.quota`.
### B.6 `session/update` — the 11 variants **[docs]** schema `SessionUpdate`; https://agentclientprotocol.com/protocol/v1/tool-calls, /agent-plan, /slash-commands, /session-modes, /v1/session-config-options, /rfds/session-usage
| `sessionUpdate` | Payload |
|---|---|
| `user_message_chunk` / `agent_message_chunk` / `agent_thought_chunk` | `{content: ContentBlock, messageId?}` — "A change in `messageId` indicates a new message has started" |
| `tool_call` | `{toolCallId, title, kind?, status?, content?[], locations?[{path,line?}], rawInput?, rawOutput?}` |
| `tool_call_update` | same fields, "only changed fields required" |
| `plan` | `{entries:[{content, priority: high\|medium\|low, status: pending\|in_progress\|completed}]}` — "MUST send a complete list… Client MUST replace the current plan completely" |
| `available_commands_update` | `{availableCommands:[{name, description, input?:{hint}}]}` — invoke by sending `/name …` as a normal text prompt |
| `current_mode_update` | `{modeId}` |
| `config_option_update` | `{configOptions:[SessionConfigOption]}` |
| `session_info_update` | `{title?, updatedAt?}` (null clears) |
| `usage_update` | `{used, size, cost?:{amount,currency}}` — tokens in context, context window size, cumulative cost |
- `ToolKind`: `read | edit | delete | move | search | execute | think | fetch | switch_mode | other`. `ToolCallStatus`: `pending` ("input is either streaming or we're awaiting approval") | `in_progress` | `completed` | `failed`. `ToolCallContent`: `{"type":"content","content":ContentBlock}` | **`{"type":"diff","path","oldText","newText"}`** | **`{"type":"terminal","terminalId"}`** ("must be added before calling `terminal/release`"). A native host should render all three; fazm flattens to text (A.5).
```json
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"sess_abc123def456","update":{
"sessionUpdate":"tool_call_update","toolCallId":"call_001","status":"completed",
"content":[{"type":"diff","path":"/Users/robert/project/src/config.json","oldText":"{\n \"debug\": false\n}","newText":"{\n \"debug\": true\n}"}]}}}
```
### B.7 Permission request (agent → client) **[docs]** tool-calls page
```json
{"jsonrpc":"2.0","id":5,"method":"session/request_permission","params":{
"sessionId":"sess_abc123def456",
"toolCall":{"toolCallId":"call_001","title":"Reading configuration file","kind":"read","status":"pending"},
"options":[
{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},
{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}}
```
Reply `{"jsonrpc":"2.0","id":5,"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}` or `{"result":{"outcome":{"outcome":"cancelled"}}}`.
- `PermissionOptionKind`: `allow_once | allow_always | reject_once | reject_always`. **Option ids are agent-defined strings — never hard-code `"allow"`** (fazm's fallback literal in A.6 would be rejected by codex-acp, which "fails closed" on unadvertised ids). Only two outcomes exist: `selected{optionId}` and `cancelled`. "If the current prompt turn gets cancelled, the Client MUST respond with the `cancelled` outcome."
### B.8 Cancellation **[docs]** prompt-turn page; https://agentclientprotocol.com/protocol/v1/cancellation
- `{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"…"}}` (notification, no id). Agent "SHOULD stop all language model requests and tool call invocations as soon as possible" and "MUST respond to the original `session/prompt` request with the `cancelled` stop reason". Client "SHOULD preemptively mark all non-finished tool calls as cancelled", "SHOULD still accept tool call updates received after sending session/cancel", and "MUST respond to all pending session/request_permission requests with the cancelled outcome".
- `$/cancel_request {requestId}` cancels any single in-flight request (either direction); receiver responds with a result or `-32800`.
- Cancel ends the **turn**, not the session — fazm learned this the hard way (A.7). Cancel is cooperative: a wedged tool subprocess will not die (fazm's SIGKILL escalation).
### B.9 Client-side FS, terminals, elicitation **[docs]** https://agentclientprotocol.com/protocol/file-system, /terminals, /v1/elicitation
- `fs/read_text_file {sessionId, path, line?, limit?}` → `{content}`; `fs/write_text_file {sessionId, path, content}` → `null` (client creates the file). Advertising `fs` lets the agent see **unsaved editor buffers** — for a non-editor host, advertise `false` (as fazm does) and let the CLI read disk.
- `terminal/create {sessionId, command, args?, env?[{name,value}], cwd?, outputByteLimit?}` → `{terminalId}`; `terminal/output` → `{output, truncated, exitStatus?}`; `terminal/wait_for_exit` → `{exitCode, signal}`; `terminal/kill`; `terminal/release`. If you advertise `terminal:true`, **you** own the PTY/process for every shell command the agent runs and stream it live via `{"type":"terminal","terminalId"}` — this is the hook for a SwiftTerm view. If you advertise `false`, the agent runs commands itself and you get text output in `tool_call_update.content`.
- `elicitation/create {mode:"form", message, requestedSchema}` or `{mode:"url", elicitationId, url}` → `{action: accept|decline|cancel, content?}`.
### B.10 Modes and config options **[docs]** https://agentclientprotocol.com/protocol/session-modes, /v1/session-config-options
- `session/new` result `modes:{currentModeId:"ask", availableModes:[{id,name,description}]}`; `session/set_mode {sessionId, modeId}`; agent-initiated → `current_mode_update`. Docs note dedicated mode methods "will be removed in a future version" in favour of config options.
- `SessionConfigOption {id, name, description?, category?: "mode"|"model"|"model_config"|"thought_level"|…, type:"select", currentValue, options:[{value,name,description?}]}` or `{type:"boolean", currentValue}`; `session/set_config_option {sessionId, configId, value}` → full `configOptions` list. **Model selection is a config option, not `session/set_model`.**
### B.11 Extensibility **[docs]** https://agentclientprotocol.com/protocol/extensibility
- Every type has `_meta: {[key]: unknown}`; root keys `traceparent/tracestate/baggage` reserved; "Implementations MUST NOT add any custom fields at the root of a type"; custom methods start with `_` (e.g. `_zed.dev/workspace/buffers`, `_session/steering`, `_claude/sdkMessage`); unknown methods → `-32601`, unknown notifications ignored; advertise via `_meta` in capabilities.
### B.12 Full-turn sequence (client view)
1. spawn agent → `initialize` (id 0) → check `protocolVersion`, capabilities, `authMethods`.
2. if `-32000` on a later call or `authMethods` non-empty: run terminal-type login in a real PTY, or `authenticate {methodId}`.
3. `session/new {cwd, mcpServers}` (id 1) → `sessionId` (+ modes/configOptions). Expect an immediate `available_commands_update` notification **before** you have wired a per-session handler (fazm's `[ROUTE-DROP-RESCUED]`, A.12).
4. `session/prompt` (id 2) → stream of `session/update` (`plan`, `agent_thought_chunk`, `agent_message_chunk`, `tool_call{pending}`…).
5. agent→client `session/request_permission` (id 5) → answer `selected{optionId}`.
6. `tool_call_update{in_progress}` … `{completed, content:[diff|terminal|content]}`, optional `usage_update`.
7. result for id 2 `{stopReason:"end_turn"}`. Cancel path: `session/cancel` → answer pending permissions `cancelled` → result `{stopReason:"cancelled"}`.
### B.13 SDKs
- **TypeScript (`@agentclientprotocol/sdk` 1.4.0):** fluent `client({name})` / `agent({name})`; `ClientSideConnection`/`AgentSideConnection` still exported but deprecated; `ndJsonStream(output: WritableStream<Uint8Array>, input: ReadableStream<Uint8Array>)` writes `JSON.stringify(msg)+"\n"`. Official child-process wiring **[docs]** https://github.com/agentclientprotocol/typescript-sdk/blob/main/src/examples/client.ts:
```ts
const agentProcess = spawn(cmd, args, { stdio: ["pipe","pipe","inherit"] });
const stream = acp.ndJsonStream(Writable.toWeb(agentProcess.stdin!), Readable.toWeb(agentProcess.stdout!));
await acp.client({ name:"snappy-os" })
.onRequest(acp.methods.client.session.requestPermission, (ctx) => ui.requestPermission(ctx.params))
.onRequest(acp.methods.client.fs.readTextFile, (ctx) => fs.read(ctx.params))
.onRequest(acp.methods.client.fs.writeTextFile, (ctx) => fs.write(ctx.params))
.connectWith(stream, async (ctx) => {
await ctx.request(acp.methods.agent.initialize, { protocolVersion: acp.PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile:true, writeTextFile:true } } });
return ctx.buildSession(cwd).withSession(async (session) => {
session.prompt("Hello, agent!");
for (;;) { const m = await session.nextUpdate(); if (m.kind === "stop") return m.response; await ui.sessionUpdate(m.notification); }
});
});
```
- **Rust:** crate `agent-client-protocol`, implement the `Client` trait; "powers the integration with external agents in the Zed editor" **[docs]** https://agentclientprotocol.com/libraries/rust.
- **Swift: three community SDKs (listed on agentclientprotocol.com/libraries/community, checked 2026-09-02):** `wiedymi/swift-acp` (MIT, v0.1.0, macOS 12+, tools 5.9, ~10.7K lines, pushed 2026-07-24, 29★) — the only one covering the full v1 client surface: `session/request_permission`, every `session/update` kind incl. `usage_update`, `set_config_option`, fs + terminal delegates, local `Process` spawn with a shell-PATH resolver, cancel; `aptove/swift-sdk` (Apache-2.0, v0.1.16, Swift 6, macOS 12+, ~9.4K lines, idle since 2026-04-25, 10★) — no `usage_update`, README claims a "2025-02-07" protocol version that is not in the code (code speaks v1); `rebornix/acp-swift-sdk` (MIT, untagged, Swift 6, macOS 13+, ~3.4K lines, idle since 2026-02-07, 6★) — minimal: no permission handling in the SDK, no terminal, no `usage_update`, FileDescriptor transport with no spawning (built for iOS). **Shipped native Apple ACP clients:** `rebornix/Agmente` (MIT, 540★, pushed 2026-05-31; iOS + a native macOS target, deployment 15.0; ACP over WebSocket via `@rebornix/stdio-to-ws`, plus a separate `AppServerClient` package for Codex app-server; app-layer `PermissionRequestParsing`, `SessionUpdateHandler`, `ChatRenderDiff`, `ToolCallRowView`, `PlanModeViews` are the reusable rendering code) and Poolside Desktop Assistant (closed source, "agent agnostic worktree native macOS desktop app"). Nobody in our host-app table spawns adapters locally from Swift; that piece is the `api.ts` spawn spec ported. (Earlier draft said "[not-found]" — wrong; corrected after a direct check.)
### B.14 Registry and ecosystem **[docs]** https://github.com/agentclientprotocol/registry/blob/main/FORMAT.md, https://agentclientprotocol.com/overview/agents, /overview/clients
- Index at `https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json` = `{"version":"1.0.0","agents":[…]}`; each `<id>/agent.json` has `id, name, version, description, license, icon?, distribution:{npx:{package,args?,env?} | uvx:{…} | binary:{darwin-aarch64|darwin-x86_64|linux-*|windows-*:{archive,sha256?,cmd,args?,env?}}}`; versions auto-bumped hourly; every listed agent must support auth (CI checks `authMethods`). Claude entry: `{"id":"claude-acp","version":"0.73.0","license":"proprietary","distribution":{"npx":{"package":"@agentclientprotocol/claude-agent-acp@0.73.0"}}}` (adapter code is Apache-2.0; the bundled Claude Code binary is not) **[docs]** https://raw.githubusercontent.com/agentclientprotocol/registry/main/claude-acp/agent.json.
- Agents (42 listed): Claude Agent (adapter), Codex CLI (adapter), Gemini CLI, Goose, Kimi CLI, OpenCode, GitHub Copilot ("public preview for ACP"), Cursor (https://cursor.com/docs/cli/acp), Augment/Auggie, Junie, Qwen Code, Mistral Vibe, Kiro CLI, OpenHands, Pi, Factory Droid, Devin, Cline, Hermes, Docker cagent, … Clients: Zed, JetBrains AI Assistant, Neovim (CodeCompanion, avante.nvim, agentic.nvim), Emacs agent-shell, marimo, Obsidian plugins, VS Code extensions, Qt Creator, Pulsar, desktop apps (Poolside Desktop Assistant — "worktree native macOS desktop app", GitKraken Kepler, Devin Desktop, Mitto), **ACP Inspector** (https://github.com/newioapp/acp-inspector — a protocol debugger you should use while building), `acpx` CLI, LangChain/Mastra/LlamaIndex/Koog frameworks.
### B.15 Known spec-level limitations **[docs]**
- No per-turn tokens/cost in v1 (`PromptResponse` = `stopReason` only); only cumulative `usage_update`; Gemini CLI and codex-acp both had open issues about not populating it (**[issue]** https://github.com/google-gemini/gemini-cli/issues/24280, https://github.com/zed-industries/codex-acp/issues/209).
- No standard model-switch method (config options are the road); `session/list`/`resume`/`close` are capability-gated and not every agent advertises them.
- Remote transport is WIP; v2 draft changes permission requests (adds `title`/`description`/`subject`), makes `messageId` mandatory, streams tool-call content, and turns diffs into structured file changes **[docs]** https://agentclientprotocol.com/announcements/acp-v2-draft.
---
## Section C — Per-agent hosting recipes
### C.1 Claude Code — two roads
**Current bits (npm, 2026-09-02):** `@agentclientprotocol/claude-agent-acp` **0.73.0** (deps `@anthropic-ai/claude-agent-sdk 0.3.257`, `@agentclientprotocol/sdk 1.4.0`, **`engines.node >= 22`**, bin `claude-agent-acp`); `@anthropic-ai/claude-agent-sdk` **0.3.258** (node ≥ 18); `@anthropic-ai/claude-code` 2.1.258. fazm pins adapter 0.29.2 / SDK 0.2.112 — roughly 44 adapter releases behind.
**The confirmed process model (both roads end here):** "The Agent SDK spawns and supervises a `claude` CLI subprocess that owns a shell, a working directory, and session files on disk… When your code calls `query()`, the SDK spawns a separate `claude` CLI process and talks to it over stdio… One agent session maps to one subprocess." "Both the TypeScript and Python SDKs bundle a native Claude Code binary… pinned to the SDK package version." Sizing: "1 GiB RAM, 5 GiB disk, and 1 CPU per agent" **[docs]** https://code.claude.com/docs/en/agent-sdk/hosting. `npm ci --omit=optional` drops the binary → set `pathToClaudeCodeExecutable` **[docs]** https://code.claude.com/docs/en/agent-sdk/quickstart.
#### C.1.a Road 1 — the ACP adapter (`@agentclientprotocol/claude-agent-acp`)
- **Spawn:** `node <…>/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js` (or `npx @agentclientprotocol/claude-agent-acp`), `stdio: pipe/pipe/pipe`, cwd irrelevant (per-session `cwd`). Flags: `--cli <args…>` forwards to the bundled native `claude` (used for terminal-auth login), `--version`, `--hide-claude-auth` (suppresses subscription login). Env: `CLAUDE_CODE_EXECUTABLE` (override bundled binary; error text "Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE"), `CLAUDE_AGENT_LOGS=<dir>` (writes `agent.log`), `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_USE_BEDROCK|VERTEX`, `CLAUDE_MODEL_CONFIG` JSON for Bedrock model ids. All `console.*` go to stderr **[docs]** repo `src/index.ts`, README, `docs/model-configuration.md` https://github.com/agentclientprotocol/claude-agent-acp.
- **What it wraps:** "This tool implements an ACP agent by using the official Claude Agent SDK" — calls `query()` with `pathToClaudeCodeExecutable`, `includePartialMessages:true`, `settingSources:["user","project","local"]`, `systemPrompt:{type:"preset",preset:"claude_code"}` (overridable via `_meta.systemPrompt`), `canUseTool`, `extraArgs:{"replay-user-messages":""}`, env `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` **[docs]** `src/acp-agent.ts`. So: your app → node adapter → SDK → bundled `claude` binary (two extra processes per session).
- **Its `initialize` result** **[docs]** `src/acp-agent.ts`: `promptCapabilities{image:true, embeddedContext:true}` (**no audio**), `mcpCapabilities{http:true, sse:true}`, `auth{logout:{}}`, `loadSession:true`, `sessionCapabilities{additionalDirectories, close, delete, fork, list, resume, subagents}`, `_meta.claudeCode.promptQueueing:true`, `_meta.steering{supported:true}` (`_session/steering` injects a message into a running turn), `_meta.goal`. **`authMethods`** (all `type:"terminal"`): `claude-ai-login` "Claude Subscription" (`args:["--cli","auth","login","--claudeai"]`), `console-login` "Anthropic Console (API usage billing)" (`--cli auth login --console`); in SSH envs a single `claude-login`; plus agent-type `gateway`/`gateway-bedrock`. Terminal methods are advertised **only when the client sets `clientCapabilities.auth.terminal`**; `authenticate()` accepts only gateway ids, anything else throws "Method not implemented." ⇒ **to let a user log in with a subscription you must spawn `claude-agent-acp --cli auth login --claudeai` in a real PTY and wait for exit 0** (Section E). fazm sidestepped this with its own PKCE flow writing the Keychain item (A.9).
- **Modes** (`src/session-mode.ts`): `default` "Manual", `acceptEdits`, `plan`, `auto`, `bypassPermissions` (disabled when root). Config options: Mode, model, effort, fast-mode. Slash commands via `available_commands_update`.
- **Extension methods** (from `src/acp-agent.ts`): `session/resume`, `session/close`, `session/delete`, `listSessions` (backed by SDK `listSessions({dir: cwd})`), `unstable_forkSession`, `unstable_listProviders|setProvider|disableProvider`, `logout` (runs `claude auth logout`), `_session/steering`, `_session/async_task/stop`. `_meta.claudeCode.options` on `session/new` forwards raw SDK `Options` (ACP owns `cwd, includePartialMessages, allowDangerouslySkipPermissions, permissionMode, canUseTool, executable`; merges `hooks, mcpServers, disallowedTools`). **`_meta.claudeCode.emitRawSDKMessages: true | filter[]` streams every raw SDK message as a `_claude/sdkMessage` notification** — the supported replacement for fazm's `patched-acp-entry.mjs` monkey-patch.
- **Usage/cost:** emits ACP `usage_update` from `modelUsage` (context size seeded at 200 000 until the first result); since 0.71.0 "report per-model token usage on prompt responses" as `PromptResponse._meta.quota {input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens, model_usage}` **[docs]** CHANGELOG + source.
- **Permission extension** (`docs/permission-extension.md`): adds `_meta.permission{version:1,title,description}`; fixed option ids `allow-once`, `allow-with-updates`, `exit-plan-*`, `reject`; maps SDK `PermissionUpdate` suggestions into "Yes, and don't ask again for npm test commands"-style options; **cancel ≠ reject**. Session-failure extension (opt-in `clientCapabilities._meta.jetbrains.air.capabilities:["sessionFailure"]`): usage-limit/auth failures arrive as `_meta.jetbrains.air.sessionFailure{category: connection|access|limit|request|service|unknown, title, actions}` on a `stopReason:"end_turn"` response — the fix for the historic silent `end_turn` on usage limit (**[issue]** #146).
- **Tracker issues worth knowing [issue]:** #421 "Authentication required: This integration does not support using claude.ai subscriptions" (one cause: `--hide-claude-auth`); #744 (open) `ANTHROPIC_API_KEY` not picked up (stale key in `~/.claude/settings.json`); #338 CLI subprocess death (exit 143) left "`ProcessTransport is not ready for writing`" — fixed #363; #880 `session/new` blocked ~100 s by an init-time `getContextUsage()` through a gateway; #337 ToS thread closed by maintainer 2026-05-16 pointing at https://zed.dev/blog/anthropic-subscription-changes. URLs: https://github.com/agentclientprotocol/claude-agent-acp/issues/{421,744,338,880,337,146}. Raw-CLI alternatives (PTY + transcript JSONL, not SDK): `moabualruz/claude-code-cli-acp`, `Xuanwo/acp-claude-code` **[blog]**.
#### C.1.b Road 2 — Claude Agent SDK directly (in your bundled Node)
Docs moved: `docs.claude.com/en/api/agent-sdk/*` → `platform.claude.com/...` → **`code.claude.com/docs/en/agent-sdk/*`** (index https://code.claude.com/docs/llms.txt).
- **API** **[docs]** https://code.claude.com/docs/en/agent-sdk/typescript: `query({prompt: string | AsyncIterable<SDKUserMessage>, options}) : Query` (an `AsyncGenerator<SDKMessage>`); `startup({options})` pre-warms the subprocess; `tool(name, desc, zodShape, handler)` + `createSdkMcpServer({name, tools})` for **in-process tools** (no separate MCP server process — this replaces fazm's stdio-MCP + Unix-socket relay, A.8); `listSessions/getSessionMessages/getSessionInfo/renameSession/tagSession`.
- **`Query` control methods** (streaming-input mode only) **[docs]** `sdk.d.ts` 0.3.258: `interrupt()`, `setPermissionMode(mode)`, `setModel(model)`, `setMaxThinkingTokens`, `supportedCommands()`, `supportedModels()`, `mcpServerStatus()`, `getContextUsage()`, `accountInfo()`, `rewindFiles(userMessageId)`, `setMcpServers`, `reconnectMcpServer`, `streamInput()`, `stopTask(taskId)`, `backgroundTasks()`, `close()`.
- **Key `Options`** (full list in `sdk.d.ts`): `cwd`, `model`, `permissionMode: 'default'|'acceptEdits'|'bypassPermissions'|'plan'|'dontAsk'|'auto'` (`bypassPermissions` requires `allowDangerouslySkipPermissions:true`), `canUseTool`, `allowedTools`/`disallowedTools`/`tools`, `mcpServers` (`stdio{command,args,env}` | `sse{url,headers}` | `http{url,headers}` | `sdk{instance}`), `hooks`, `resume`/`continue`/`forkSession`/`resumeSessionAt`/`sessionId`, `persistSession`/`sessionStore`, `settingSources: ['user'|'project'|'local']`, `includePartialMessages`, `maxTurns`, `maxBudgetUsd`, `abortController`, `pathToClaudeCodeExecutable`, `executable: 'bun'|'deno'|'node'`, `executableArgs`, **`spawnClaudeCodeProcess: (opts) => SpawnedProcess`** ("Use to run Claude Code in VMs, containers, or remote environments" — you can own the spawn from Swift), **`env` replaces the subprocess environment** ("pass `{ ...process.env, ... }`"), `stderr: (data) => void`, `systemPrompt` (default is a **minimal** prompt, not Claude Code's — use `{type:'preset', preset:'claude_code'}`; CLAUDE.md loads only via `settingSources`) **[docs]** https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts.
- **Permissions** **[docs]** https://code.claude.com/docs/en/agent-sdk/permissions, /user-input:
```ts
type CanUseTool = (toolName, input, { signal, suggestions?: PermissionUpdate[], blockedPath? }) => Promise<
| { behavior:'allow'; updatedInput?; updatedPermissions?: PermissionUpdate[] }
| { behavior:'deny'; message: string; interrupt?: boolean }>;
```
Evaluation order: **Hooks → deny rules → ask rules → permission mode → allow rules → `canUseTool`**; "Auto-approved tools never reach `canUseTool`"; `AskUserQuestion` always reaches it (answer with `updatedInput.answers`), "The callback can stay pending indefinitely"; `plan` mode routes writes to the callback; `dontAsk` never calls it. `PermissionUpdate` lets you persist "always allow" rules (`addRules`, `setMode`, `addDirectories`).
- **Messages** **[docs]** `sdk.d.ts`, https://code.claude.com/docs/en/agent-sdk/streaming-output, /cost-tracking: `system/init{session_id, apiKeySource: 'ANTHROPIC_API_KEY'|'apiKeyHelper'|'/login managed key'|'none', claude_code_version, cwd, tools, mcp_servers[{name,status}], model, permissionMode, slash_commands, capabilities}`; `assistant{message: BetaMessage(content: text|thinking|tool_use)}`; `user{tool_use_result}`; `stream_event{event: text_delta|thinking_delta|input_json_delta}` (with `includePartialMessages`); `system/compact_boundary{compact_metadata{trigger, pre_tokens}}`; `system/api_retry`; `rate_limit_event`; `task_started/task_notification`; `result{subtype:'success'|'error_during_execution'|'error_max_turns'|'error_max_budget_usd', total_cost_usd, usage, modelUsage:{[model]:{inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens, costUSD, contextWindow}}, num_turns, session_id, permission_denials}`. `total_cost_usd` is a **running total** per session ("read the latest result rather than summing"); "client-side estimates, not authoritative billing data". These are exactly the events fazm's patch re-exported (A.2) — the adapter now exposes them via `emitRawSDKMessages`.
- **Sessions** **[docs]** https://code.claude.com/docs/en/agent-sdk/sessions, https://code.claude.com/docs/en/sessions: transcripts at `~/.claude/projects/<cwd with non-alphanumerics→'-'>/<session-id>.jsonl` (200-char truncation + hash; `CLAUDE_CONFIG_DIR` relocates); since CLI v2.1.223 `--resume` "looks for the ID in the current project directory and its git worktrees first, then in every other project on this machine" — which retires fazm's cwd-recovery machinery (A.3) if you're on a current SDK. `-p`/SDK sessions are hidden from the picker but resumable by id. SDK `forkSession:true` = new id with copied history.
- **Hosting limits** **[docs]** hosting page: "No top-level session timeout", "Memory growth over long sessions"; isolate tenants with `settingSources: []`, `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1`, per-tenant `CLAUDE_CONFIG_DIR`.
- **The SDK↔CLI wire (what you'd speak from Swift if you dropped Node):** stdout NDJSON of `SDKMessage` plus `{type:'control_request', request_id, request:{subtype:'can_use_tool'|'hook_callback'|'mcp_message'|'elicitation'|…}}` / `{type:'control_response'}` / `{type:'control_cancel_request'}`; host→CLI `initialize{hooks?, sdkMcpServers?, systemPrompt?, agents?}`, `interrupt`, `set_permission_mode`, `set_model`, `get_context_usage`, `get_session_cost`… Shapes are in `sdk.d.ts`; **the framing has no standalone docs page [not-found]**. CLI equivalent: `claude -p --output-format stream-json --input-format stream-json --verbose --include-partial-messages --replay-user-messages --permission-prompt-tool mcp__x__y --resume <id> --session-id <uuid> --fork-session --permission-mode … --max-turns N --max-budget-usd 5 --mcp-config ./mcp.json --strict-mcp-config --bare` **[docs]** https://code.claude.com/docs/en/headless, /cli-reference. `--permission-prompt-tool` waits for that MCP server up to `MCP_TIMEOUT`; the exact tool I/O contract is **[not-found]** on a current official page (third-party: https://lobehub.com/mcp/user-claude-code-permission-prompt-tool). `--bare` "will become the default for `-p`" and does not read OAuth/Keychain (needs `ANTHROPIC_API_KEY`). SIGTERM → exit 143 with the turn unfinished; SIGINT/`interrupt()` ends cleanly.
#### C.1.c Auth and policy — the part that decides shippability
- **Precedence (1→7)** **[docs]** https://code.claude.com/docs/en/authentication#authentication-precedence: Bedrock/Vertex/Foundry env → `ANTHROPIC_AUTH_TOKEN` (Bearer) → `ANTHROPIC_API_KEY` (X-Api-Key; "In non-interactive mode (`-p`), the key is always used when present") → `apiKeyHelper` → `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`, 1-year, "requires a Pro, Max, Team, or Enterprise plan… can only make model requests"; not read in `--bare`) → Anthropic profile/WIF → subscription OAuth from `/login`.
- **Storage** **[docs]** same page: "On macOS, credentials are stored in the encrypted macOS Keychain. When the Keychain rejects the write, such as when it's locked in an SSH session, Claude Code stores your login in `~/.claude/.credentials.json` with file mode 0600"; `CLAUDE_CONFIG_DIR` "keys the macOS Keychain entry to that directory too, so a session with a different `CLAUDE_CONFIG_DIR` reads a different entry." The item name `Claude Code-credentials` is not in the docs; fazm reads/writes it via `security` (A.9) — **[blog/source-verified only]**.
- **Env vars that matter to a host** **[docs]** https://code.claude.com/docs/en/env-vars: `CLAUDECODE` ("Set to `1` in subprocesses Claude Code spawns… stdio MCP server subprocesses. IDE extensions also set this") — the nested-session guard fazm deletes (A.2); `CLAUDE_CODE_CHILD_SESSION` ("only set by Claude Code itself… A nested interactive `claude` TUI started this way is automatically excluded" from `--resume`/`--continue`); `CLAUDE_CODE_FORCE_SESSION_PERSISTENCE=1`; `CLAUDE_CONFIG_DIR`; `DISABLE_AUTOUPDATER=1` (`DISABLE_UPDATES` blocks manual too); `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` (any non-empty value, even `0`); `DISABLE_TELEMETRY` (same semantics); `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` ("In Agent SDK and `claude -p` sessions, this also skips the background small/fast-model request that generates the session title"); `CLAUDE_CODE_SIMPLE=1` (= `--bare`; "OAuth tokens and keychain credentials are not read"); `CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS=1`; `CLAUDE_AGENT_SDK_MCP_NO_PREFIX=1`; `CLAUDE_CODE_SESSION_ID` (set in tool/hook/MCP subprocesses); `MAX_THINKING_TOKENS`; `CLAUDE_CODE_MAX_OUTPUT_TOKENS`. Nested-session error text: "Claude Code cannot be launched inside another Claude Code session" **[issue]** https://github.com/anthropics/claude-code/issues/25803.
- **Policy** **[docs]**: SDK overview/quickstart: "Unless previously approved, Anthropic does not allow third party developers to offer claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK. Use the API key authentication methods… instead." https://code.claude.com/docs/en/agent-sdk/overview. Legal page: "Anthropic does not permit third-party developers to offer Claude.ai login into their own applications, or to route requests through Free, Pro, or Max plan credentials on behalf of their users… developers may not collect, store, or intermediate Claude.ai credentials or session tokens" — **but** "Nor does it prevent an end user from signing in to the unmodified Claude Code binary with their own Claude subscription, including where a platform hosts Claude Code" under conditions: Commercial ToS, "The Claude Code binary must not be modified… may not remove, disable, or restrict any authentication method built into it", each user authenticates with their own credential, no reselling, may say "runs Claude Code" in plain text, no logo https://code.claude.com/docs/en/legal-and-compliance. Branding: "Claude Agent" / "Powered by Claude", never "Claude Code" for SDK products **[docs]** overview.
- **Subscription usage for programmatic use:** Anthropic announced separate monthly "Agent SDK credits" (effective 2026-06-15; Pro $20, Max 5x $100, Max 20x $200) then **paused it the same day**: "For now, nothing has changed: Claude Agent SDK, `claude -p`, and third-party app usage still draw from your subscription's usage limits." **[docs]** https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan; corroborated **[blog]** https://zed.dev/blog/anthropic-subscription-changes. (Press reported Max 20x as $400 — conflicts with the support article; trust the support article.)
- **Net for SnappyOS:** fazm's approach — running its own PKCE flow against `claude.ai/oauth/authorize` with Claude Code's client id and writing the Keychain item (A.9) — is precisely "intermediating Claude.ai credentials" and sits on the wrong side of the legal page. The compliant pattern is the one `claude-agent-acp` and Zed use: hand the **unmodified** `claude` binary a real terminal for `claude auth login --claudeai` (terminal-auth method) and never touch the token. Get written confirmation from Anthropic before shipping; default to API key / `CLAUDE_CODE_OAUTH_TOKEN` supplied by the user.
#### C.1.d ACP adapter vs SDK — pick
| Concern | ACP adapter | SDK direct |
|---|---|---|
| Uniform multi-agent | one client for Claude + Codex + Gemini + 40 others (B.14) | Claude only |
| UI primitives | native `tool_call{kind,status}`, `diff`, `terminal`, `plan`, modes, commands | you rebuild them from `tool_use`/`tool_result`/`stream_event`/`TaskCreate` |
| Permissions | standard request + option list; adapter maps SDK suggestions into "always allow" options; cancel ≠ reject | full `canUseTool` incl. `updatedInput`, `PermissionUpdate` rules, `interrupt`; hooks run even in bypass |
| Cost/usage | `usage_update` (cumulative) + `_meta.quota` per turn (0.71+) | authoritative per-turn `result.modelUsage`, `maxBudgetUsd`, `getContextUsage()`, `accountInfo()` |
| Sessions | `session/list|load|resume|close|delete|unstable_forkSession` | `listSessions`, `resume`, `forkSession`, `resumeSessionAt`, `sessionStore`, `persistSession:false` |
| Auth UX | terminal-auth method → you must spawn a PTY login | you set env; subscription login still needs the CLI's own flow |
| Process count | app → node adapter → SDK → `claude` | app → node SDK → `claude` (or `spawnClaudeCodeProcess` from Swift) |
| Escape hatch | `_meta.claudeCode.options` + `emitRawSDKMessages` → `_claude/sdkMessage` | n/a |
| Stability | ACP v1 stable; v2 draft changes shapes | semver; `.d.ts` is the contract; binary pinned per SDK version |
### C.2 OpenAI Codex CLI
**Current bits (npm registry, 2026-09-02):** `@openai/codex` 0.152.1 (bin `codex` → `bin/codex.js`, real binary in per-platform optionalDependencies `@openai/codex-darwin-arm64` etc. at `vendor/<target-triple>/bin/codex`), `@openai/codex-sdk` 0.152.1, `@agentclientprotocol/codex-acp` 1.8.0 (depends on `@openai/codex ^0.152.0`). `@zed-industries/codex-acp` (0.16.0, what fazm pins at 0.12.0) is **archived**: "Development migrated to `agentclientprotocol/codex-acp` on the new Codex App Server." **[docs]** https://github.com/zed-industries/codex-acp, https://registry.npmjs.org/@openai/codex/latest, https://registry.npmjs.org/@agentclientprotocol/codex-acp/latest. Note `developers.openai.com/codex/*` now 308-redirects to `learn.chatgpt.com/docs/*` and the GitHub `docs/*.md` files are stubs **[docs]** https://developers.openai.com/codex/noninteractive.
**Three ways in, ranked for a GUI host:**
| Surface | Spawn | Approvals? | Verdict |
|---|---|---|---|
| **`codex app-server`** (stdio JSON-RPC) | `codex app-server` (default `--listen stdio://`; also `ws://IP:PORT`, `unix://`) | **Yes** — server→client requests | The surface OpenAI's own VS Code extension and desktop app use. "enables deep integration inside your own product: authentication, conversation history, approvals, and streamed agent events" **[docs]** https://learn.chatgpt.com/docs/app-server; README https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md. Caveat: "experimental and aren't supported for production workloads" (same page). |
| **`@agentclientprotocol/codex-acp`** (ACP over stdio) | `npx -y @agentclientprotocol/codex-acp` | Yes — mapped to ACP `session/request_permission` | "starts the Codex App Server, translates ACP requests into Codex operations, and maps Codex events back" **[docs]** https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/README.md. Use this if the app speaks ACP for every agent. |
| `codex exec --json` / `@openai/codex-sdk` | `codex exec --experimental-json …` | **No** — `approval_policy: Never` forced; approval requests auto-rejected ("file change approval is not supported in exec mode") | Fire-and-forget automation only **[docs]** https://raw.githubusercontent.com/openai/codex/main/codex-rs/exec/src/lib.rs. The TS SDK "spawns the CLI and exchanges JSONL events over stdin/stdout" **[docs]** https://raw.githubusercontent.com/openai/codex/main/sdk/typescript/README.md. |
| `codex mcp-server` | stdio MCP, tools `codex` / `codex-reply` | `execCommandApproval` / `applyPatchApproval` → `{decision: allow|deny}` | "experimental and subject to change" **[docs]** https://raw.githubusercontent.com/openai/codex/main/codex-rs/docs/codex_mcp_interface.md. Legacy. `codex proto` no longer exists **[not-found]** in `codex-rs/cli/src/main.rs`. |
**app-server wire format & lifecycle [docs, README above]:** JSON-RPC 2.0 "with the `"jsonrpc":"2.0"` header omitted on the wire", newline-delimited. Sequence: `initialize {clientInfo:{name,title,version}}` → `initialized` notification → `thread/start {model, cwd, approvalPolicy: "never"|"unlessTrusted"|…, sandbox: "workspaceWrite"|"readOnly"|"dangerFullAccess", ephemeral?, baseInstructions?, developerInstructions?}` → `turn/start {threadId, input:[{type:"text"|"image"|"localImage"|"audio"|"localAudio",…}], cwd?, approvalPolicy?, sandboxPolicy?, model?, effort?, outputSchema?}`; `turn/interrupt`, `turn/steer`; `thread/resume {threadId}`, `thread/fork`, `thread/list` (cursor + `cwd`/`archived`/`searchTerm` filters), `thread/read`, `thread/archive|unarchive|delete`. Backpressure error `-32001` "Server overloaded; retry later." `capabilities.experimentalApi: true` unlocks gated fields; `capabilities.optOutNotificationMethods` suppresses noisy deltas. Side effect: a `thread/start` with `cwd` under workspace-write/full access **marks that project trusted in `config.toml`**.
Official Node sketch **[docs]** https://learn.chatgpt.com/docs/llms-full.txt:
```ts
const proc = spawn("codex", ["app-server"], { stdio: ["pipe","pipe","inherit"] });
send({ method:"initialize", id:0, params:{ clientInfo:{ name:"my_product", title:"My Product", version:"0.1.0" } } });
send({ method:"initialized", params:{} });
send({ method:"thread/start", id:1, params:{ model:"gpt-5.4" } });
send({ method:"turn/start", id:2, params:{ threadId, input:[{ type:"text", text:"Summarize this repo." }] } });
```
**What app-server streams [docs, README]:** `turn/started`, `turn/completed {turn.status: completed|interrupted|failed, error?.codexErrorInfo: ContextWindowExceeded|UsageLimitExceeded|rateLimitExceeded|Unauthorized|SandboxError|…}`, `turn/diff/updated {diff}` (aggregated unified diff after every file change), `turn/plan/updated {plan:[{step,status}]}`, `thread/tokenUsage/updated`, `item/started` → deltas → `item/completed` where deltas are `item/agentMessage/delta`, `item/reasoning/summaryTextDelta`, `item/reasoning/textDelta`, `item/commandExecution/outputDelta`, `item/fileChange/patchUpdated`, `item/mcpToolCall/progress`. Item types: `userMessage, agentMessage, plan, reasoning, commandExecution{command,cwd,status,aggregatedOutput,exitCode,durationMs}, fileChange{changes:[{path,kind,diff}]}, mcpToolCall, collabToolCall, subAgentActivity, webSearch, imageGeneration, imageView, contextCompaction, …`.
**Approvals (server→client JSON-RPC requests) [docs, README]:**
- `item/commandExecution/requestApproval {itemId, threadId, turnId, kind: command|writeStdin, reason, command, cwd, commandActions, availableDecisions?, proposedExecpolicyAmendment?, networkApprovalContext?}` → reply `{ "decision": "accept" | "acceptForSession" | {"acceptWithExecpolicyAmendment":{…}} | {"applyNetworkPolicyAmendment":{…}} | "decline" | "cancel" }`.
- `item/fileChange/requestApproval {itemId, threadId, turnId, reason?, grantRoot?}` → `{ "decision": "accept"|"acceptForSession"|"decline"|"cancel" }`.
- `item/permissions/requestApproval {…, permissions:{fileSystem:{write:[…]}, network?}}` → `{ "scope": "session"|"turn", "permissions": {granted subset} }`.
- `item/tool/requestUserInput`, `mcpServer/elicitation/request`, `item/tool/call` (client `dynamicTools`).
- "UI guidance for IDEs: surface an approval dialog as soon as the request arrives."
- `approvalsReviewer: "auto_review"` delegates approvals to a subagent.
**Auth via app-server [docs, README "Auth endpoints"]:** `account/read` → `{account:{type:"chatgpt", email, planType}}`; `account/login/start {type:"chatgpt"}` → `{loginId, authUrl}` (app-server hosts the localhost callback — open `authUrl`); `type:"chatgptDeviceCode"` → `{verificationUrl:"https://auth.openai.com/codex/device", userCode}`; `account/login/completed`, `account/updated {authMode: apikey|chatgpt|…}`, `account/logout`, `account/rateLimits/read` + `account/rateLimits/updated`, `account/usage/read`. Credentials: `~/.codex/auth.json` ("treat like a password") or keyring via `cli_auth_credentials_store = file|keyring|auto`; `CODEX_HOME` defaults to `~/.codex` **[docs]** https://learn.chatgpt.com/docs/auth. Env: `CODEX_API_KEY` (one-shot), `CODEX_ACCESS_TOKEN` (automation) **[docs]** llms-full.txt. `codex login --api-key` is deprecated → `printenv OPENAI_API_KEY | codex login --with-api-key`; `codex login --device-auth` **[docs]** https://learn.chatgpt.com/docs/auth. This makes fazm's hand-rolled OAuth (`codex-oauth-flow.ts`) unnecessary on the app-server road.
**ACP adapter mapping (`@agentclientprotocol/codex-acp`) [docs]** https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/docs/permission-extension.md: `accept`→`allow_once`, `acceptForSession`→`allow_always`, `acceptWithExecpolicyAmendment`→`allow_always`, `decline`/`cancel`→`reject_once`; network allow→`allow_always`, deny→`reject_always`; unadvertised option ids fail closed. Env: `CODEX_API_KEY`/`OPENAI_API_KEY`, `CODEX_PATH` (custom binary), `CODEX_CONFIG` (JSON merged into session config), `INITIAL_AGENT_MODE = read-only | agent | agent-full-access`, `NO_BROWSER`, `APP_SERVER_LOGS` **[docs]** README. Slash commands `/status /mcp /skills /goal /review /compact /logout`. Registry manifest: `{"id":"codex-acp","version":"1.8.0","distribution":{"npx":{"package":"@agentclientprotocol/codex-acp@1.8.0"}}}` **[docs]** https://raw.githubusercontent.com/agentclientprotocol/registry/main/codex-acp/agent.json.
**`codex exec` flags (if you only need batch) [docs]** https://learn.chatgpt.com/docs/non-interactive-mode + `codex-rs/exec/src/cli.rs`: `--json` (alias `--experimental-json`), `--output-last-message <path>`, `--output-schema <path>`, `--sandbox read-only|workspace-write|danger-full-access`, `--full-auto` (deprecated), `--dangerously-bypass-approvals-and-sandbox`/`--yolo`, `-C/--cd`, `-m/--model`, `-c key=value`, `-p/--profile`, `--skip-git-repo-check`, `--ephemeral`, `--ignore-user-config`, `-i/--image`, prompt `-` = stdin, `codex exec resume <id|--last|--all>`, `codex exec fork`. Exits 1 with "Not inside a trusted directory and --skip-git-repo-check was not specified." outside a git repo **[docs]** exec `lib.rs`. JSONL events: `thread.started{thread_id}`, `turn.started`, `turn.completed{usage:{input_tokens,cached_input_tokens,cache_write_input_tokens,output_tokens,reasoning_output_tokens}}`, `turn.failed{error}`, `item.started|updated|completed{item:{id,type: agent_message|reasoning|command_execution{command,aggregated_output,exit_code,status}|file_change{changes:[{path,kind}]}|mcp_tool_call|collab_tool_call|web_search|todo_list|error}}` **[docs]** https://raw.githubusercontent.com/openai/codex/main/codex-rs/exec/src/exec_events.rs.
**Codex config keys a host cares about [docs]** https://learn.chatgpt.com/docs/config-file/config-reference: `approval_policy = "untrusted"|"on-request"|"never"` (`on-failure` deprecated), `approvals_reviewer`, `sandbox_mode`, `[sandbox_workspace_write] writable_roots/network_access`, `model_reasoning_effort = minimal|low|medium|high|xhigh`, `hide_agent_reasoning`, `show_raw_agent_reasoning`, `notify = [cmd]`, `web_search`, `[history] persistence = "save-all"|"none"`, `[projects."<path>"] trust_level`, `[mcp_servers.<id>] command/args/env/cwd/url/http_headers/bearer_token_env_var/startup_timeout_sec`, profiles at `$CODEX_HOME/<name>.config.toml`.
**macOS sandbox [docs]** https://raw.githubusercontent.com/openai/codex/main/codex-rs/core/README.md: "Expects `/usr/bin/sandbox-exec` to be present"; workspace-write keeps `.git` and `.codex` read-only; "the sandbox applies to spawned commands, not just built-in file operations" https://learn.chatgpt.com/docs/sandboxing.
**Non-TTY hazards [issue]:** `codex exec` hangs at 0 % CPU when stdin is an inherited-but-never-closed pipe — workaround `< /dev/null` (https://github.com/openai/codex/issues/20919); silently exits 0 with empty stdout when detached from a controlling TTY with a long prompt (https://github.com/openai/codex/issues/19945); Codex.app hangs after the Homebrew CLI is upgraded while the app runs (https://github.com/openai/codex/issues/23695). Desktop/VS Code "bundle a platform-specific binary, launch it as a child process, and keep a bidirectional stdio channel open" **[blog]** https://www.infoq.com/news/2026/02/opanai-codex-app-server/ (OpenAI's original post returned 403).
### C.3 Gemini CLI
**Current bits:** `@google/gemini-cli` 0.58.0, bin `gemini` → `bundle/gemini.js`, Node ≥ 20 **[docs]** https://registry.npmjs.org/@google/gemini-cli/latest. ACP registry manifest: `{"id":"gemini","version":"0.58.0","distribution":{"npx":{"package":"@google/gemini-cli@0.58.0","args":["--acp"]}}}` **[docs]** https://raw.githubusercontent.com/agentclientprotocol/registry/main/gemini/agent.json.
**Spawn:** `gemini --acp` (or `node bundle/gemini.js --acp`). `--experimental-acp` still parses but is "deprecated, use --acp instead" **[docs]** `packages/cli/src/config/config.ts`. Official page: "ACP mode is a special operational mode of Gemini CLI designed for programmatic control, primarily for IDE and other developer tool integrations. It uses a JSON-RPC protocol over stdio" **[docs]** https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/cli/acp-mode.md. Transport is `acp.ndJsonStream(stdout, stdin)` + `AgentSideConnection` from `@agentclientprotocol/sdk` **[docs]** `packages/cli/src/acp/acpStdioTransport.ts`.
**Env for an embedded host:** `GEMINI_API_KEY` (or `GOOGLE_API_KEY`; Vertex via `GOOGLE_GENAI_USE_VERTEXAI`, `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_LOCATION`), `GEMINI_CLI_TRUST_WORKSPACE="true"` ("Useful for headless environments"), `GEMINI_SANDBOX=false`, `NO_COLOR`, `GEMINI_CLI_HOME`, `GEMINI_TELEMETRY_ENABLED=false`; settings `general.enableAutoUpdate=false`, `general.enableAutoUpdateNotification=false`, `privacy.usageStatisticsEnabled=false`, `security.auth.selectedType="gemini-api-key"` **[docs]** https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/reference/configuration.md. fazm's `GEMINI_CLI_TRUST_WORKSPACE` requirement (A.2) is confirmed by the docs.
**Handshake [docs]** `packages/cli/src/acp/acpRpcDispatcher.ts`: `initialize` returns `authMethods` = `[{id: oauth-personal ("Log in with Google")}, {id:"gemini-api-key", _meta:{"api-key":{provider:"google"}}}, {id:"vertex-ai"}, {id:"gateway", _meta:{gateway:{protocol:"google"}}}]` and `agentCapabilities: { loadSession:true, promptCapabilities:{image:true, audio:true, embeddedContext:true}, mcpCapabilities:{http:true, sse:true} }`. Then `authenticate {methodId, _meta?:{"api-key":"<key>"}}` (errors → `-32000`), `session/new {cwd, mcpServers}` (throws `-32000 "Authentication required."` if not authed; auth runs *before* MCP servers start), `session/load {sessionId, cwd, mcpServers}` via `resumeChat` **[docs]** `acpSessionManager.ts`. `session/set_mode` ids: `default` ("Prompts for approval"), `auto_edit`, `yolo`, `plan` **[docs]** `acpUtils.ts`; `unstable_setSessionModel` exists **[docs]** acp-mode.md.
**Permissions [docs]** `acpSession.ts`: `session/request_permission {sessionId, options, toolCall:{toolCallId, status:"pending", title, content, locations, kind}}` with options `allow_always` "Allow for this session", `allow_always` "…in all future sessions" (only if `security.enablePermanentToolApproval`), MCP "Allow all server tools for this session", `allow_once`, `reject_once`; `outcome:"cancelled"` maps to Cancel; a separate allow/reject prompt fires for reads outside the workspace.
**Streams:** `agent_message_chunk`, `agent_thought_chunk`, `tool_call` + `tool_call_update`, `available_commands_update`; per-turn tokens in `PromptResponse._meta.quota.token_count` (fazm reads exactly this, A.4) **[docs]** acpSession.ts; fazm source.
**Headless alternative (no approvals):** `gemini -p "…" --output-format stream-json` → `{type, timestamp}` events `init{session_id, model}`, `message{role, content, delta?}`, `tool_use{tool_name, tool_id, parameters}`, `tool_result{tool_id, status, output?, error?}`, `error`, `result{status, stats:{total_tokens,input_tokens,output_tokens,cached,duration_ms,tool_calls,models}}`; exit codes 0/1/42/53 **[docs]** https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/cli/headless.md, `packages/core/src/output/types.ts`. Non-TTY stdin is read to EOF and prepended to the prompt; `setRawMode(true)` only when interactive **and** `isTTY` **[docs]** `packages/cli/src/gemini.tsx`. Flags: `--approval-mode default|auto_edit|yolo|plan`, `--allowed-tools`, `--include-directories`, `-r/--resume [id|index|latest]`, `--list-sessions`, `--session-id` **[docs]** cli-reference.md. **Gotcha:** "Sandbox is enabled when using `--yolo` or `--approval-mode=yolo` by default" (Docker/Seatbelt) **[docs]** configuration.md.
**Sessions:** `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl`; retention `general.sessionRetention` **[docs]** https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/cli/session-management.md.
**Gemini ACP issues [issue]:** #23959 "ACP server does not start when sandboxing is enabled and stdin is not a TTY" (https://github.com/google-gemini/gemini-cli/issues/23959); #17952 ~30 s delay before `session/request_permission` (fixed PR #17955); #12042 ACP prompts for login when spawned from a script despite cached OAuth — closed not-planned, workaround API key (https://github.com/google-gemini/gemini-cli/issues/12042); #10855 `GEMINI_API_KEY` still prompts for auth method; #24916 repeated permission for same file; #7880 Windows EOL parsing; PR #23673 stdin `resume()` fix for Ink hang in headless. fazm's "sessionId mismatch on session/update" (A.12) was **[not-found]** as a public issue.
**Zed's config for any ACP agent [docs]** https://raw.githubusercontent.com/zed-industries/zed/main/docs/src/ai/external-agents.md:
```json
{ "agent_servers": { "my-agent": { "type": "custom", "command": "node", "args": ["~/projects/agent/index.js", "--acp"], "env": {} } } }
```
Zed passes its own Google key to Gemini as `GEMINI_API_KEY` if the process lacks one; it does *not* do so for Claude/Codex (same page). Whether Zed uses bundled Node or system `npx` for registry agents: **[not-found]** on that page (see Section D).
---
## Section D — Prior-art host apps and what they learned
Method tags: **(a)** raw PTY + terminal emulator · **(b)** `claude -p --output-format stream-json` (or equivalent CLI JSON) · **(c)** vendor SDK (Claude Agent SDK / Codex SDK) · **(d)** ACP · **(e)** own agent loop / own daemon.
| Host | Method | Stack | What it taught |
|---|---|---|---|
| **Zed** | (d) | Rust editor; agents as subprocesses | The reference ACP client. Registry-installed agents (Claude, Codex, OpenCode, Copilot, Cursor, Pi) launch via `npx <pkg>` per `agent.json`; custom agents via `agent_servers.{name}:{type:"custom", command, args, env}`; "External agents are separate processes communicating over ACP" and "usually own their own runtime, auth, model selection, tools"; Zed's own MCP servers "may be forwarded to External Agents over ACP"; debug with `dev::OpenAcpLogs` **[docs]** https://raw.githubusercontent.com/zed-industries/zed/main/docs/src/ai/external-agents.md. Design rationale: "Just as the Language Server Protocol unbundled language intelligence from monolithic IDEs, our goal with the Agent Client Protocol is to enable you to switch between multiple agents without switching your editor" **[blog]** https://zed.dev/blog/bring-your-own-agent-to-zed. Zed downloads and manages its **own Node runtime** (Linux path `~/.local/share/zed/node/node-v*/bin/node`; "Zed currently tries to download an upstream Node runtime no matter what") with `node.path` / `node.npm_path` / `node.ignore_system_version` settings **[issue]** https://github.com/zed-industries/zed/issues/12631, https://github.com/zed-industries/zed/issues/46162, **[docs]** https://zed.dev/docs/configuring-zed. Permission button labels and "always allow" persistence: **[not-found]** on the fetched pages. Zed passes its Google key as `GEMINI_API_KEY` but never its Anthropic/OpenAI keys to Claude/Codex (external-agents.md). |
| **Anthropic Claude Code Desktop (Code tab)** | (e) first-party | Electron-style desktop app; spawns its own Claude Code | Parallel sessions each get a git worktree at `<project-root>/.claude/worktrees/` (configurable; `.worktreeinclude` for gitignored files); permission-mode selector (Manual/Accept edits/Plan/Auto/Bypass; `dontAsk` is CLI-only); usage ring shows context + plan usage; "The desktop app does not always inherit your full shell environment. On macOS, when you launch the app from the Dock or Finder, it reads your shell profile, such as `~/.zshrc` or `~/.bashrc`, to extract `PATH` and a fixed set of Claude Code variables, but other variables you export there are not picked up"; SSH sessions "install Claude Code on the remote machine automatically" **[docs]** https://code.claude.com/docs/en/desktop. Auth: "Claude Desktop and cloud sessions do not call `apiKeyHelper` or read these environment variables: they use OAuth" **[docs]** https://code.claude.com/docs/en/authentication. Whether it ships a bundled `claude` binary separate from the CLI install: **[not-found]** in the docs. |
| **Conductor** (conductor.build) | **(c)** Agent SDK | Tauri shell + Rust core + Bun **[blog]** | First-party: "Conductor uses native Claude Code, but we do so through the Claude Agent SDK" (2026-06-15 post on the paused subscription change; "No action is required") **[docs]** https://conductor.build/blog/claude-subscription-update. "Run parallel Claude Code, Codex, and Cursor agents in isolated workspaces on your Mac" **[docs]** https://conductor.build/; "Each task gets its own workspace, branch, files, terminal, diff, and review path" https://conductor.build/docs. Third-party write-up: Tauri, "Rust core spawning agent CLIs", Node→Bun runtime, `--resume <uuid>` **[blog]** https://performance.dev/the-conductor-rewrite. Per-agent auth is the CLI's own (`claude /login`, `codex login`) **[docs]** https://www.conductor.build/docs/installation (per lane report). Lesson: a native-feeling Mac app can be Tauri+Rust around the **SDK**, and the SDK road keeps the user's subscription (after Anthropic's pause). |
| **Xum** (ex-Mux, coder) | (e) own loop + PTY; **ACP server** | Electron 40, Vercel AI SDK, node-pty, xterm/ghostty-web, `@agentclientprotocol/sdk` | "Xum has a custom agent loop but much of the core UX is inspired by Claude Code"; providers via API keys (no Claude subscription OAuth); runtimes Local / Worktree / SSH; Costs tab **[docs]** https://github.com/coder/mux (README), https://xum.coder.com. Exposes itself **as** an ACP agent (`xum acp`) so Zed can drive it **[docs]** https://xum.coder.com/integrations/acp.md (per lane). Lesson: if you write your own loop you lose the vendor's tools/skills/hooks/subscription; Xum's answer is ACP-server mode, not hosting the CLIs. |
| **Emdash** (generalaction) | **(a) PTY + (d) ACP** | Electron, node-pty, `@agentclientprotocol/claude-agent-acp` + `@agentclientprotocol/codex-acp` | The closest open-source analogue to what Robert wants. Claude ACP spawn: `command: process.execPath, args:[claude-agent-acp/dist/index.js], env:{ELECTRON_RUN_AS_NODE:'1', CLAUDE_CODE_EXECUTABLE: ctx.cli}` — "Point the adapter's Claude Agent SDK at the host-installed claude binary instead of the SDK's auto-downloaded native binary" **[docs]** https://raw.githubusercontent.com/generalaction/emdash/main/packages/plugins/src/agents/impl/claude/index.ts (lines 140-160), adapter specifier `'@agentclientprotocol/claude-agent-acp/dist/index.js'` (…/claude/adapter.ts:5). Terminal-mode command uses `--dangerously-skip-permissions` as the auto-approve flag, `--resume`, `--session-id`, `--model` (same file). Architecture doc: "Desktop relies on Electron's `child_process.fork` behavior, which runs children with `ELECTRON_RUN_AS_NODE`. The packaged app must keep the `RunAsNode` fuse enabled" **[docs]** https://raw.githubusercontent.com/generalaction/emdash/main/agents/architecture/acp-runtime.md. Pre-seeds `~/.claude.json` `projects[<worktree>].hasTrustDialogAccepted=true` so the CLI never blocks on the trust dialog (…/claude/trust.ts, per lane); installs marker-tagged `UserPromptSubmit`/`Notification`/`Stop` hooks and notes "Claude's Notification events carry no notification_type field" (…/claude/hooks.ts, per lane). Packaging: `hardenedRuntime: true`, entitlements `app-sandbox=false`, `cs.allow-jit`, `cs.allow-unsigned-executable-memory`, `cs.disable-library-validation`; `asarUnpack` for `node-pty/**`, `**/*.node`; "upstream node-pty tarballs ship the darwin spawn-helper prebuild without the exec bit" **[docs]** https://raw.githubusercontent.com/generalaction/emdash/main/apps/emdash-desktop/electron-builder.config.ts, `.scratch/dev-setup-overhaul-build/issues/04-native-dependency-rework.md` (per lane). Changelog notes "Orphaned processes are cleaned up properly: detached descendants on kill" **[docs]** https://emdash.com/changelog (per lane). |
| **Superconductor** (superconductor.com) | (e) cloud VMs | web + native clients | "each implementation" runs in a full cloud VM; bring "your Claude Pro, Max, or Team plan, your ChatGPT subscription, your SuperGrok plan, or your own API keys"; shows 5-hour/7-day plan usage next to the agent and "Estimated API spend on each implementation card" **[docs]** https://www.superconductor.com/ , https://www.superconductor.com/docs/agents (per lane). Rationale for cloud: "juggling the worktrees added significant mental overhead and actually began straining our laptops" **[blog]** https://www.superconductor.com/blog/why-we-built-superconductor (per lane). A separate **super.engineering** ("No Electron. No Tauri. 100% Rust", agents "as local subprocesses on your own subscriptions") is closed alpha **[blog]** https://x.com/superdoteng/status/2042335263154978868 (per lane; site 403). |
| **Piebald** (piebald.ai) | (a)-like: drives the **real interactive `claude`** in the background | closed source, desktop + web | Provider docs: uses `claude` from PATH / manual path / managed install, "internally use Claude Code to communicate with Anthropic's API so there's no risk of your account getting banned"; changelog v0.4.0 (2026-06-01): "we now run Claude Code interactively in the background without relying on the Agent SDK or `claude -p`" — explicitly to keep usage on the main subscription bucket after Anthropic's (later paused) split **[docs]** https://docs.piebald.ai/providers/claude-max.md , https://docs.piebald.ai/changelog.md (per lane). Reimplements the Claude hooks contract (`SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/Stop/PreCompact/…`) **[docs]** https://docs.piebald.ai/features/agentic/claude-code-hooks-compatibility.md. Permission modes Read-only / Auto-accept / Plan / YOLO; "persists all sessions (including pending tool-call approvals) across machine reboots" **[blog]** https://github.com/Jamie-BitFlight/claude_skills/blob/main/research/developer-tools/piebald.md. Piebald's `tweakcc` documents that the native `claude` is "a large platform-specific native executable containing the same minified/compiled JavaScript… packaged up in a Bun binary" **[docs]** https://github.com/Piebald-AI/tweakcc. Lesson: driving the interactive TUI over a PTY is the only road with zero policy ambiguity about subscriptions, at the cost of screen-scraping. "The Companion": **[not-found]**. |
| **Claudia / opcode** (getAsterisk) | (b) | Tauri 2 + Rust + React | Requires "`claude` is available in your PATH"; browses `~/.claude/projects/`; "Cost Tracking: Monitor your Claude API usage and costs in real-time"; "Process Isolation: Agents run in separate processes" **[docs]** https://github.com/getAsterisk/claudia. Exact spawn flags **[not-found]** in the README. |
| **CodeLayer / humanlayer** | (b) via Go daemon | `hld` daemon (Go) + Tauri WUI + `claudecode-go` | `claudecode-go` launches sessions with `SessionConfig{ MCPConfig, PermissionPromptTool: "mcp__approvals__request_permission", AllowedTools: []string{"mcp__approvals__*"}, SessionID (resume) }` and an MCP server `humanlayer mcp claude_approvals` — i.e. **approvals via `--permission-prompt-tool`**, not a PTY **[docs]** https://raw.githubusercontent.com/humanlayer/humanlayer/main/claudecode-go/README.md (lines 68-120). Lesson: the pre-SDK way to get a permission callback out of `claude -p` is an MCP tool the CLI calls. |
| **Vibe Kanban** (BloopAI) | (b)/(a) spawns each CLI | Rust backend + React, `npx vibe-kanban` | "Switch between 10+ coding agents — Claude Code, Codex, Gemini CLI, GitHub Copilot, Amp, Cursor, OpenCode, Droid, CCR, and Qwen Code"; "each workspace gives an agent a branch, a terminal, and a dev server"; project is **sunsetting** per README banner **[docs]** https://github.com/BloopAI/vibe-kanban. Spawn mechanics **[not-found]** in README. |
| **Crystal** (stravu) | — | Electron | "has been deprecated and replaced by Nimbalyst" **[docs]** https://github.com/stravu/crystal. |
| **fazm** | (d) via bundled-Node bridge | Swift + bundled Node 22 + adapters | Section A. Public positioning: "runs the real Claude Code, Codex, and Gemini CLI agent loop in a native Mac app, on your own Claude Pro or Max account"; "Sessions survive restarts, fork any chat in one click, nothing gets auto-compacted" **[docs]** https://fazm.ai/cc. Source: https://github.com/mediar-ai/fazm. |
| **Workshop** | — | — | No host app by that name found (Canonical's `workshop` is a container runner) **[not-found]** (lane). Other native-Mac hosts that surfaced but were not researched: Mosaic https://github.com/defyus/mosaic, diri https://github.com/cristicretu/diri, cmux https://github.com/manaflow-ai/cmux (lane, **[blog]**). |
| **Happy Coder, Cline "Claude Code provider", Kilo** | — | — | **[not-found]** — not reached in this pass. |
**D.2 Additional host detail (from the prior-art lane; primary sources):**
- **Zed's spawn path, concretely:** `ShellBuilder::new(&Shell::System, …).non_interactive()` → `build_std_command(path, args)` → `envs(env)` → `current_dir(first worktree root)` → `Child::spawn(cmd, piped, piped, piped)`; stderr lines are logged as `agent stderr: …` into the ACP debug log; exit surfaces as `LoadError` with trailing stderr **[docs]** https://github.com/zed-industries/zed/blob/main/crates/agent_servers/src/acp.rs. `util::process::Child::spawn` does `pre_exec(|| { libc::setsid(); Ok(()) })` and `kill()` = `libc::killpg(pid, SIGKILL)` — the rationale being a spawned shell stealing the foreground process group **[docs]** https://github.com/zed-industries/zed/blob/main/crates/util/src/process.rs, **[blog]** https://registerspill.thorstenball.com/p/how-to-lose-control-of-your-shell. Env passed = shell-resolved project env + registry `distribution.env` + per-agent `settings_env` + proxy vars (`agent_servers.rs`). Registry npx agents are `npm install`ed into `~/Library/Application Support/Zed/external_agents/registry/npx/<id>/` with Zed's **managed Node v24.11.0** and then run as `node <resolved bin>`; a `cmd == "node"` in a binary manifest is rewritten to the managed node **[docs]** https://github.com/zed-industries/zed/blob/main/crates/project/src/agent_server_store.rs, https://github.com/zed-industries/zed/blob/main/crates/node_runtime/src/node_runtime.rs. That `node <bin>` rewrite breaks packages whose `bin` is a native Mach-O **[issue]** https://github.com/zed-industries/zed/issues/62716. Zed's Gemini special-case adds a terminal auth method `spawn-gemini-cli` ("gemini /auth") (acp.rs). Cost: `usage_update` → `TokenUsage` + `SessionCost{amount,currency}` and a context ring since 1.7.2 **[docs]** https://github.com/zed-industries/zed/blob/main/crates/acp_thread/src/acp_thread.rs. Zed's ACP-host bug tracker is a preview of yours: agent processes never terminated (55 procs / 3.1 GB) #61303; archived sessions keep MCP servers alive #56747; wrong node picked from a workspace #45241; `ERR_MODULE_NOT_FOUND zod` #43675; Claude Code gets a different env than the editor (direnv) #38988; `terminal.shell` leaking into ACP spawns #46551; usage-limit exit 143 leaves a dead session #55501; dead connection never respawned #62828; permission prompts never render #62788 **[issue]** https://github.com/zed-industries/zed/issues/{61303,56747,45241,43675,38988,46551,55501,62828,62788}.
- **Vibe Kanban's Claude executor speaks the SDK wire format without the SDK:** `npx -y @anthropic-ai/claude-code@2.1.119 -p --permission-prompt-tool=stdio --permission-mode=bypassPermissions --verbose --output-format=stream-json --input-format=stream-json --include-partial-messages --replay-user-messages`, follow-ups with `--resume <id> --resume-session-at <uuid>`, `env_remove("ANTHROPIC_API_KEY")` when on subscription, tokio `kill_on_drop` + `command_group` **[docs]** https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/claude.rs. The control protocol it implements: host→CLI `{"type":"control_request","request_id":"<uuid>","request":{"subtype":"initialize","hooks":{…}}}`, `{"subtype":"set_permission_mode","mode":"…"}`, `{"subtype":"interrupt"}`; CLI→host `control_request` with `subtype:"can_use_tool"` (`tool_name`, `input`, `permission_suggestions`, `tool_use_id`) and `"hook_callback"`; host replies `{"type":"control_response",…}` **[docs]** https://github.com/BloopAI/vibe-kanban/blob/main/crates/executors/src/executors/claude/protocol.rs. Gemini via `--experimental-acp`, Codex via `codex app-server` (`gemini.rs`, `codex.rs`). PATH refresh: `$SHELL [-l] -c 'source ~/.zshrc; printf "%s" "$PATH"'` with `TERM=dumb`, 5 s timeout (`crates/utils/src/shell.rs`). OpenCode zombies after sleep/wake under launchd **[issue]** https://github.com/BloopAI/vibe-kanban/issues/3205. **This is the proof that a non-Node host can drive Claude Code's own SDK protocol directly over stdio** (see the final recommendation).
- **CodeLayer/hld (Go):** `exec.Command(claudePath, args…)`, `--resume <id>` (+`--fork-session`), `--output-format stream-json` (auto `--verbose`), `--mcp-config <inline JSON>`, `--permission-prompt-tool mcp__codelayer__request_permission`, then `--print -- <query>`; binary discovery `LookPath`, `~/.claude/local/claude`, `~/.npm/bin`, `~/.bun/bin`, `~/.local/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, then `zsh -lc "which claude"`; interrupt = SIGINT, kill = SIGKILL **[docs]** https://github.com/humanlayer/humanlayer/blob/main/claudecode-go/client.go. Daemon JSON-RPC over `~/.humanlayer/daemon.sock` (0600) + SQLite; "restarting the daemon breaks active Claude sessions" **[docs]** https://github.com/humanlayer/humanlayer/blob/main/hld/PROTOCOL.md, `DEVELOPMENT.md`.
- **Crystal (deprecated → Nimbalyst):** `pty.spawn(command, args, {name:'xterm-color', cols:80, rows:30})` and parsed stream-json off the PTY; on `env: node:` shebang failure re-spawned `node --no-warnings --enable-source-maps <cli.js>`; PATH via `${shell} -l -i -c 'echo $PATH'`; teardown SIGTERM → `kill -TERM -<pgid>` → 200 ms → SIGKILL **[docs]** https://github.com/stravu/crystal/blob/main/main/src/services/panels/cli/AbstractCliManager.ts, `main/src/utils/shellPath.ts`. Nimbalyst persists "Always" as `Bash(git:*)` rules into `.claude/settings.local.json` **[docs]** https://docs.nimbalyst.com/open-safe-private-secure/permissions-and-safety.md.
- **Claudia/opcode (Tauri 2):** always `--dangerously-skip-permissions`; env whitelist `PATH, HOME, USER, SHELL, LANG, LC_*, NODE_PATH, NVM_DIR, NVM_BIN, HOMEBREW_PREFIX, HOMEBREW_CELLAR`; discovery `which`, `NVM_BIN`, `~/.nvm/versions/node/*/bin`, `/usr/local/bin`, `/opt/homebrew/bin`, `~/.claude/local`, `~/.local/bin` **[docs]** https://github.com/winfunc/opcode/blob/main/src-tauri/src/commands/claude.rs; classic GUI failure `env: node: No such file or directory` from the npm shebang `#!/usr/bin/env -S node …` **[issue]** https://github.com/winfunc/opcode/issues/94, #58.
- **Happy Coder:** local mode spawns the interactive `claude` with `stdio: ['inherit','inherit','inherit','pipe']` (fd 3 side channel) plus `--settings <hookSettingsPath>`; remote mode uses SDK `query()` + `canUseTool` → phone. Gotchas: Ink leaves stdin `O_NONBLOCK` (fix `process.stdin._handle.setBlocking(true)`); SDK sets `CLAUDE_CODE_ENTRYPOINT=sdk-ts` which hides sessions from `claude --resume` (#1202); **launchd agents outside the Aqua session can't reach the Keychain → `API Error: 401`** (workaround: export `CLAUDE_CODE_OAUTH_TOKEN`) **[docs]** https://github.com/slopus/happy/blob/main/packages/happy-cli/src/claude/claudeLocal.ts, README, **[issue]** https://github.com/slopus/happy/issues/{80,1202}.
- **Cline / Kilo:** Cline's legacy provider ran `claude -p --output-format stream-json --max-turns 1` and deleted `ANTHROPIC_API_KEY`; the current provider uses the Agent SDK with `permissionMode:"acceptEdits"` and no `canUseTool` **[docs]** https://github.com/cline/cline/blob/v3.20.0/src/integrations/claude-code/run.ts, `sdk/packages/llms/src/providers/vendors/community.ts`. Kilo: "As of January 2026… Claude Code credentials cannot be used in Kilo Code or other third-party harnesses"; Kilo CLI is itself an ACP agent (`kilo acp`) **[docs]** https://github.com/Kilo-Org/kilocode-legacy/blob/main/docs/legacy-ides/ai-providers/claude-code.md, https://zed.dev/acp/agent/kilo.
- **Anthropic's own hosts:** Claude Desktop "includes Claude Code. You don't need to install Node.js or the CLI separately"; "Desktop runs the same underlying engine"; `--print`/`--output-format` "Not available" **[docs]** https://code.claude.com/docs/en/desktop-quickstart. The VS Code extension "bundles its own copy of the CLI" and exposes `claudeProcessWrapper` ("Executable used to launch the Claude process. The bundled binary path is passed as an argument") **[docs]** https://code.claude.com/docs/en/vs-code.
**Cross-cutting lessons the hosts converged on:**
1. Nobody serious re-implements the agent loop for Claude Code any more — Conductor (SDK), Emdash (ACP adapter), Piebald (interactive CLI), fazm (ACP adapter) all run the vendor's loop; Xum, which has its own loop, compensates by exposing itself as an ACP server.
2. Every desktop host isolates parallel sessions with **git worktrees** (Claude Desktop `.claude/worktrees/`, Conductor, Emdash `~/emdash/worktrees`, Xum `~/.xum/src/<project>/<workspace>`, Vibe Kanban) — and all of them hit the "worktree lacks `.env`/`node_modules`" problem (`.worktreeinclude`, setup scripts).
3. Hosts that want approvals get them from the protocol (ACP `session/request_permission`, Codex app-server `item/*/requestApproval`, SDK `canUseTool`, or the older `--permission-prompt-tool` MCP trick) — none scrape the TUI for "Allow?" prompts except Piebald.
4. The two auth postures are "the CLI logs in by itself" (Conductor, Zed, Emdash, Piebald) versus "we intermediate the token" (fazm's PKCE flow) — only the former is clearly inside Anthropic's legal page (C.1.c).
5. Cost display is thin everywhere except SDK-based hosts: Zed shows `usage_update`; Superconductor shows estimated API spend + plan windows; fazm had to patch the adapter; Codex app-server gives `thread/tokenUsage/updated` and `account/rateLimits/*`.
---
## Section E — Gotchas for a NATIVE MAC APP host (entitlements, PATH, PTY, notarization, bundled Node, lifecycle)
**E.1 App Sandbox is off the table for this design.** A sandboxed app's children inherit its sandbox; per Apple's entitlement guide, "If your app employs a child process created with either the posix_spawn function or the NSTask class, you can configure the child process to inherit the sandbox of its parent… a child target must use exactly two App Sandbox entitlement keys: `com.apple.security.app-sandbox` and `com.apple.security.inherit`. If you specify any other App Sandbox entitlement, the system aborts the child process" **[docs]** https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html. A user-installed `claude`/`codex`/`gemini` is not signed with `inherit`, and the agents need `$HOME`-wide file access anyway. fazm (`Fazm.entitlements`: `app-sandbox=false`) and Emdash (`entitlements.mac.plist`: `app-sandbox=false`) both ship un-sandboxed Developer-ID builds. Consequence: **no Mac App Store**; distribute with Developer ID + notarization (fazm: Sparkle; `AGENTS.md:224` "Signs with Developer ID, notarizes with Apple").
**E.2 Hardened runtime + entitlements for the bundled runtime.** Notarization requires `--options runtime` on every executable ("The executable does not have the hardened runtime enabled") and a secure timestamp ("The signature does not include a secure timestamp"; only `timestamp.apple.com`; "Generating a secure timestamp requires internet access"), and no `com.apple.security.get-task-allow` in shipping builds **[docs]** https://developer.apple.com/documentation/security/resolving-common-notarization-issues. V8 needs `com.apple.security.cs.allow-jit` ("Without the Allow execution of JIT-compiled code entitlement, frameworks that rely on just-in-time (JIT) compilation may fall back to an interpreter. Other code using JIT compilation may crash") **[docs]** https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-jit; fazm and Emdash additionally set `cs.allow-unsigned-executable-memory` ("Including this entitlement exposes your app to common vulnerabilities in memory-unsafe code languages") https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.allow-unsigned-executable-memory; native `.node` addons / Python need `cs.disable-library-validation` ("prevents a program from loading frameworks, plug-ins, or libraries unless they're either signed by Apple or signed with the same Team ID… Gatekeeper runs extra security checks on programs that have it disabled") https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.disable-library-validation. Practice from fazm: sign node with `--options runtime --entitlements Desktop/Node.entitlements` (`run.sh:657-663`), sign every `*.node`/`*.dylib`/`*.so`/`rg` under `node_modules` individually (`codemagic.yaml:808-816`), keep Python from writing `.pyc` into the bundle (`PYTHONDONTWRITEBYTECODE=1`, else "invalidates the code signature and breaks Sparkle auto-updates", `index.ts:2795-2798`), `rsync --delete` node_modules so stale nested duplicates don't shadow (`run.sh:301-306`), and gate on a 16 K-page node binary ("macOS 26 will crash", `codemagic.yaml:919-924`).
**E.3 The macOS 26 Code-Signing-Monitor trap.** "On macOS 26+ (Tahoe), Sparkle auto-updates can silently corrupt the code signing seal of the bundled node binary. The kernel's Code Signing Monitor (CSM) then kills the process with SIGKILL on launch. The binary passes `codesign --verify` but still gets killed" — fazm copies node to `$TMPDIR/fazm-node-<scope>` and verifies it with `node --version` before every spawn (`NodeBinaryHelper.swift:1-95`). Bundle-scope the temp copy or dev and prod builds clobber each other (`:20-27`). **[source; no Apple doc found]**.
**E.4 PATH in a GUI app.** Apps launched from Finder/Dock/LaunchAgent get `/usr/bin:/bin:/usr/sbin:/sbin` and never read `~/.zshrc` **[blog]** https://github.com/sindresorhus/fix-path, https://www.bounga.org/tips/2020/04/07/instructs-mac-os-gui-apps-about-path-environment-variable/. Three viable strategies: (1) **bundle everything and never look up PATH** — fazm spawns every Node child with `process.execPath` and every binary by absolute path (`index.ts:1585`, `2517`, `2557`); (2) resolve the user's shell PATH once via `$SHELL -ilc 'echo $PATH'` (what fix-path/shell-env do; what Claude Desktop itself does: "reads your shell profile… to extract PATH" https://code.claude.com/docs/en/desktop); (3) a hard-coded ladder `/opt/homebrew/bin`, `/usr/local/bin`, `~/.nvm/versions/node/*`, `/usr/bin/which` (fazm `ACPBridge.swift:2537-2590`, which also does **no** login shell). Also note LaunchServices may hand you `/private/var/folders/...` as cwd — fazm pins `currentDirectoryURL` to `$HOME` (`ACPBridge.swift:588-597`). Zed sidesteps the problem by downloading its own Node **[issue]** https://github.com/zed-industries/zed/issues/12631.
**E.5 Bundling the runtimes.** Claude: `@agentclientprotocol/claude-agent-acp` requires **Node ≥ 22** (npm `engines`); the SDK "bundle[s] a native Claude Code binary… pinned to the SDK package version" (a Bun-compiled executable per tweakcc), dropped by `npm ci --omit=optional`, overridable with `CLAUDE_CODE_EXECUTABLE` / `pathToClaudeCodeExecutable` **[docs]** https://code.claude.com/docs/en/agent-sdk/hosting, quickstart; Emdash points the adapter at the host's `claude` so the user keeps one login. Codex: a Rust binary shipped in `@openai/codex-darwin-arm64` at `vendor/<triple>/bin/codex`, or run `codex app-server` from the user's install **[docs]** https://registry.npmjs.org/@openai/codex/latest, `sdk/typescript/src/exec.ts`. Gemini: pure Node (`bundle/gemini.js`, Node ≥ 20) **[docs]** npm. fazm ships Node v22.14.0 in `Contents/Resources/Fazm_Fazm.bundle/node` (`build.sh:41-69`) plus the bridge's full `node_modules` (`run.sh:296-306`) — hundreds of MB, and every Mach-O inside must be signed (E.2). Alternative not researched: Node SEA / bun-compiled bridge **[not-found]**.
**E.6 PTY vs pipes, per CLI.** Claude interactive needs a TTY; `-p`, the SDK and the ACP adapter run over plain pipes (`stdio: pipe`, fazm and Emdash). Codex: `codex app-server` is TTY-free by design; `codex exec` hangs at 0 % CPU when stdin is an inherited-but-never-closed pipe (fix: `< /dev/null` or close stdin) **[issue]** https://github.com/openai/codex/issues/20919 and can exit 0 with empty stdout when detached from a controlling TTY **[issue]** https://github.com/openai/codex/issues/19945. Gemini: `setRawMode(true)` only "if `config.isInteractive() && process.stdin.isTTY`", non-TTY stdin is read to EOF **[docs]** `packages/cli/src/gemini.tsx`; `--acp` with sandbox on and non-TTY stdin never starts **[issue]** https://github.com/google-gemini/gemini-cli/issues/23959. If you want a real terminal (E.10), SwiftTerm's `LocalProcess` "uses forkpty for pseudo-terminal support" with `startProcess(executable:args:environment:execName:currentDirectory:)` and a `killEscalationDelay` **[docs]** https://raw.githubusercontent.com/migueldeicaza/SwiftTerm/main/Sources/SwiftTerm/LocalProcess.swift (lines 183-212, 485). node-pty's darwin `spawn-helper` must be executable and signed (Emdash note, D).
**E.7 ANSI / TERM / colour.** Protocol channels (ACP stdout, app-server stdout, stream-json) are clean JSON; **stderr is not** — codex-acp's real error text arrives ANSI-coloured on stderr and fazm strips `\x1b\[[0-9;]*m` before surfacing it (`codex-provider.ts:259-274`). Set `NO_COLOR=1` for Gemini ("Set to any value to disable all color output") **[docs]** configuration.md; `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` also skips the background title-generation model call in SDK/`-p` sessions **[docs]** env-vars. fazm's Swift side does no ANSI stripping at all (A.11) — tool output with escapes reaches the UI.
**E.8 Process lifecycle: what actually kills the tree.**
- `detached:true` + `kill(-pid)` reaches only the direct child's group; MCP servers and the `claude` binary create their own groups and survive — fazm's Swift `killProcessTree` walks `pgrep -P` depth-first and SIGTERMs bottom-up (`ACPBridge.swift:700-753`); both bridge and adapter run a **PPID watchdog** (every 5 s, exit when PPID flips to 1) because "20+ orphan ACP bridges" were observed (`index.ts:110-135`, `patched-acp-entry.mjs:19-51`); `sweepOrphanedBridges()` on every start found "14 of 20 swept orphans survived SIGTERM and only died on SIGKILL" because the CLI's SIGTERM handler tries to flush IPC to a dead parent (`ACPBridge.swift:841-846`). macOS has no `prctl(PR_SET_PDEATHSIG)`; the PPID poll or a pipe-closure watchdog (`ws-relay.ts:76-84` uses `kill(ppid, 0)`) is the substitute.
- **Read the pipe before `waitUntilExit`** or a >16 KB write deadlocks the child and your actor (`ACPBridge.swift:779-782`).
- Treat EPIPE on stdout/stderr as "parent gone → exit 0" (`index.ts:6317-6350`); resume pending Swift continuations in `deinit` to avoid "SWIFT TASK CONTINUATION MISUSE" (`ACPBridge.swift:538-545`); use a generation counter so a stale `terminationHandler` can't clobber a restarted process (`ACPBridge.swift:654-665`).
- **Never SIGTERM mid-tool-call**: the in-flight `tool_use` never gets a `tool_result`, "the Anthropic API parked waiting on that tool_use_id … the session was unrecoverable"; fazm added a SIGHUP drain-then-exit path (`index.ts:6278-6316`). SDK docs: SIGTERM → exit 143 with the turn unfinished; `interrupt()`/SIGINT ends cleanly (C.1.b). ACP `session/cancel` and Codex `turn/interrupt` are cooperative — a wedged browser tool needs SIGKILL of the MCP child (`index.ts:143-198`).
- Node heap: fazm caps the bridge at `--max-old-space-size=256` and screen-scrapes stderr for `FatalProcessOutOfMemory` / exit codes 133/134/5/6 (`ACPBridge.swift:588`, `:2298-2303`).
**E.9 Credentials and env hygiene.**
- Claude stores the login "in the encrypted macOS Keychain. When the Keychain rejects the write… `~/.claude/.credentials.json` with file mode 0600"; `CLAUDE_CONFIG_DIR` "keys the macOS Keychain entry to that directory too" **[docs]** https://code.claude.com/docs/en/authentication. The generic-password item is `Claude Code-credentials` (fazm `oauth-flow.ts:31`; read via `/usr/bin/security find-generic-password -s "Claude Code-credentials" -w`, `ChatProvider.swift:2573-2578`). Whether a differently-signed app reading that item triggers a Keychain ACL prompt: **[not-found]** in docs — treat as a risk and prefer letting the `claude` binary own its token.
- Precedence surprise: in `-p`/SDK mode `ANTHROPIC_API_KEY` "is always used when present" and silently overrides the subscription **[docs]** env-vars — fazm removes it from the child env in personal mode (`ACPBridge.swift:2367-2369`) and blanks it for third-party MCP children ("no API key handed to subprocesses", `index.ts:2643-2648`). The SDK's `env` option **replaces** the environment (spread `process.env` yourself) **[docs]** typescript reference.
- Delete `CLAUDECODE` from the child env or a nested launch refuses to start / `--resume` silently fails (`index.ts:1562-1565`; **[docs]** env-vars; **[issue]** https://github.com/anthropics/claude-code/issues/25803). `CLAUDE_CODE_CHILD_SESSION`-marked children are excluded from `--resume`/`--continue` unless `CLAUDE_CODE_FORCE_SESSION_PERSISTENCE=1` **[docs]** env-vars.
- Codex: `~/.codex/auth.json` ("treat like a password") or keyring; `CODEX_HOME`; app-server `account/login/start` runs the browser flow for you **[docs]** https://learn.chatgpt.com/docs/auth, app-server README. Gemini: `GEMINI_API_KEY`; OAuth-personal needs an interactive browser flow that "is hostile to a background subprocess" (`gemini-provider.ts:84-86`; **[issue]** #12042).
- Disable phone-home in embedded CLIs: `DISABLE_AUTOUPDATER=1`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`, `DISABLE_TELEMETRY=1` (Claude; note "any non-empty value including `0` turns the behavior on") **[docs]** env-vars; `general.enableAutoUpdate=false`, `privacy.usageStatisticsEnabled=false`, `GEMINI_TELEMETRY_ENABLED=false` (Gemini) **[docs]** configuration.md; Codex `[history] persistence`, `notify`, `hide_agent_reasoning` **[docs]** config reference.
**E.10 Trust dialogs and first-run prompts block headless children.** Claude asks "trust this folder?" on first use of a cwd — Emdash pre-seeds `~/.claude.json` `projects[<path>].hasTrustDialogAccepted=true` (D); Gemini silently skips MCP registration for untrusted folders unless `GEMINI_CLI_TRUST_WORKSPACE=true` (`gemini-provider.ts:196-203`, **[docs]** configuration.md); Codex `thread/start` with a writable sandbox marks the project trusted in `config.toml` and `codex exec` refuses non-git dirs without `--skip-git-repo-check` (C.2). Claude's `--bare`/`CLAUDE_CODE_SIMPLE=1` skips hooks/skills/MCP discovery and the Keychain — good for cheap background jobs, wrong for the user's main session **[docs]** env-vars.
**E.11 Sessions, resume and crash recovery.** Transcript locations: Claude `~/.claude/projects/<cwd, non-alnum→'-'>/<id>.jsonl` (200-char truncation + hash) **[docs]** https://code.claude.com/docs/en/sessions; Codex `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl` (`index.ts:3323-3345`; SDK README "Threads are persisted in `~/.codex/sessions`"); Gemini `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` **[docs]** session-management.md. Gotchas fazm paid for: resume is cwd-addressed (fixed upstream in CLI ≥ 2.1.223 cross-project search), "phantom" ids that never wrote a turn, resuming a cancelled session replays stale chunks (ACP #442, fixed in adapter 0.29.2), a mid-thinking cancel leaves an unsigned thinking block that 400s on resume (`index.ts:5076-5090`), `session/prompt` can never resolve (#630) so race it against an idle timer, and a hung MCP spawn hangs `session/new` for the whole warmup (`index.ts:3203-3222`). Bank the session id **before** the first prompt (`session_started`, `protocol.ts:560-580`) so a rate-limit on turn 1 doesn't orphan the conversation.
**E.12 Concurrency and rate limits.** "One agent session maps to one subprocess"; budget "1 GiB RAM, 5 GiB disk, and 1 CPU per agent" **[docs]** hosting page. Subscription limits are 5-hour and 7-day windows, surfaced as `rate_limit_event {rateLimitType: five_hour|seven_day, utilization, resetsAt}` (SDK; `protocol.ts:372-383`), and Codex `account/rateLimits/updated`. A credit-exhausted turn poisoned *other* sessions on the same adapter process (0 ms `end_turn`), so fazm restarts the whole adapter with a 30 s cooldown (`index.ts:1930-2000`). Per-tenant isolation: `CLAUDE_CONFIG_DIR`, `settingSources: []`, `CLAUDE_CODE_DISABLE_AUTO_MEMORY=1` **[docs]** hosting page. Anthropic's separate "Agent SDK credits" for `-p`/SDK/third-party apps were announced then **paused on 2026-06-15** ("nothing has changed") **[docs]** https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan — but the legal page still forbids intermediating claude.ai credentials (C.1.c).
**E.13 Terminal-in-app.** Either advertise ACP `terminal:true` and implement `terminal/create|output|wait_for_exit|kill|release` yourself (then every shell command the agent runs is a PTY you own and can render in SwiftTerm, streamed via `{"type":"terminal","terminalId"}`), or advertise `false` and render `tool_call_update.content` text. Advertising `fs` capabilities means the agent will ask *you* for file contents (intended for unsaved editor buffers) — say `false` unless you are an editor (B.9; fazm sets both false, `codex-provider.ts:219-222`).
**E.14 More verified gotchas (prior-art lane, primary sources).**
- **Foundation `Process` spawns with `POSIX_SPAWN_SETPGROUP|POSIX_SPAWN_CLOEXEC_DEFAULT` and `terminate()` = `kill(pid, SIGTERM)` on the direct child only** **[docs]** https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/Process.swift, https://developer.apple.com/documentation/foundation/process/terminate(). For a real tree kill from Swift use `posix_spawn` yourself with `POSIX_SPAWN_SETSID` (`0x0400`, in xnu `spawn.h`, not the man page) and `killpg` **[docs]** https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/spawn.h, https://keith.github.io/xcode-man-pages/posix_spawnattr_setflags.3.html — or copy Zed (`setsid` in `pre_exec`, `killpg(SIGKILL)`). Orphan detection without `prctl`: `kqueue EVFILT_PROC NOTE_EXIT` on the parent pid (what Bun's `--no-orphans` does on macOS) **[docs]** https://keith.github.io/xcode-man-pages/kqueue.2.html, **[issue]** https://github.com/oven-sh/bun/pull/29930.
- **Claude-specific signal behaviour [issue]:** SIGTERM/SIGINT can orphan Bash-tool children #29096; a Bash-tool timeout SIGTERM once killed Claude itself via the shared process group #45717 (closed not-planned); hangs ignoring SIGINT/SIGTERM #19900/#25442 — https://github.com/anthropics/claude-code/issues/{29096,45717,19900,25442}. Codex leaves orphans after exit #15379 and an interrupted child keeps running #27758 https://github.com/openai/codex/issues/{15379,27758}. Gemini re-execs itself (`relaunchAppInChildProcess`) and the bootstrap parent doesn't forward termination → child reparented to PID 1 holding the OAuth session **[issue]** https://github.com/google-gemini/gemini-cli/issues/25590 (fazm sets `GEMINI_CLI_NO_RELAUNCH`-adjacent env; see `gemini.tsx`).
- **`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`** strips Anthropic/cloud credentials from Bash/hook/MCP children — useful for a host that injects the user's tools; a 2.1.251 regression also stripped `CLAUDE_CONFIG_DIR` **[docs]** env-vars, **[issue]** https://github.com/anthropics/claude-code/issues/91020. Interactive `claude` with piped stdin dies with `Error: Raw mode is not supported on the current process.stdin, which Ink uses` **[issue]** https://github.com/anthropics/claude-code/issues/5925. Codex TUI refuses `TERM=dumb` / non-terminal stdin outright (`codex-rs/tui/src/tui.rs`).
- **node-pty on macOS is `posix_spawn` + a `spawn-helper` binary, not `forkpty`** (helper acquires the controlling TTY then `execvp`; exit via `kqueue NOTE_EXIT`) because hardened-runtime `fork` cost ~300 ms/spawn; prebuilt `spawn-helper` shipped without `+x` → `posix_spawnp failed.`; `app.asar` → `app.asar.unpacked` path rewrite bugs **[docs]** https://github.com/microsoft/node-pty/blob/main/src/unix/pty.cc, `spawn-helper.cc`, **[issue]** https://github.com/microsoft/node-pty/issues/{476,923,850,919,863,950}. If you go Swift-native, SwiftTerm's `forkpty` path avoids all of that — but its default env sets `TERM=xterm-256color`, `COLORTERM=truecolor`, `LANG=en_US.UTF-8` and **deliberately omits PATH**, so always pass your own `environment:` **[docs]** https://github.com/migueldeicaza/SwiftTerm/blob/main/Sources/SwiftTerm/Terminal.swift.
- **Keychain ACLs bite a differently-signed reader.** Claude's `Claude Code-credentials` item carries the partition list `apple-tool:` only, so a Swift app reading it via SecItem gets the "wants to use your confidential information" prompt, and "Always Allow" is reset every time Claude rewrites the item on token refresh (CodexBar #624/#458) **[docs]** https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains, https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md, **[issue]** https://github.com/steipete/CodexBar/issues/{624,458}. Historical Claude bug: setting `CLAUDE_CODE_OAUTH_TOKEN` deleted the Keychain item on exit **[issue]** https://github.com/anthropics/claude-code/issues/37512. Rule: let the `claude` binary own its credential; never read it from Swift.
- **Concurrency corrupts `~/.claude.json`.** Multiple concurrent instances race on the config file ("JSON Parse error: Unexpected EOF", backups in `~/.claude/backups/`; reported 8+ times, no documented lock) and race on OAuth refresh across many processes → spurious `/login` prompts **[issue]** https://github.com/anthropics/claude-code/issues/{28847,28922,3117,2593,24317,54443}. Mitigation: one `CLAUDE_CONFIG_DIR` per hosted session (plus `CLAUDE_CODE_PROJECT_DIR_NAME`, v2.1.234+: "This suits a host that embeds Claude Code and gives each session its own config directory") **[docs]** https://code.claude.com/docs/en/sessions. Resuming the same session in two processes without forking interleaves messages (same page).
- **PATH recovery, done by the pros:** Zed runs `<shell> -l -i -c 'cd $HOME; <zed> --printenv >&0'` under `setsid` and parses JSON out of the noisy rc output (fd 0 because `>2` "can't be used in interactive zsh/old bash"); fails on `.bashrc` that `exec fish` #35759 **[docs]** https://github.com/zed-industries/zed/blob/main/crates/util/src/shell_env.rs. VS Code: `$SHELL -i -l -c "'<execPath>' -p '…JSON.stringify(process.env)…'"` with a 10 s timeout (`application.shellEnvironmentResolutionTimeout`) **[docs]** https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts. `launchctl config user path <value>` is the only launchd-level knob and is "intentionally scoped to the PATH environment variable" **[docs]** https://keith.github.io/xcode-man-pages/launchctl.1.html.
- **The npm `claude` shim is the wrong binary to spawn from a GUI**: its shebang `#!/usr/bin/env -S node --no-warnings --enable-source-maps` fails without node on PATH (opcode #94); the native install (`curl -fsSL https://claude.ai/install.sh | bash`, `~/.local/bin/claude` → `~/.local/share/claude/versions/`, a Bun single-file executable "signed by 'Anthropic PBC' and notarized by Apple") and the npm `@anthropic-ai/claude-code` package both resolve to that native binary now **[docs]** https://code.claude.com/docs/en/setup, https://claude.ai/install.sh, CHANGELOG 2.1.181.
- **Sandboxed-app helper rule, verbatim:** "If your macOS app embeds a command-line tool, that tool must inherit the containing app's sandbox configuration"; helper signed with exactly `com.apple.security.app-sandbox` + `com.apple.security.inherit` ("Adding other entitlements to the tool can cause problems") **[docs]** https://developer.apple.com/documentation/xcode/embedding-a-helper-tool-in-a-sandboxed-app. Electron's `@electron/osx-sign` default entitlement is `allow-jit` only; electron-builder's template adds `allow-unsigned-executable-memory` + `disable-library-validation`; `@electron/notarize` says Electron ≥ 12 "should not" need unsigned-executable-memory **[docs]** https://github.com/electron/osx-sign/blob/main/entitlements/default.darwin.plist, https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/templates/entitlements.mac.plist, https://github.com/electron/notarize. Bun-compiled binaries need the JIT entitlements too **[docs]** https://bun.com/docs/bundler/executables.
- **Codex keyring detail:** `cli_auth_credentials_store = file|keyring|auto`; keyring service `"Codex Auth"`, account `cli|<sha256(CODEX_HOME)[:16]>` **[docs]** https://github.com/openai/codex/blob/main/codex-rs/login/src/auth/storage.rs. Gemini optional keychain `gemini-cli-oauth` when `GEMINI_FORCE_ENCRYPTED_FILE_STORAGE=true`; `NO_BROWSER=true`; a stray `GOOGLE_CLOUD_PROJECT` forces an org-subscription check **[docs]** https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/authentication.mdx, `packages/core/src/config/storage.ts`.
- **Terminal embedding alternatives:** libghostty is "not stable… not yet stabilized for general-purpose embedding" (only `libghostty-vt` public alpha) **[docs]** https://ghostty-org-ghostty.mintlify.app/api/overview; SwiftTerm issues to know: `processTerminated` before final data (#370 → `drainTimeout`), macOS 15 `Subprocess` path (#472) **[issue]** https://github.com/migueldeicaza/SwiftTerm/issues/{370,472,534}.
---
## Section F — Top 25 things AI gets wrong about hosting agent CLIs
1. "Just spawn `claude` and parse its output" — the interactive TUI needs a PTY and emits Ink frames, not events; the machine surface is `-p --output-format stream-json`, the SDK, or the ACP adapter **[docs]** https://code.claude.com/docs/en/headless.
2. "`claude -p` is a different engine from the SDK" — the SDK "spawns a separate `claude` CLI process and talks to it over stdio"; they are the same binary **[docs]** https://code.claude.com/docs/en/agent-sdk/hosting.
3. "The ACP adapter wraps the raw CLI" — it wraps the Claude Agent SDK, which then spawns the CLI: three processes deep **[docs]** https://github.com/agentclientprotocol/claude-agent-acp.
4. "ACP `session/prompt` returns token usage" — v1 returns only `stopReason`; per-turn usage is a draft RFD, adapters stuff it into `_meta` **[docs]** https://agentclientprotocol.com/rfds/end-turn-token-usage.
5. "`session/set_model` is an ACP method" — it is not in the spec; model choice is a `session/set_config_option` category (Gemini calls it `unstable_setSessionModel`) **[docs]** https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/main/schema/v1/schema.json.
6. "ACP `env` for MCP servers is an object" — it is an array of `{name,value}`, and `command` must be absolute **[docs]** https://agentclientprotocol.com/protocol/v1/session-setup.
7. "Permission option ids are `allow`/`deny`" — ids are agent-defined; kinds are `allow_once|allow_always|reject_once|reject_always`, and codex-acp fails closed on unknown ids (fazm's literal `"allow"` fallback is wrong) **[docs]** https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/docs/permission-extension.md.
8. "`session/cancel` kills the session" — it ends the turn; the session survives and must be reused, and the prompt must still return `stopReason:"cancelled"` **[docs]** https://agentclientprotocol.com/protocol/v1/prompt-turn.
9. "Cancel = reject" — in the Claude permission extension a cancelled request is distinct from a rejection and the client must answer pending permission requests with `cancelled` on turn cancel **[docs]** tool-calls page.
10. "Images go in as Anthropic `source:{...}` blocks" — ACP image blocks are flat `{type:"image", data, mimeType}` and there is no PDF block at all (`index.ts:4489-4503`).
11. "`codex exec` will prompt for approvals" — exec forces `approval_policy: Never` and auto-rejects every approval request; use `codex app-server` (or the ACP adapter) for interactive approvals **[docs]** https://raw.githubusercontent.com/openai/codex/main/codex-rs/exec/src/lib.rs.
12. "Codex app-server messages are normal JSON-RPC" — the `"jsonrpc":"2.0"` header is omitted on the wire **[docs]** https://raw.githubusercontent.com/openai/codex/main/codex-rs/app-server/README.md.
13. "Use `@zed-industries/codex-acp` / `@zed-industries/claude-code-acp`" — both are archived; the live packages are `@agentclientprotocol/codex-acp` 1.8.0 and `@agentclientprotocol/claude-agent-acp` 0.73.0 **[docs]** https://github.com/zed-industries/codex-acp, npm.
14. "`gemini --experimental-acp`" — deprecated in favour of `gemini --acp`; and it refuses to start with sandbox on and a non-TTY stdin **[docs]** `packages/cli/src/config/config.ts`, **[issue]** https://github.com/google-gemini/gemini-cli/issues/23959.
15. "Gemini will just use my API key" — in ACP you must send `authenticate {methodId:"gemini-api-key"}` (or set `security.auth.selectedType`) or `session/new` fails with `-32000 "Authentication required."` **[docs]** `packages/cli/src/acp/acpSessionManager.ts`.
16. "MCP servers register regardless of folder" — Gemini silently skips MCP for untrusted folders unless `GEMINI_CLI_TRUST_WORKSPACE=true`; Claude blocks on a trust dialog unless `hasTrustDialogAccepted` is pre-seeded (`gemini-provider.ts:196-203`; Emdash `trust.ts`).
17. "`kill(-pgid)` cleans up the tree" — grandchildren (MCP servers, the `claude` binary) start their own process groups; walk `pgrep -P` and poll PPID for orphan detection (`ACPBridge.swift:700-753`, `index.ts:110-135`).
18. "SIGTERM is a clean stop" — mid-tool SIGTERM leaves a `tool_use` without a `tool_result` and the API parks on it forever; drain first (`index.ts:6278-6316`); the SDK exits 143 with the turn unfinished **[docs]** typescript reference.
19. "Read the exit status, then the output" — reading a `Pipe` after `waitUntilExit` deadlocks on >16 KB of output (`ACPBridge.swift:779-782`).
20. "GUI apps inherit the user's PATH" — they get `/usr/bin:/bin:/usr/sbin:/sbin`; bundle absolute paths or resolve via a login shell **[blog]** https://github.com/sindresorhus/fix-path; even Claude Desktop reads your shell profile to recover PATH **[docs]** https://code.claude.com/docs/en/desktop.
21. "A sandboxed app can spawn the user's CLIs" — children inherit the sandbox and must be signed with exactly `app-sandbox` + `inherit`; real hosts ship `app-sandbox=false` with Developer ID **[docs]** https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html.
22. "Sign the .app and you're done" — every embedded Mach-O (node, `*.node`, dylibs, `rg`) needs `--options runtime --timestamp`; node needs `allow-jit`; missing any one fails notarization **[docs]** https://developer.apple.com/documentation/security/resolving-common-notarization-issues.
23. "`codesign --verify` passing means it will run" — on macOS 26 the Code Signing Monitor SIGKILLs a binary whose seal Sparkle corrupted even though verify passes; copy the runtime out of the bundle before launch (`NodeBinaryHelper.swift:3-13`).
24. "Setting `ANTHROPIC_API_KEY` alongside the user's login is harmless" — in `-p`/SDK mode the key "is always used when present" and silently bills the API instead of the subscription **[docs]** https://code.claude.com/docs/en/env-vars.
25. "We can run our own OAuth against claude.ai and store the token" — Anthropic's legal page says developers "may not collect, store, or intermediate Claude.ai credentials or session tokens"; the sanctioned path is the unmodified binary logging itself in (terminal-auth method), and the June 2026 SDK-credit split is paused, not cancelled **[docs]** https://code.claude.com/docs/en/legal-and-compliance, https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan.
---
## Sources
**Local (read-only):** `/Users/robertboulos/projects/fazm/acp-bridge/{package.json, package-lock.json, tsconfig.json, src/index.ts, src/protocol.ts, src/patched-acp-entry.mjs, src/acp-translate.ts, src/fazm-tools-stdio.ts, src/fazm-tools-http.ts, src/codex-provider.ts, src/codex-query.ts, src/codex-oauth-flow.ts, src/gemini-provider.ts, src/gemini-query.ts, src/oauth-flow.ts, src/api-failure.ts, src/ws-relay.ts, src/cron-runner.mjs, scripts/patch-playwright-overlay.cjs}`; `/Users/robertboulos/projects/fazm/Desktop/Sources/{Chat/ACPBridge.swift, Chat/NodeBinaryHelper.swift, Providers/ChatProvider.swift, Providers/ChatToolExecutor.swift, BundleExtension.swift, AuthService.swift, FazmApp.swift, FloatingControlBar/ShortcutSettings.swift, MainWindow/Components/ChatUIComponents.swift, Chat/CustomAPIEndpointCredentials.swift}`; `/Users/robertboulos/projects/fazm/Desktop/{Fazm.entitlements, Fazm-Release.entitlements, Node.entitlements, Python.entitlements}`; `/Users/robertboulos/projects/fazm/{run.sh, build.sh, codemagic.yaml, AGENTS.md, CLAUDE.md, README.md}`. Upstream: https://raw.githubusercontent.com/mediar-ai/fazm/main/acp-bridge/src/{approval-gate.ts,index.ts} (commit d3816032).
**ACP:** https://agentclientprotocol.com/llms.txt · /overview/introduction · /protocol/overview · /protocol/v1/transports · /protocol/initialization · /protocol/v1/authentication · /protocol/v1/session-setup · /protocol/v1/session-list · /protocol/v1/prompt-turn · /protocol/content · /protocol/v1/tool-calls · /protocol/agent-plan · /protocol/slash-commands · /protocol/session-modes · /protocol/v1/session-config-options · /protocol/file-system · /protocol/terminals · /protocol/v1/elicitation · /protocol/v1/cancellation · /protocol/extensibility · /rfds/session-fork · /rfds/end-turn-token-usage · /rfds/session-usage · /announcements/acp-v2-draft · /libraries/typescript · /libraries/rust · /overview/agents · /overview/clients · https://github.com/agentclientprotocol/agent-client-protocol · https://raw.githubusercontent.com/agentclientprotocol/agent-client-protocol/main/schema/v1/schema.json · https://github.com/agentclientprotocol/typescript-sdk/blob/main/src/examples/client.ts · https://github.com/agentclientprotocol/registry/blob/main/FORMAT.md · https://raw.githubusercontent.com/agentclientprotocol/registry/main/{claude-acp,codex-acp,gemini}/agent.json · https://github.com/newioapp/acp-inspector · npm registry views of `@agentclientprotocol/sdk`, `@zed-industries/agent-client-protocol`.
**Claude:** https://github.com/agentclientprotocol/claude-agent-acp (README, src/index.ts, src/acp-agent.ts, src/session-mode.ts, docs/permission-extension.md, docs/model-configuration.md, CHANGELOG) · issues #146 #337 #338 #363 #421 #744 #880 · https://code.claude.com/docs/llms.txt · /en/agent-sdk/overview · /quickstart · /typescript · /hosting · /permissions · /user-input · /sessions · /streaming-output · /cost-tracking · /modifying-system-prompts · /en/headless · /en/cli-reference · /en/sessions · /en/authentication · /en/env-vars · /en/legal-and-compliance · /en/desktop · https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan · https://zed.dev/blog/anthropic-subscription-changes · https://github.com/anthropics/claude-code/issues/25803 · npm `@anthropic-ai/claude-agent-sdk` 0.3.258 `sdk.d.ts`, `@anthropic-ai/claude-code` 2.1.258 · https://lobehub.com/mcp/user-claude-code-permission-prompt-tool (third-party) · https://github.com/Piebald-AI/tweakcc.
**Codex:** https://learn.chatgpt.com/docs/{non-interactive-mode, app-server, codex-sdk, auth, sandboxing, agent-approvals-security, config-file/config-reference, llms-full.txt} · https://developers.openai.com/codex/noninteractive (redirect) · https://raw.githubusercontent.com/openai/codex/main/codex-rs/{app-server/README.md, docs/codex_mcp_interface.md, exec/src/cli.rs, exec/src/lib.rs, exec/src/exec_events.rs, cli/src/main.rs, core/README.md, config/src/types.rs} · https://raw.githubusercontent.com/openai/codex/main/sdk/typescript/{README.md, src/codexOptions.ts, src/threadOptions.ts, src/events.ts, src/exec.ts} · https://registry.npmjs.org/@openai/{codex,codex-sdx}/latest · issues #20919 #19945 #18578 #39970 #7144 #23695 · https://github.com/zed-industries/codex-acp · https://raw.githubusercontent.com/agentclientprotocol/codex-acp/main/{README.md, readme-dev.md, docs/permission-extension.md} · https://www.infoq.com/news/2026/02/opanai-codex-app-server/ · https://codex.danielvaughan.com/2026/04/15/codex-app-server-complete-guide/ · https://github.com/openabdev/openab/issues/1352.
**Gemini:** https://raw.githubusercontent.com/google-gemini/gemini-cli/main/docs/{cli/acp-mode.md, cli/headless.md, cli/cli-reference.md, reference/configuration.md, cli/session-management.md, cli/checkpointing.md, ide-integration/index.md} · https://geminicli.com/docs/cli/acp-mode/ · packages/cli/src/{config/config.ts, gemini.tsx, acp/README.md, acp/acpStdioTransport.ts, acp/acpRpcDispatcher.ts, acp/acpSessionManager.ts, acp/acpSession.ts, acp/acpUtils.ts, utils/errors.ts} · packages/core/src/{output/types.ts, services/chatRecordingTypes.ts} · issues #7880 #10855 #12042 #17952 #15502 #16504 #23959 #24916 #13924 #27466 #24280 · PRs #23673 #23680 #23818 · https://registry.npmjs.org/@google/gemini-cli/latest.
**Hosts:** https://raw.githubusercontent.com/zed-industries/zed/main/docs/src/ai/external-agents.md · https://zed.dev/docs/configuring-zed · https://zed.dev/blog/bring-your-own-agent-to-zed · https://github.com/zed-industries/zed/issues/{12631,46162,55283} · https://conductor.build/ · https://conductor.build/docs · https://conductor.build/blog/claude-subscription-update · https://www.conductor.build/docs/installation · https://performance.dev/the-conductor-rewrite · https://github.com/coder/mux · https://xum.coder.com/integrations/acp.md · https://github.com/generalaction/emdash (README; packages/plugins/src/agents/impl/claude/{index,adapter,trust,hooks}.ts; agents/architecture/acp-runtime.md; apps/emdash-desktop/electron-builder.config.ts; build/entitlements.mac.plist) · https://emdash.com/changelog · https://www.superconductor.com/ · https://www.superconductor.com/docs/agents · https://www.superconductor.com/blog/why-we-built-superconductor · https://x.com/superdoteng/status/2042335263154978868 · https://piebald.ai/ · https://docs.piebald.ai/{introduction, llms.txt, changelog.md, providers/claude-max.md, features/agentic/claude-code-hooks-compatibility.md} · https://github.com/Jamie-BitFlight/claude_skills/blob/main/research/developer-tools/piebald.md · https://github.com/getAsterisk/claudia · https://raw.githubusercontent.com/humanlayer/humanlayer/main/claudecode-go/README.md · https://github.com/BloopAI/vibe-kanban · https://github.com/stravu/crystal · https://fazm.ai/cc · https://github.com/mediar-ai/fazm · https://github.com/{defyus/mosaic, cristicretu/diri, manaflow-ai/cmux}.
**Hosts (lane-verified, additional):** https://github.com/zed-industries/zed/blob/main/crates/{agent_servers/src/acp.rs, agent_servers/src/agent_servers.rs, project/src/agent_server_store.rs, util/src/process.rs, util/src/shell_env.rs, node_runtime/src/node_runtime.rs, acp_thread/src/acp_thread.rs} · https://zed.dev/blog/claude-code-via-acp · https://zed.dev/docs/ai/{agent-panel, tool-permissions, terminal-threads, parallel-agents} · https://github.com/zed-industries/zed/issues/{61303,46474,56747,45241,43675,53309,38988,46551,55501,62828,62788,62716,51597,47910,35759,39506} · https://registerspill.thorstenball.com/p/how-to-lose-control-of-your-shell · https://github.com/BloopAI/vibe-kanban/blob/main/crates/{executors/src/executors/claude.rs, executors/src/executors/claude/protocol.rs, executors/src/executors/gemini.rs, executors/src/executors/codex.rs, utils/src/shell.rs} · https://github.com/BloopAI/vibe-kanban/issues/3205 · https://github.com/humanlayer/humanlayer/blob/main/{claudecode-go/client.go, hld/PROTOCOL.md, hld/session/manager.go, humanlayer-wui/src-tauri/src/daemon.rs, DEVELOPMENT.md} · https://github.com/stravu/crystal/blob/main/main/src/{services/panels/cli/AbstractCliManager.ts, utils/shellPath.ts} · https://docs.nimbalyst.com/open-safe-private-secure/permissions-and-safety.md · https://github.com/winfunc/opcode/blob/main/src-tauri/src/commands/{claude.rs, usage.rs} · https://github.com/winfunc/opcode/issues/{94,58} · https://github.com/slopus/happy (packages/happy-cli/README.md, src/claude/claudeLocal.ts) · https://github.com/slopus/happy/issues/{80,1202} · https://github.com/cline/cline/blob/v3.20.0/src/integrations/claude-code/run.ts · https://github.com/Kilo-Org/kilocode-legacy/blob/main/docs/legacy-ides/ai-providers/claude-code.md · https://zed.dev/acp/agent/kilo · https://code.claude.com/docs/en/{desktop-quickstart, vs-code, setup, iam, costs, errors, worktrees} · https://claude.ai/install.sh · https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md.
**macOS / lifecycle (lane-verified, additional):** https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/Foundation/Process.swift · https://developer.apple.com/documentation/foundation/process/{terminate(),interrupt()} · https://keith.github.io/xcode-man-pages/{kill.2, posix_spawnattr_setflags.3, kqueue.2, launchctl.1, path_helper.8, xcode-select.1}.html · https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/spawn.h · https://man7.org/linux/man-pages/man2/pr_set_pdeathsig.2const.html · https://github.com/oven-sh/bun/pull/29930 · https://nodejs.org/api/child_process.html · https://github.com/pkrumins/node-tree-kill · https://developer.apple.com/documentation/xcode/embedding-a-helper-tool-in-a-sandboxed-app · https://developer.apple.com/documentation/security/{protecting-user-data-with-app-sandbox, hardened-runtime, notarizing-macos-software-before-distribution} · https://www.electronjs.org/docs/latest/tutorial/mac-app-store-submission-guide · https://github.com/electron/osx-sign/blob/main/entitlements/default.darwin.plist · https://github.com/electron-userland/electron-builder/blob/master/packages/app-builder-lib/templates/entitlements.mac.plist · https://github.com/electron/notarize · https://v2.tauri.app/{develop/sidecar/, distribute/sign/macos/} · https://github.com/tauri-apps/tauri/issues/11992 · https://bun.com/docs/bundler/executables · https://nodejs.org/api/single-executable-applications.html · https://github.com/microsoft/node-pty/blob/main/src/unix/{pty.cc, spawn-helper.cc} · https://github.com/microsoft/node-pty/issues/{476,923,850,919,863,950} · https://github.com/chalk/{strip-ansi, ansi-regex, supports-color} · https://no-color.org/ · https://github.com/microsoft/vscode/blob/main/src/vs/platform/shell/node/shellEnv.ts · https://github.com/sindresorhus/shell-env · https://developer.apple.com/documentation/technotes/tn3137-on-mac-keychains · https://github.com/steipete/CodexBar/blob/main/docs/keychain-prompts.md · https://github.com/steipete/CodexBar/issues/{624,458} · https://github.com/anthropics/claude-code/issues/{5925,48375,59585,91020,29096,45717,19900,25442,37512,70697,20553,1757,5515,24317,54443,28847,28922,3117,2593} · https://github.com/openai/codex/issues/{15379,27758} · https://github.com/openai/codex/blob/main/codex-rs/{tui/src/tui.rs, login/src/auth/storage.rs, rollout/src/rollout_file_name.rs} · https://github.com/google-gemini/gemini-cli/issues/{25590,27290,25583} · https://github.com/google-gemini/gemini-cli/blob/main/{docs/get-started/authentication.mdx, packages/core/src/config/storage.ts} · https://github.com/migueldeicaza/SwiftTerm/blob/main/Sources/SwiftTerm/{Terminal.swift, Pty.swift, Mac/MacLocalTerminalView.swift} · https://github.com/migueldeicaza/SwiftTerm/issues/{370,472,534} · https://ghostty-org-ghostty.mintlify.app/api/overview · https://support.claude.com/en/articles/{11049741-what-is-the-max-plan, 11145838-using-claude-code-with-your-pro-or-max-plan}.
**macOS:** https://developer.apple.com/library/archive/documentation/Miscellaneous/Reference/EntitlementKeyReference/Chapters/EnablingAppSandbox.html · https://developer.apple.com/documentation/security/app-sandbox · https://developer.apple.com/documentation/security/resolving-common-notarization-issues · https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.security.cs.{allow-jit, allow-unsigned-executable-memory, disable-library-validation} · https://indiestack.com/2017/09/sandbox-inheritance-tax/ · https://developer.apple.com/forums/thread/120647 · https://github.com/sindresorhus/fix-path · https://www.bounga.org/tips/2020/04/07/instructs-mac-os-gui-apps-about-path-environment-variable/ · https://raw.githubusercontent.com/migueldeicaza/SwiftTerm/main/{README.md, Sources/SwiftTerm/LocalProcess.swift}.
Source: rebornix/Agmente @ 87f224e7d5884d450f4d54cc1d72724416e6d750 (2026-03-21, "Enhance session handling in ServerViewModel and add tests for session re-materialization"), MIT. Local clone /Users/robertboulos/projects/cloned-repos/Agmente. All paths below are relative to that root; every claim is [src] unless tagged [docs].
Read this first — the five things that change how you use this repo:
ListViewKit + MarkdownView) is UIKit-only. On macOS SessionDetailView falls back to a SwiftUI LazyVStack bubble path, and MarkdownText there is AttributedString(markdown:) inline-only (no code blocks, no headings). Agmente/SessionDetailView.swift:181-187, Agmente/ChatRendering/HighPerformanceChatListView/HighPerformanceChatListView.swift:1,558-602, Agmente/SessionSupplementalViews.swift:195-239.ACPSessionUpdateHandler) is shallow: it emits text/tool events only. ACP plan, usage_update, session_info_update, tool_call.content[] kinds diff/terminal, locations, rawInput are never read. ACPClient/Sources/ACPClient/ACP/SessionUpdateHandler.swift:112-160.ChatRenderDiff is a list-row diff, not a text diff. Diffs are shown as raw monospaced Text. Agmente/ChatRendering/ChatRenderDiff.swift:12-36, Agmente/FileChangesSummaryView.swift:207-214.Process(/NSTask/posix_spawn in sources). Transport is URLSessionWebSocketProvider only; agents are bridged by @rebornix/stdio-to-ws and optionally a Cloudflare Tunnel. ACPClient/Sources/ACPClient/Support/URLSessionWebSocketProvider.swift, docs/remote-agent.md:12-30 [docs].AppServerClient package's transport/service/event-parser is not used at runtime. Codex traffic goes through ACPService + a JSON-RPC shim, and CodexServerViewModel re-parses raw JSON. ACPClient/Sources/ACPClient/Codex/ACPService+CodexJSONRPC.swift:1-17, Agmente/CodexServerViewModel.swift:5-8,2614-2620.| Item | Value | Cite |
|---|---|---|
| App targets | Agmente, AgmenteTests, AgmenteUITests |
Agmente.xcodeproj/project.pbxproj:150,173,196 |
SUPPORTED_PLATFORMS |
iphoneos iphonesimulator macosx (native macOS, not Catalyst) |
project.pbxproj:469 |
| Deployment targets | iOS 18.0, macOS 15.0 | project.pbxproj:366,464 |
SWIFT_VERSION |
5.0 (app target; packages are swift-tools-version: 6.0) |
project.pbxproj:473, ACPClient/Package.swift:1 |
| Sandbox | ENABLE_APP_SANDBOX = YES, bundle com.example.Agmente |
project.pbxproj:445,466 |
| Persistence | Core Data model Agmente (StoredServer, StoredSession, StoredMessage) |
Agmente/Persistence.swift:28, Agmente/SessionStorage.swift:13-32 |
| Package | Platforms | Deps | Purpose |
|---|---|---|---|
ACPClient |
iOS 17 / macOS 13 | rebornix/acp-swift-sdk @ branch: main (pinned rev b800b3f) |
WebSocket transport + typed ACP service, parsers, response dispatcher. Also carries the Codex JSON-RPC shim and a hand-rolled JSONRPC.swift. ACPClient/Package.swift:8-11,19, Agmente.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved |
AppServerClient |
iOS 17 / macOS 10.15, no deps | Typed Codex app-server methods/payloads/parsers/event parser + its own copy of URLSessionWebSocketProvider. Only the model structs (AppServerModel, AppServerReasoningEffortOption, AppServerSkill, AppServerSkillScope) are imported by the app. AppServerClient/Package.swift:7-17, Agmente/CodexServerViewModel.swift:5-8 |
| Dep | Version | Why |
|---|---|---|
Lakr233/ListViewKit |
1.1.8 | Diffable UIKit list (ListView, ListViewDiffableDataSource, ListRowView, ListViewAdapter) driving the iOS transcript. project.pbxproj:643-646, HighPerformanceChatListView.swift:59-60,353-411 |
Lakr233/MarkdownView |
3.6.2 | MarkdownTextView, MarkdownParser, MarkdownTheme, PreprocessedContent — pre-parsed markdown rows with off-main parsing and content-offset binding. project.pbxproj:651-654, Agmente/ChatRendering/ChatMarkdownPackageCache.swift:2-3,45-46 |
rebornix/acp-swift-sdk |
main @ b800b3f | Supplies ACP.Value, ACP.ID, ACP.AnyRequest/AnyResponse/AnyMessage, ACPError, InitializedNotification. Usage counts: ACP.Value ×109, ACP.ID ×30, ACP.AnyResponse ×30, ACPError ×17, ACP.AnyRequest ×16, ACP.AnyMessage ×11 (grep). |
swift-log 1.9.1, swift-system 1.6.4 |
transitive | Not imported by any Agmente/ACPClient/AppServerClient source (packages use their own ACPLogger/PrintLogger, ACPClient/Sources/ACPClient/Support/Logging.swift). Pulled in by acp-swift-sdk. |
| Transitive via MarkdownView | Highlightr 2.3.0, Litext 0.5.6, LRUCache 1.2.1, MSDisplayLink 2.0.8, SpringInterpolation 1.4.0, swift-cmark, swift-collections 1.3.0, SwiftMath 1.7.3 | Syntax highlighting, text layout, display-link throttling, math. Package.resolved |
WebSocketProviding implementations: URLSessionWebSocketProvider and MockWebSocketProvider. ACPClient/Sources/ACPClient/Support/WebSocket.swift:6, Support/URLSessionWebSocketProvider.swift:5, Sources/ACPClientMocks/MockWebSocket.swift:44.Authorization: Bearer (token provider) + CF-Access-Client-Id/Secret + persistent X-Client-Id (UUID stored in UserDefaults ACPClientManager.clientId). ACPClient.swift:45-62,158-169, ACPClientManager.swift:143-162,469-486.\n by default (appendNewline: true) for stdio bridges. ACPClientConfiguration.swift:10-11,18.ACPClient.swift:171-271.setWithoutEscapingSlashesEnabled because codex-acp rejects session\/list. ACPClient.swift:88-101, Models/AgentInfo.swift:50-55.pingInterval (15s from the app), health check ping with 8s timeout on resume. ACPClient.swift:139-156, ACPClientManager.swift:88,403-432, AppViewModel.swift:1551.npx -y @rebornix/stdio-to-ws --persist --grace-period 604800 "<agent cmd>" --port 8765; --persist + X-Client-Id keeps the child alive across iOS backgrounding. docs/remote-agent.md:12-26, Agents.md:127.session/update → chat items#ACPClient) — thin#sessionId = params.sessionId; update = params.update ?? params.sessionUpdate; kind = update.sessionUpdate ?? update.type. ACP/SessionUpdateParsing.swift:5-11.extractText(from:) returns the first of: content string, content.text, first content[] element's .content.text or .text. Multi-block content is truncated to one block. SessionUpdateParsing.swift:59-77.toolCallOutput = rawOutput ?? extractText. SessionUpdateParsing.swift:114-117.ACPSessionUpdateEvent: agentThought(text), userMessage(text), agentMessage(text), toolCall(ACPToolCallInfo), toolCallUpdate(ACPToolCallUpdate), modeChange(modeId), configOptionsUpdate([ACPSessionConfigOption]), availableCommandsUpdate([SessionCommand]). ACP/SessionUpdateHandler.swift:8-31.ACPToolCallInfo { toolCallId?, title, kind?, status } (status defaults "pending"); ACPToolCallUpdate { toolCallId?, status?, title?, kind?, output? }. SessionUpdateHandler.swift:36-65,164-192.handle(params:activeSessionId:) filters by session; nil kind → text fallback → .agentMessage. SessionUpdateHandler.swift:91-109.agent_thought_chunk, user_message_chunk, agent_message_chunk, tool_call, tool_call_update, current_mode_update, config_option_update, available_commands_update. Unknown kinds with any extractable text are emitted as agentMessage; otherwise dropped. SessionUpdateHandler.swift:112-160.ACPClient/Sources + Agmente): ACP plan (entries[], status, priority), usage_update, session_info_update, locations, rawInput, content[] kinds diff / terminal. A plan update has no content key, so it yields zero events. The only case "plan": in the ACP package is the log summarizer. SessionUpdateParsing.swift:24-26.available_commands_update → SessionCommand(id:name, name, description, inputHint: input.hint). SessionUpdateHandler.swift:194-205.config_option_update → ACPSessionConfigOptionParser.parse(from: update) (reads configOptions[]; type select/boolean/flag; options[] may be grouped; a mode id/category is the mode selector). Models/SessionConfigOption.swift:94-187.Agmente/AppViewModel.swift)#| Type | Fields | Cite |
|---|---|---|
ChatMessage: Identifiable, Equatable |
id: UUID, role: Role {user, assistant, system}, content: String, isStreaming, segments: [AssistantSegment], images: [ChatImageData], isError |
AppViewModel.swift:2865-2939 |
ChatMessage.sanitizedUserContent |
strips everything before "## My request for Codex:" on user rows |
AppViewModel.swift:2872,2929-2934 |
AssistantSegment: Identifiable, Equatable |
id: UUID, kind: Kind {message, thought, toolCall, plan}, text, toolCall: ToolCallDisplay? |
AppViewModel.swift:3002-3021 |
ToolCallDisplay: Equatable |
toolCallId?, title, kind?, status?, output?, permissionOptions: [ACPPermissionOption]?, acpPermissionRequestId: ACP.ID?, permissionRequestId: JSONRPCID?, Codex-only approvalRequestId: JSONRPCID?, approvalKind?, approvalReason?, approvalCommand?, approvalCwd? |
AppViewModel.swift:3023-3038 |
CodableSegment / CodableToolCall |
persistence mirror; permission/approval fields deliberately not persisted | AppViewModel.swift:2942-3000 (comment at 2989) |
ChatImageData |
id, thumbnail: UIImage (200px), mimeType; equality by id |
Agmente/ImageAttachment.swift:227-242 |
PendingUserInputRequest, UserInputQuestion, UserInputOption |
Codex plan-mode questions | Agmente/PlanModeViews.swift:6-32 |
SessionSummary |
id, title?, cwd?, updatedAt? |
ACPClient/Sources/ACPClient/Models/SessionSummary.swift:3-15 |
AgentProfile / AgentCapabilityState / PromptCapabilityState / AgentModeOption / SessionCommand |
initialize result model; AgentBehaviorRules hardcodes qwen-code cwd requirement and claude version warnings |
Models/AgentInfo.swift:7-130,206-275 |
Agmente/ACPSessionViewModel.swift, @MainActor)#handleChatUpdate re-checks session id, runs handler, applies each event. ACPSessionViewModel.swift:346-358,669-715.ensureStreamingAssistantMessage() returns the index of the message whose id is streamingMessageId; if a user row was appended after it, the pointer is considered stale, the old row is un-streamed and a fresh assistant row is appended. ACPSessionViewModel.swift:931-949.agent_message_chunk / agent_thought_chunk merge: appendAssistantText(text, kind:) — if the message has no segments but has plain content (restored snapshot), seed a .message segment first; then if the last segment has the same kind and no toolCall, text.append(delta), else push a new segment. Quirk: if the last segment's text ends with "characters)" (the truncation marker), a new segment is started instead of appending. ACPSessionViewModel.swift:804-831.tool_call → appendToolCall: search the streaming message's segments by toolCallId; if found overwrite title (if non-empty), kind (if non-empty), status (always), and rewrite segment text to "[kind] title"; else append AssistantSegment(kind: .toolCall, text: "[kind] title", toolCall:). in_progress/pending set isStreaming = true. ACPSessionViewModel.swift:879-929.tool_call_update → applyToolCallUpdate. Target resolution order: (1) segment with same toolCallId; (2) if id + non-empty title → new segment; (3) else the last tool-call segment, unless incoming title/kind differ (then new segment if title non-empty); (4) else new segment if title. Then status = update.status ?? old, kind = update.kind ?? old, title = update.title ?? old; output replaces (= output), never appends. ACPSessionViewModel.swift:717-773.upsertToolCallFromAppServer(toolCallId:title:kind:status:output:). ACPSessionViewModel.swift:862-877.user_message_chunk (replay from session/load): sanitize; if a streaming row exists and the previous user row already contains the text, drop it (dedupe), else finish streaming; append to last user row or create one. ACPSessionViewModel.swift:775-802.current_mode_update → currentModeId + per-(server,session) mode cache + delegate. ACPSessionViewModel.swift:695-701,184-207.config_option_update → applySessionConfigOptions (also derives modes from the mode selector). ACPSessionViewModel.swift:172-186.available_commands_update → replace list, cache per session, clear stale selection. ACPSessionViewModel.swift:216-238.completePlanItem(id:text:) replaces the last .plan segment's text or appends one (replace semantics); deltas go through appendAssistantText(kind: .plan). ACPSessionViewModel.swift:843-854; callers CodexServerViewModel.swift:3154-3183,3427-3460.rebuildAssistantContent(at:) regenerates content as joined lines: message/thought/plan text verbatim; tool calls as "Tool call: [kind] title (status)". This string is what persists and what ChatEntryMapper.resolvedSegments re-parses when segments is empty. ACPSessionViewModel.swift:972-1000, Agmente/ChatRendering/ChatEntryMapper.swift:80-151.saveChatState() after every mutation → in-memory cache + Core Data (non-streaming rows only). ACPSessionViewModel.swift:1079-1090, AppViewModel.swift:1394-1408.sessionUpdate kind coverage (package handler + app VM combined)#| ACP kind | Package event | App mutation | Missing |
|---|---|---|---|
agent_message_chunk |
.agentMessage(text) (first text block only) |
append to last .message segment of streaming row |
multi-block content, image/audio/resource blocks |
agent_thought_chunk |
.agentThought(text) |
append to last .thought segment |
same |
user_message_chunk |
.userMessage(text) |
replay dedupe → append/create user row, ends streaming row | images in replay |
tool_call |
.toolCall(id,title,kind,status) |
upsert tool segment by id; isStreaming=true if pending/in_progress |
content[], locations[], rawInput |
tool_call_update |
.toolCallUpdate(id,status?,title?,kind?,output?) |
merge; output replaced | content[] kinds diff/terminal, locations, rawInput, rawOutput when non-string |
plan |
none (falls to text fallback; no content key → nothing) |
— | whole feature |
available_commands_update |
.availableCommandsUpdate([SessionCommand]) |
replace list, cache, clear stale selection | — |
current_mode_update |
.modeChange(modeId) |
currentModeId, mode cache, delegate log |
— |
config_option_update |
.configOptionsUpdate([...]) |
replace options; derive modes from mode selector |
— |
usage_update |
none | — | token/cost display |
session_info_update |
none | — | title/metadata refresh |
anything else with content text |
.agentMessage(text) |
rendered as agent prose | should be dropped/logged |
Cites: SessionUpdateHandler.swift:112-160, ACPSessionViewModel.swift:669-773,775-831,879-929.
session/prompt sent → addUserMessage + startNewStreamingResponse (empty assistant row, isStreaming=true, streamingMessageId set). ServerViewModel.swift:1190-1193, ACPSessionViewModel.swift:1056-1062.agent_thought_chunk "Let me" → segments [thought:"Let me"]; content rebuilt "Let me".agent_thought_chunk " check" → [thought:"Let me check"] (same kind, appended).tool_call {id:tc1,title:"git status",kind:execute,status:pending} → [thought, toolCall(tc1,pending)]; segment text "[execute] git status".session/request_permission (request, id 7) → tc1 gets permissionOptions, acpPermissionRequestId=7, status awaiting_permission; row shows buttons. ACPSessionViewModel.swift:493-536.pending, response {outcome:{outcome:selected,optionId}} sent. ACPSessionViewModel.swift:361-384,613-638.tool_call_update {id:tc1,status:in_progress} → status only. 8. tool_call_update {id:tc1,status:completed,rawOutput:"On branch main"} → status + output.agent_message_chunk "Clean tree." → new .message segment (last segment is a tool call, so no merge). ACPSessionViewModel.swift:817-828.session/prompt response {stopReason:end_turn} → .stopReason action → handleStopReason → isStreaming=false, stopReason cached. ResponseDispatcher.swift:183-185, ACPSessionViewModel.swift:1140-1145.content string: "Let me check\nTool call: [execute] git status (completed)\nClean tree." — this is what re-parses if segments are lost. ACPSessionViewModel.swift:972-1000.ACPResponseDispatcher)#dispatchSuccess(result:method:context:) returns [ACPResponseAction]: sessionMigrated(from:to:) (placeholder → real id on session/new), sessionActivated(ACPSessionActivation{sessionId,cwd,modes,configOptions}), sessionMaterialized, modeChanged, configOptionsChanged, initialized(ACPInitializeResult), stopReason(String) (any result with stopReason), sessionListReceived, sessionLoadCompleted, capabilityConfirmed(.listSessions). ACP/ResponseDispatcher.swift:8-44,133-200.dispatchError: -32601 on session/load|resume|list → capabilityDisabled. No handling for -32000/auth-required (grep 0). ResponseDispatcher.swift:208-233.stopReason → handleStopReason sets stopReason, finishStreamingMessage(), notifies delegate. ACPSessionViewModel.swift:1140-1145; applied at AppViewModel.swift:2392-2394.Task; every inbound message/state/error is forwarded with its own Task { @MainActor in delegate?... }. ACPClient.swift:109-137,286-308.ACPService hops again to MainActor for delegate calls and resumes continuations inside a PendingRequestStore actor; request ids from a RequestIDSequence actor (.int(counter)). ACPService.swift:121-151,179-205.ACPClientManager, ACPSessionViewModel, ServerViewModel, CodexServerViewModel, AppViewModel are all @MainActor. ACPClientManager.swift:67, ACPSessionViewModel.swift:62, ServerViewModel.swift:12, CodexServerViewModel.swift:12, AppViewModel.swift:10.AppViewModel.handleIncoming is the single switchboard: responses → dispatcher; notifications → Codex VM (if Codex) then session/update → handleChatUpdate (also bumps session timestamp); requests → Codex approvals / session/request_permission / fs/* / terminal/* else -32601. AppViewModel.swift:2242-2356.ChatTranscriptContainerView (UIViewRepresentable) → HighPerformanceChatListView (UIKit + ListViewKit). macOS: SessionDetailView.chatTranscript — plain SwiftUI ScrollView { LazyVStack { chatBubble } }. SessionDetailView.swift:181-187,199-282; same split in CodexSessionDetailView.swift:235-241,304-381.ChatTranscriptContainerView/HighPerformanceChatListView is a Text(message.content) stub reachable only from the DEBUG gallery, which itself is #if DEBUG && canImport(UIKit). ChatTranscriptContainerView.swift:97-199, HighPerformanceChatListView.swift:558-602, Agmente/Debug/ChatComponentGalleryView.swift:1.updateQueue (qos: .userInteractive) that maps messages → entries and pre-parses markdown off-main, then diffs on main; per-row height cache; a sizing MarkdownTextView with throttleInterval = 1/60; snapshot application deferred while the user is dragging. HighPerformanceChatListView.swift:57,72-76,151-177,258-306. A removed UserDefaults toggle Agmente.useHighPerformanceChatRenderer shows it was once optional. AppViewModel.swift:228,320.Agmente/ChatRendering/)#ChatEntry.Kind: userText, userImages, assistantMarkdown, assistantThought, assistantPlan, toolCall, fileChanges, system, error, streamingIndicator. Stable ids "message.<uuid>.<kind>[.<segmentId>]"; equality/hash = id + contentHash. ChatEntry.swift:4-15,49-156.contentHash inputs: text (markdown), text + isStreaming (thought/plan), 17-part signature for tool calls (id, title, kind, status, output, option ids/names, request ids, approval fields, isStreaming). ChatEntry.swift:158-184.ChatEntryMapper.entries(from:): user → images row + text row; assistant → segments, with file-change tool calls split out into one fileChanges row; a streamingIndicator row appended while streaming (suffix tail/solo). ChatEntryMapper.swift:4-78.ChatRenderDiff.make(old:new:) = set difference on ids + contentHash inequality → inserted/removed/updated id lists; used only as an "anything changed?" gate before applySnapshot. ChatRenderDiff.swift:12-36, HighPerformanceChatListView.swift:166-170.ChatHeightCache keyed (entryId, rounded width, contentHash) under NSLock. ChatHeightCache.swift:4-39.ChatMarkdownPackageCache: MarkdownParser().parse(content) → MarkdownTextView.PreprocessedContent(parserResult:theme:), cached by entry id + content hash + theme identity. ChatMarkdownPackageCache.swift:15-53.ChatScrollAnimationPolicy: animate only when the last message changed in place (streaming) or ≤3 tail appends with identical prefix ids; never on first render or history hydration. ChatScrollAnimationPolicy.swift:4-33.render(messages:) → background: map + pre-parse markdown → main: prune thought-expansion state, compute visible entries (expanded thoughts get contentHash ^ 0x5F3759DF so the row re-measures), diff, applyEntries → dataSource.applySnapshot(using:animatingDifferences:) → scrollToBottom if auto-scrolling. If the user is tracking/dragging/decelerating and not pinned, the snapshot is parked in pendingEntries and flushed on scroll end. HighPerformanceChatListView.swift:56,143-178,224-306,333-350. The container always passes animated: false, so animatingDifferences is effectively never true. ChatTranscriptContainerView.swift:73-77.
HighPerformanceChatListView.render)#updateUIView fires only when lastMessages != messages (array Equatable), computes shouldAnimateScrollToBottom via the policy, calls render(messages:animated:false,scrollToBottomAnimated:). ChatTranscriptContainerView.swift:59-83.updateQueue: mapper.entries(from:) then pre-warm markdownCache.package(...) for every markdown/thought/plan entry (thoughts use the footnote theme). HighPerformanceChatListView.swift:151-159.rawEntries, prune expansion state, compute visibleEntries (salted hashes for expanded thoughts). :161-165,224-233.ChatRenderDiff.make(old: renderedEntries, new:); if empty, just try to flush parked entries. :166-170.applyEntries: if the user is dragging and not pinned, park in pendingEntries (OR-ing animation flags); else dataSource.applySnapshot(using:animatingDifferences:) and scrollToBottom when pinned. :258-286.rowKindFor, listViewMakeRow, heightFor (cache → measure), configureRowView (render tokens prevent re-parsing). :353-556.updateAutoScrollingFromCurrentOffset → flush parked entries. :196-203,338-346.| Package API | Used at |
|---|---|
ListViewKit.ListView (+ .delegate, .adapter, .contentInset, .maximumContentOffset, .scroll(to:), .reloadData(), isTracking/isDragging/isDecelerating) |
HighPerformanceChatListView.swift:59,100,109-126,180-191,285 |
ListViewDiffableDataSource<ChatEntry>.applySnapshot(using:animatingDifferences:) |
:60,284,302 |
ListViewAdapter (rowKindFor, listViewMakeRow, heightFor, configureRowView), ListRowView (prepareForReuse, superListView), RowKind |
:353-556, RowViewSupport.swift:5, MarkdownRows.swift:44,221 |
MarkdownParser().parse(_), MarkdownTextView.PreprocessedContent(parserResult:theme:) |
ChatMarkdownPackageCache.swift:45-46 |
MarkdownTextView (theme, throttleInterval, setMarkdown(_), setMarkdownManually(_), boundingSize(for:), bindContentOffset(from:)) |
MarkdownRows.swift:12-16,37,47, HighPerformanceChatListView.swift:72-76,439-462 |
MarkdownTheme (.default, fonts.body/bold/italic/footnote/codeInline/code, align(to:), MarkdownTheme.codeScale) |
HighPerformanceChatListView.swift:315-329 |
HighPerformanceChatListView/)#BaseRowView insets rows 14/12/14; ActionButton closure-target button. RowViewSupport.swift:5-44.UserTextRowView: right-aligned tinted bubble, max width row - 50. UserRows.swift:4-42. UserImagesRowView: 80×80 thumbnails right-aligned. UserRows.swift:44-87.MarkdownRowView(style:): card (assistant/thought gray6, plan blue 8%), setMarkdown(_, renderToken:) skips re-render when token "<id>#<hash>" unchanged, binds content offset to the enclosing scroll view. MarkdownRows.swift:6-50, HighPerformanceChatListView.swift:21-52,500-502.ThoughtRowView: 34pt purple chip "Thinking" + chevron; expanded → full-width card with a footnote-weight MarkdownTheme (makeThoughtMarkdownTheme); expanded automatically while entry.isStreaming, toggle disabled while streaming; chevron/width animate 0.2s. MarkdownRows.swift:52-247, HighPerformanceChatListView.swift:215-256,315-329.SystemRowView centered capsule (info / red error), StreamingIndicatorRowView spinner + "Thinking…". SystemRows.swift:4-104.ToolCallRowView — see table. ToolCallRowView.swift.| Aspect | Behaviour | Cite |
|---|---|---|
| Payload | title, status, kind, output, ACP request id + options, JSONRPC request id, Codex approval id/reason/command/cwd, isStreaming | ToolCallRowView.swift:77-133 |
| Compact vs regular | compact when no output, no approval detail lines, no actions; compact command-like (execute/command/shell) uses monospaced 15pt (14pt if multi-line: contains \n or >72 chars) |
ToolCallRowView.swift:121-132,583-610 |
| Leading icon | always hammer.fill in orange-brown; iconForToolKind/colorForToolKind (read/edit/delete/move/search/execute/think/fetch) are defined but unused in this row |
ToolCallRowView.swift:227-228,779-804 |
| Status visual | only awaiting_permission (with options or approval id) → exclamationmark.shield.fill trailing icon; a spinner slot exists but is never started; no completed/failed glyph |
ToolCallRowView.swift:224-238,299,328-337 |
| Output | UILabel limited to 5 lines of output.truncatedToolOutput(maxLines: 6, maxChars: 1200) |
ToolCallRowView.swift:178,247-248, SessionSupplementalViews.swift:267-292 |
| Detail lines | Reason: / Command: / CWD: (Codex approvals) |
ToolCallRowView.swift:626-638 |
| Buttons | Approve/Decline row (Codex) then one button per ACP option, wrapped into rows by measured width; green for allow_*, red for reject_*, gray unknown | ToolCallRowView.swift:463-581,742-777 |
| Height estimate | mirrors layout: title bounding rect + details + output + button rows (34pt each) + insets; min 58 (regular) / 34 (compact) | ToolCallRowView.swift:384-461 |
| Expand/collapse | none; no tap-to-expand on tool rows | — |
FileChangesRowView: "N file(s) changed" + Undo (bordered) + Review (prominent) + one FileChangePreviewRowView per unique path (dedupe by lowercased last path component, prefer the one with a separator / non-diff verb / longer path). FileChangesRows.swift:5-251,166-231.FileChangesSummaryView (same dedupe) and FileChangesReviewSheet — diff shown as Text(diff) monospaced caption inside a gray card; no parsing, colours, line numbers or hunk headers. FileChangesSummaryView.swift:73-243.FileChangeSummary.isFileChangeSegment: kind token ∈ {edit,file,files,patch,diff,apply,write} (split on ., /, -, _→-) or title prefix ∈ {edit:,create:,delete:,rename:,move:,add:,update:}; items(from:) skips any segment whose output is empty (see §I.8). FileChangesSummaryView.swift:13-56.Undo → alert "Agmente can't undo file changes yet." SessionDetailView.swift:171-175,326-328.MarkdownView package (full CommonMark via swift-cmark, code highlighting via Highlightr, math via SwiftMath), parsed once per content hash off-main; streaming re-renders the whole row's PreprocessedContent (no incremental append API used). ChatMarkdownPackageCache.swift:45-46, HighPerformanceChatListView.swift:155-159,500-502.MarkdownText = AttributedString(markdown:options: .inlineOnlyPreservingWhitespace) after unescaping \n/\t, appending two spaces to every line (hard breaks), truncating at 400 lines / 20k chars, falling back to verbatim text above 20k. No code blocks, headings, lists, tables. SessionSupplementalViews.swift:195-264.MarkdownStyle paddings: assistant 10/8, thought & plan 10/10. HighPerformanceChatListView.swift:21-52.plan updates: not modelled, not rendered (§B)..plan segments render as MarkdownRowView(style: .plan) on iOS, and as ProposedPlanCard on the SwiftUI path (blue card, "Proposed Plan" header, "Planning..." spinner while streaming, buttons Implement this plan → sends prompt "Implement the plan." with plan mode off, Continue planning no-op). HighPerformanceChatListView.swift:398-399,455-462, PlanModeViews.swift:36-106, CodexSessionDetailView.swift:1031-1038,619-627.turn/plan/updated steps are flattened to "- step: description (status)" lines. CodexServerViewModel.swift:3193-3238.item/tool/requestUserInput → UserInputQuestionsSheet: paged TabView, single/multi-select rows, isOther free text (SecureField if isSecret), Skip submits {}; macOS frame 680×560 min. PlanModeViews.swift:112-400, CodexSessionDetailView.swift:140-144.thoughtBubble — brain icon, caption markdown, lineLimit(6) when >240 chars or >5 lines with "Show more/less". SessionDetailView.swift:1201-1246.groupedThoughtSegments() groups consecutive thoughts plus any tool calls that follow them into a ThoughtGroup; thoughtGroupCard auto-expands while streaming, and when streaming ends stays expanded only if the group contains a tool call. SessionSupplementalViews.swift:99-190, CodexSessionDetailView.swift:1062-1129.terminal/* requests are refused with -32001. ACPSessionViewModel.swift:466-475..textSelection(.enabled) on SwiftUI markdown (SessionDetailView.swift:778, SessionSupplementalViews.swift:405,424). Session rows on macOS get a context menu (Archive / Open in New Window). ContentView.swift:635-647,1849-1861.isAutoScrollingToBottom cleared on drag begin, restored when offset is within autoScrollTolerance (4pt) of maximumContentOffset; onAtBottomChanged feeds a floating "scroll to bottom" button (glass on iOS 26/macOS 26). HighPerformanceChatListView.swift:85-86,180-213,333-350, ChatTranscriptState.swift:38-44, SessionDetailView.swift:105-127.scrollPosition(id:anchor:.bottom) + onChange of count/last content/last segments with 50–150ms asyncAfter nudges; isAtBottom threshold 80pt. SessionDetailView.swift:199-320.| Concern | macOS | Cite |
|---|---|---|
| Shell | NavigationSplitView (sidebar min/ideal 350) with splitSelection; iOS uses NavigationStack path |
ContentView.swift:22-44,65-71 |
| Windows | second WindowGroup("Session", id:"session-detail-window", for: String.self); "Open in New Window" stores a SessionWindowPayload (model + VMs) in SessionWindowStore keyed "<serverId>::<sessionId>", sets NSWindow.title via an NSViewRepresentable |
AgmenteApp.swift:20-29, SessionWindowStore.swift:4-93, ContentView.swift:552-593 |
| Keyboard | CommandGroup(after: .textEditing): Send Message ⌘↩, Cancel Response ⌘. via @FocusedValue(\.promptComposerActions); only SessionDetailView publishes the focused value (Codex view does not, §I.9) |
PromptComposerCommands.swift:22-43, SessionDetailView.swift:518 |
| Composer height | heuristic charsPerLine = (width-20)/8, 22pt lines, max 110 (iOS measures a UITextView) |
SessionDetailView.swift:32-58 |
| Menus | .menuStyle(.borderlessButton) on iOS vs .buttonStyle(.plain) on macOS for pickers |
CodexSessionDetailView.swift:712-717,798-803,861-866 |
| Sheets | fixed frame(minWidth:…) instead of presentationDetents |
PlanModeViews.swift:172-177, ContentView.swift:1527-1531 |
| Colours | NSColor+UISemantic.swift shims Color(.systemGray6)-style names |
Agmente/NSColor+UISemantic.swift:3-33 |
| Search | .toolbar placement |
ContentView.swift:650-659 |
ACPPermissionRequest { sessionId?, toolCallId?, toolCallTitle (default "Unknown operation"), toolCallKind?, options: [ACPPermissionOption{optionId,name,kind}] }. ACP/PermissionRequestParsing.swift:4-16,41-71.ACPPermissionOptionKind: allow_once, allow_always, reject_once, reject_always, unknown; alternate spellings mapped: proceed_once→allowOnce, proceed_always→allowAlways, cancel→rejectOnce. PermissionRequestParsing.swift:18-38.PermissionRequestParsingTests.swift:6-51.AppViewModel.handleIncoming routes session/request_permission to the currently selected session VM without checking the request's sessionId. AppViewModel.swift:2343-2344.handlePermissionRequest: record pendingPermissionRequests[requestId] = (sessionId, toolCallId), then updateToolCallWithPermission — find the tool-call segment by id across all assistant messages, else in the streaming message, else append a new tool-call segment; set permissionOptions, acpPermissionRequestId, status = "awaiting_permission". ACPSessionViewModel.swift:409-433,493-536.name.truncatedLabel(maxChars: 24), icon checkmark.circle (allow_*), xmark.circle (reject_*), questionmark.circle (unknown); green/red/gray palettes with explicit dark-mode RGB. iOS wraps by measured width; SwiftUI ACP uses an HStack; SwiftUI Codex uses LazyVGrid(.adaptive(minimum: 102)). ToolCallRowView.swift:491-515,742-777, SessionDetailView.swift:970-1061, CodexSessionDetailView.swift:1370-1444..keyboardShortcut/defaultAction on option buttons), no timeout, no "auto approve" setting (grep autoApprove|approveAll|yolo only hits the mode-icon switch SessionDetailView.swift:1173,1192).SessionDetailView.swift:972-975.agent ─ request session/request_permission {sessionId, toolCall{toolCallId,title,kind,status}, options[]} ─▶ ACPClient.handleIncomingData
─▶ ACPService.acpClient(didReceiveMessage:) (.request is NOT resolved as a response; forwarded raw) ACPService.swift:140-144
─▶ ACPClientManager ─▶ AppViewModel.handleIncoming(.request) AppViewModel.swift:2326-2355
─▶ sessionViewModel.handlePermissionRequest(request) AppViewModel.swift:2343-2344
pendingPermissionRequests[id] = (sessionId, toolCallId) ACPSessionViewModel.swift:422
updateToolCallWithPermission → segment.toolCall.{permissionOptions, acpPermissionRequestId, status="awaiting_permission"}
─▶ ChatEntry.toolCall contentHash changes (option ids in signature) → row re-configured with buttons ChatEntry.swift:158-184
user taps option ─▶ ChatEntryActionHandlers.onACPPermissionResponse(requestId, optionId) HighPerformanceChatListView.swift:531-533
─▶ AppViewModel.sendPermissionResponse ─▶ sessionViewModel.sendPermissionResponse AppViewModel.swift:2081-2083
remove pending, clear options (status→"pending"), send ACPMessageBuilder.permissionResponseSelected
user taps Stop ─▶ AppViewModel.sendCancel ─▶ cancelPendingPermissionRequests(for: sessionId) (cancelled outcome per request)
─▶ session/cancel ─▶ abandonStreamingMessage AppViewModel.swift:2050-2069
{"outcome":{"outcome":"selected","optionId":…}}; cancelled → {"outcome":{"outcome":"cancelled"}}; errors → ACPError by code. ACP/ACPMessageBuilder.swift:9-45; tests ACPMessageBuilderTests.swift:6-57.sendPermissionResponse: remove pending entry, clearPermissionOptionsForToolCall (options → nil, request id → nil, status awaiting_permission → pending), send. ACPSessionViewModel.swift:361-384,613-638.AppViewModel.sendCancel → cancelPendingPermissionRequests(for: sessionId) sends a cancelled outcome for every pending request in that session, then session/cancel, then abandonStreamingMessage() (drops an empty streaming row or un-streams it). AppViewModel.swift:2050-2069, ACPSessionViewModel.swift:387-406,1021-1037.item/commandExecution/requestApproval / item/fileChange/requestApproval → updateToolCallWithApproval (fields approvalRequestId/Kind/Reason/Command/Cwd, status awaiting_permission); reply {"decision":"accept"|"decline"[, "acceptSettings":{"forSession":bool}]}; UI passes acceptForSession: nil. ACPSessionViewModel.swift:538-611, CodexServerViewModel.swift:2852-2881,3636-3677, ToolCallRowView.swift:467-498.PermissionPreset): Default → approvalPolicy:"on-request" + sandboxPolicy:{type:"workspaceWrite"}; Full access → "never" + dangerFullAccess; persisted per server in UserDefaults. CodexServerViewModel.swift:124-158, AppViewModel.swift:471-490, CodexSessionDetailView.swift:825-868.ServerType.acp|codexAppServer) but also auto-detected from initialize: ACP markers (protocolVersion, agentCapabilities, agentInfo, agent) → .acp; else userAgent present → .codexAppServer; version parsed from "codex/1.0.0". ACP/InitializeParsing.swift:36-43,90-119, AppViewModel.swift:2659-2676.AppViewModel swaps ServerViewModel for CodexServerViewModel and flags an initialized ack; the ack is sent lazily as "notifications/initialized" before the next Codex call. AppViewModel.swift:2191-2213,449-478, CodexServerViewModel.swift:199-201,490-502. (Add-server validation sends the SDK's InitializedNotification.name instead; AppServerMethods.initialized is "initialized" — three spellings, §I.10.)clientCapabilities {fs, terminal} and Codex capabilities {experimentalApi:true}. AppViewModel.swift:1721-1742, ACPServiceModels.swift:33-52.CodexServerViewModel.swift)#| Direction | Method | Params / handling | Cite | |
|---|---|---|---|---|
| → | thread/start |
approvalPolicy, persistExtendedHistory:true, cwd; result thread.id |
2622-2647 | |
| → | turn/start |
threadId, input:[{type:text,text}], model, effort, skills[], approvalPolicy, sandboxPolicy, `collaborationMode {mode: plan\ |
default, settings{model, reasoning_effort:null, developer_instructions:null}}; result turn.id → activeTurnId`, binds a streaming row |
2649-2714 |
| → | turn/interrupt |
{threadId, turnId} |
2716-2724 | |
| → | thread/resume |
{threadId, persistExtendedHistory:true} → full turns/items |
2726-2742 | |
| → | thread/read |
{threadId, includeTurns:true} |
2744-2757 | |
| → | thread/loaded/list |
paginated data[] of loaded thread ids |
2759-2789 | |
| → | addConversationListener |
{conversationId, experimentalRawEvents:false} |
2796-2804 | |
| → | thread/list |
{cursor:null, limit:50} → data[] {id, preview, cwd, updatedAt/createdAt} |
2812-2821, 3681-3693 | |
| → | thread/archive, model/list, skills/list |
2823-2850 | ||
| ← notif | turn/started |
activeTurnId, interruptible, bind streaming row |
3060-3078 | |
| ← notif | item/agentMessage/delta |
appendAssistantText(.message) |
2986-2996 | |
| ← notif | item/plan/delta |
.plan delta |
2997-2999, 3154-3169 | |
| ← notif | item/started / item/completed |
handleCodexItemEvent(status: in_progress/completed) |
3000-3025, 3240-3367 | |
| ← notif | turn/plan/updated |
flatten steps → completePlanItem |
3026-3028, 3171-3238 | |
| ← notif | turn/diff/updated, codex/event/turn_diff |
synthetic tool call toolCallId:"turn_diff:<turnId>", kind edit, title "diff: <path from diff --git>", output = unified diff |
2974-2985, 3123-3152 | |
| ← notif | turn/completed |
clears active turn, unbinds streaming, emits stop reason turn_completed |
3029-3059 | |
| ← notif | error |
willRetry → inline "⚠️ … (retrying…)" else terminal system error |
3079-3080, 3101-3121 | |
| ← req | item/commandExecution/requestApproval, item/fileChange/requestApproval |
handleApprovalRequest (routed from AppViewModel) |
3636-3677, AppViewModel.swift:2330-2335 |
|
| ← req | item/tool/requestUserInput |
questions sheet | 3085-3094, 2885-2942 |
turn/completed (clears saved turn, unbinds background VM). CodexServerViewModel.swift:2956-2971.parseThreadItem normalises type (strip _, lowercase) and maps: usermessage/message(role user) → user; agentmessage/assistantmessage → assistant text; plan; reasoning|thought|analysis → thought (text ∥ content[] ∥ summary[]); commandexecution|command|exec|shell → tool call kind execute (title via commandExecutionDisplayTitle, which unwraps zsh/bash/sh -c|-lc, /usr/bin/env, PowerShell -Command, cmd /c); filechange|file|diff|patch → tool call kind edit, output = diff from changes[].diff|patch|content[type:diff]; toolcall|tool|functioncall|function → generic tool call; substring heuristics as fallback. CodexServerViewModel.swift:3836-4176,352-488.
item/completed for agentMessage de-duplicates against the streaming row (exact / suffix / contains / whitespace-normalised / prefix-extension) so delta+final don't double-render; <proposed_plan>…</proposed_plan> in a message becomes a .plan segment. CodexServerViewModel.swift:3369-3425,3462-3472,194-197.itemId via reasoningCache. CodexServerViewModel.swift:3247-3267.ChatMessage/AssistantSegment/ToolCallDisplay, ACPSessionViewModel (per session), ChatEntryMapper, all row views, ChatEntryActionHandlers {onACPPermissionResponse, onJSONRPCPermissionResponse, onApproveRequest, onDeclineRequest, onUndoFileChanges, onReviewFileChanges}. ChatTranscriptState.swift:27-36.ServerViewModelProtocol lets AppViewModel treat both VMs uniformly (sessionSummaries, sendPrompt, openSession, archiveSession, …). Agmente/ServerViewModelProtocol.swift:7-114.availableModes is []), instead model/effort, skills (grouped by scope user→repo→system→admin), permissions preset, plan toggle, archive, log export; images are text-only ("Codex app-server: image attachments are not supported yet"); isStreaming is hasStreamingRow || canInterruptActiveTurn; the send button is tri-state Send / Stop (turn/interrupt) / Reset (clearLikelyInFlightState) when a resume-derived turn is not confirmed live. CodexServerViewModel.swift:59-96,1959-1961,2020-2047, CodexSessionDetailView.swift:474-495,564-601.CodexThreadResumeResult { id, preview?, cwd?, createdAt?, activeTurnId?, turns: [Turn{id,status?,items}] }; Item = userMessage(id,text) | agentMessage(id,text) | plan(id,text) | reasoning(id,text) | commandExecution(id,command?,output?) | fileChange(id,path?,changeType?,diff?) | toolCall(id,title,kind?,status?,output?) | unknown(type). activeTurnId = first turn whose status normalises to inprogress|running|pending|started. CodexServerViewModel.swift:326-350,3761-3834.turn:<turnId>:<reasoning|command|file|tool|user|assistant|plan>:<itemId> or turn:<turnId>:idx:<i>:<kind> when no item id; stored per session in sessionMessageKeys[threadId][messageId] so later thread/reads can reuse rows. CodexServerViewModel.swift:1750-1776,170,1510.CodexServerViewModel.swift:1566-1573.CodexServerViewModel):| State var | Set by | Cleared by | Effect |
|---|---|---|---|
activeThreadId |
setActiveSession, startThread, resumeThread, readThread |
deleteSession |
notifications for other threads dropped (except turn/completed) :2956-2971 |
activeTurnId |
turn/start result, turn/started, alignActiveTurnIfNeeded (any item/*/delta with a new turnId), thread/resume |
turn/completed, turn/interrupt, terminal error, clearLikelyInFlightState |
drives canInterruptActiveTurn :63-79 |
activeTurnIsInterruptible |
true on live events; false when only inferred from thread/resume |
with activeTurnId |
Stop vs Reset button :81-83,2296-2316 |
turnStreamingMessageIds[turnId] → UUID |
bindStreamingMessageForTurn (memory → current streaming → resume-key lookup → ensure new) |
turn/completed, interrupt |
which assistant row receives deltas :2363-2403 |
lastStreamingEventAtByThreadId |
every delta/item/plan event | turn/completed, reset |
15s window for "likely in flight" :2326-2345 |
savedTurnByThread |
switching away from a thread with an active turn | reopening that thread, background turn/completed |
restores in-flight detection when navigating back :2249-2285 |
thread/loaded/list → if loaded, addConversationListener + thread/read(includeTurns:true); else thread/resume. CodexServerViewModel.swift:2806-2810,689-713, AppServerClient/codex-thread-hydration.md:17-33 [docs].CodexServerViewModel.swift:192,721-745,2336-2345.mergeChatFromThreadHistory): resume nodes keyed turn:<id>:<kind>:<itemId>; reuse existing rows by key, mergeResumeMessagePayload refuses to downgrade a richer local row (prefix snapshot / dropped tool rows / dropped output); unmatched local rows are carried forward near their neighbours; duplicate assistant/thought text suppressed by normalised containment and tool-id subset checks. CodexServerViewModel.swift:1292-1557,1096-1297. Spec: Agmente/specs/codex-load-resume-merge.md [docs].thread/resume calls 2s apart until item count stabilises. CodexServerViewModel.swift:2143-2247.CodexSessionLogger (actor) writes JSONL per session to ~/Library/Application Support/Agmente/logs/codex/, including wire frames, merge stats and chat snapshots; export/zip from the session menu. Agmente/CodexSessionLogger.swift:4-114, CodexSessionDetailView.swift:1446-1518.AppServerClient package (unused transport, useful reference)#AppServerMethods constants incl. review/start, command/exec, config/*, mcpServer/*. AppServerMethods.swift:1-27.AppServerEventParser → AppServerEvent (threadStarted, turnStarted, turnCompleted, agentMessageDelta, itemStarted, itemCompleted, diffUpdated, planUpdated, tokenUsageUpdated, approvalRequested, notification, request). AppServerEventParser.swift:3-141.unlessTrusted|untrusted|onRequest|onFailure|never), sandbox (readOnly|workspaceWrite|dangerFullAccess|externalSandbox), reasoning effort/summary, review targets, config writes. AppServerPayloads.swift:40-131,346-411,451-546.onRequest, the live VM sends "on-request". AppServerPayloads.swift:49, CodexServerViewModel.swift:142..agents/skills/codex-local-cli-e2e/: runs one XCUITest testCodexDirectWebSocketConnectInitializeAndSessionFlow against a real codex app-server --listen ws://127.0.0.1:8788; env contract AGMENTE_E2E_CODEX_ENABLED=1, AGMENTE_E2E_CODEX_ENDPOINT (or _HOST), optional _PROMPT, optional _CONFIG_PATH file; test XCTSkips when disabled. Script run_codex_local_e2e.sh boots the sim, uninstalls the app for a clean first-run, optionally starts the server (nohup, nc -z port wait), runs xcodebuild -only-testing:, greps failures, treats an unexpected skip as failure, always cleans up (kill server, uninstall app, optional shutdown). SKILL.md:12-83, references/agmente-codex-e2e-contract.md:14-27, scripts/run_codex_local_e2e.sh:139-244, AgmenteUITests/AgmenteUITests.swift:63-160,190-227..github/skills/run-agmente-codex-e2e/: agent-driven variant using stdio-to-ws bridge on port 9000 (start_codex.sh with pid/log files), XcodeBuildMCP build_run_sim, describe_ui before every tap, validates the RPC sequence initialize → initialized → thread/list → thread/start → turn/start → turn/started → turn/completed and item/* streaming, mandatory cleanup.sh. SKILL.md:18-40, scripts/start_codex.sh:17-40, references/ui-checklist.md:27-64.e2e/ is the source of truth (scenario front-matter + shared assertion vocabulary); skills are execute-only and must not edit the repo to make a run pass. e2e/README.md:16-27, e2e/assertions/common.md, e2e/scenarios/codex/local-cli-smoke.md..agents/skills/upstream-protocol-drift-watch/: diff upstream ACP/Codex repos (codex-rs/app-server-protocol, ACP docs/schema) against local method constants and score risk. SKILL.md:44-93.emptyStateAddServerButton, ServerNameField, ServerTypeCodex/ServerTypePicker, ProtocolPicker, HostField, newSessionButton, codexPromptEditor, codexSendButton, codexUserBubble, codexAssistantBubble, codexThinkingBubble, codexSystemBubble. CodexSessionDetailView.swift:543,593,982,1049,1053,1058, ServerManagementViews.swift:88-111.session/list fanned out once per used working directory (Core Data usedWorkingDirectories on the server row), results accumulated in pendingMultiCwdFetch and merged/sorted by updatedAt desc then id. ServerViewModel.swift:998-1152, SessionStorage.swift:142-176.mtime, updatedAt (number / numeric string / ISO8601 ± fractional), startTime; unix values auto-scaled from ns/µs/ms. ACP/SessionListParsing.swift:54-96. Title = title ?? prompt. SessionListParsing.swift:21-24.-32601 on session/list → capability off, fall back to cache; when the agent supports list, the fetched list prunes stale Core Data rows. AppViewModel.swift:2264-2268, ServerViewModel.swift:436-455.thread/list limit 50; cwd missing on older servers is back-filled from cache/storage. CodexServerViewModel.swift:2051-2078,2525-2562.ContentView.swift:700-770,1441-1533.pendingLocalSessions; if connected+initialized, session/new {cwd, mcpServers:[]} fires immediately (task tracked in creatingSessionTasks), else deferred to first prompt. Response → finalizePendingSessionCreation: resolve id, migrate VM/cache/storage from placeholder, mark materialized, remove placeholder. Failure → placeholder removed and error row. ServerViewModel.swift:858-918,514-653,977-995.openSession(id): map placeholder→resolved; Codex → setActiveSession only; if already materialized this connection → nothing; if local messages exist and agent can session/load → clear messages and session/load (server replays history as user_message_chunk/agent_message_chunk); if no load support → show cached; if not connected → pendingSessionLoad. ServerViewModel.swift:782-850,727-779.session/load first (or session/resume when load unsupported), -32601 flips the capability flag. ServerViewModel.swift:1282-1351.ACPClientManager and reset on every connect/disconnect/failure. ACPClientManager.swift:114-115,513-516,524-555.defaultModeId from initialize. ServerViewModel.swift:687-708.| State | How you get there | Prompt path | Cite |
|---|---|---|---|
Local placeholder (pendingLocalSessions) |
New Session while disconnected/uninitialized, or immediately before session/new returns |
awaits creatingSessionTasks[id] or creates now; never persisted; failure removes it |
ServerViewModel.swift:858-918,1207-1273,352-353 |
Materialized this connection (connectionManager.isSessionMaterialized) |
session/new / session/load / session/resume success, or sessionLoadDidComplete |
direct session/prompt |
:577,1304,1339,1422-1426 |
| Known but not materialized | app relaunch, reconnect (sets reset), server switch | preflight session/load (or resume), -32601 flips capability then falls through |
:1282-1351, ACPClientManager.swift:513-516 |
| Cached-only (agent lacks load/resume) | e.g. Gemini | shows Core Data transcript; new prompts go to a session the server may not know (Limited session recovery) |
:840-843, AppViewModel.swift:1867-1884 |
Loading (pendingSessionLoad == sessionId) |
open while disconnected | "Loading session..." overlay until connected | SessionDetailView.swift:272-273, AppViewModel.swift:1487-1523 |
StoredServer {id,name,scheme,host,token,cf*,workingDirectory,serverType,usedWorkingDirectories:NSArray}, StoredSession {sessionId,title,cwd,updatedAt}, StoredMessage {messageId,role,content,createdAt,orderIndex,segmentsData}; saveMessages replaces all rows for a session; saveSession never overwrites with nil; updatedAt only touched locally for agents without session/list. SessionStorage.swift:53-433, ServerViewModel.swift:474-476.chatCache[server][session], stopReasonCache, updatesCache (log lines), sessionSummaryCache, initializationCache, agentInfoCache. AppViewModel.swift:276-283,204.isStreaming, images. AppViewModel.swift:2882-2889,2989.ACPClientManager.clientId, lastConnectedAt. AppViewModel.swift:225-229, ACPClientManager.swift:131-132.ACPClientManager: NWPathMonitor (offline → disconnect + .failed(NetworkOfflineError); online → reconnect), exponential backoff 1s·2^(n-1) capped at 3 attempts then silent stop, verifyConnectionHealth ping with 8s timeout before reuse, connectAndWait/initializeAndWait continuations, "already initialized" RPC error treated as success. ACPClientManager.swift:81-92,171-209,339-386,403-465.scenePhase == .active → resumeConnectionIfNeeded (1s throttle) → health check → reconnect → initialize → fetchSessionList(force:) → Codex fetchModels + resubscribeActiveSessionAfterReconnect. ContentView.swift:51-56, AppViewModel.swift:1639-1657,1688-1719.session/load/resume (transcript is kept locally, not cleared). ServerViewModel.swift:1282-1351.ACPClientManager shared by all server VMs (TODO acknowledged); selectServer persists the old server's state, disconnects, applies the new config non-destructively (isApplyingSelectedServerConfig guard), reconnects. Only one live socket. AppViewModel.swift:363,878-909,1029-1047.ACPSessionViewModel instances are created lazily and kept per server, with objectWillChange forwarding; placeholder ids are migrated in place. ServerViewModel.swift:47-75,206-241.session/cancel after cancelling pending permissions; Codex turn/interrupt or local reset. AppViewModel.swift:2050-2069, CodexServerViewModel.swift:1986-2047.ACPServiceError {disconnected, rpc(id,error), unsupportedMessage} → formatPromptError → system error row via failPendingTurn; authMethods are parsed from initialize but never surfaced or used (authenticate is never sent); no -32000 handling; add-server validation maps NSURLErrorDomain -1001…-1200 on local hosts to a "local network permission" hint and probes session/list/session/load support with a fake id capability-probe. ACPService.swift:153-177, AppViewModel.swift:2454-2466,2045-2048, InitializeParsing.swift:45-58, AppViewModel.swift:689-756.ACPClient/Tests/ACPClientTests#SessionUpdateHandlerTests (:10-405): message chunk from content string and from content.text; thought chunk; user chunk; tool_call full and minimal (status defaults pending); tool_call_update with rawOutput, with title/kind, with content[] text ("On branch main"); current_mode_update with/without modeId; available_commands_update incl. input.hint; session filtering (other session → 0 events, match → 1, nil filter → pass); unknown type with text → agentMessage, without text → nothing; nil params, empty update, empty text → nothing; sessionId(from:) extraction and nil.SessionUpdateParsingTests (:6-160): summariser strings for message/tool_call/tool_call_update/mode/commands; extractText from array payload; parse returns session+kind; tool helpers prefer title and rawOutput; output from content[]; user text from content.text.PermissionRequestParsingTests (:6-51): full request incl. proceed_always→allowAlways; missing title → "Unknown operation" and empty options.ACPMessageBuilderTests (:6-57): selected outcome shape; cancelled outcome has no optionId; initialized notification uses SDK name with null params; error response carries code/message.ResponseDispatcherTests (:9-568): session/new, load, resume dispatch; placeholder migration vs same id; set_mode via currentModeId and modeId; set_config_option; initialize via method and via agent fallback; stopReason; session/list from sessions and items keys; cwd transform; -32601 disables load/resume/list; other codes only rpcError; session/new with modes; fallback session id from other methods; empty/nil results; pending cwd fallback.SessionResponseParsingTests (:9-500): session/new id keys (sessionId/session/id), cwd/workingDirectory, modes, configOptions→mode synthesis, fallbacks, nil; session/load basic, history/messages arrays, timestamps, modes, nil; set_mode variants; config options; parseModes edge cases; equality.InitializeParsingTests (:6-115): ACP extracts modes + authMethods; Codex from userAgent; ACP preferred when markers exist; Codex version edge cases.AgentInfoParsingTests (:6-70): AgentProfile from JSON; available commands parse.SessionListParsingTests (:6-73): mtime+prompt; updatedAt string; sort desc; cwd transform.PromptBuilderTests (:9-329): text/whitespace/images/audio/context blocks, capability warnings, JSON shapes, debug descriptions, validate/makePayload.ServiceModelTests (:9-230): params encoding for load/resume/create/list/set_mode/cancel/initialize payloads.ACPServiceTests (:39-158, swift-testing): initialize resolves; RPC error surfaces; load; set_config_option.ACPClientTests (:10-90, swift-testing): wire encode/decode; connect+receive; bearer header from provider; ping when configured; unescaped-slash toggle.ACPClientManagerTests (:56-333): client id generate/persist/reuse/provided; initial state; persisted lastConnectedAt; config defaults/all options; reconnect settings; disconnect resets; delegate logs; connectAndWait; health ping; initializeAndWait; session tracking reset; failed clears connecting.ACPClientManagerRaceTests (:32): disconnect/connect race.AppServerClient/Tests#EventParserTests:5 agentMessage delta event; JSONRPCTests:5,15 decode without / encode with jsonrpc header; ResponseParsingTests:7-140 skills sorted by scope then name, scope Comparable, display names, allCases order.AgmenteTests#ACPSessionViewModelTests (:155-633): save/load chat state via cache delegate, with stop reason, without context; load from storage when cache empty; reset; setChatMessages; stopReason and load-complete call delegates; mode change delegate; addUserMessage / startNewStreamingResponse / error row; mode set/cache/migrate (no overwrite); state transitions; multi-session cache isolation; streaming state restore; commands update/restore/migrate.AgentViewModelTests (:156-1125): capability nil before init; ACP init populates agent info; Codex init sets caps; ACP preferred; qwen-code modes+caps; prompt caps default false; session list → summaries, marks support true/false, items+prompt+mtime; commands update; message + tool output; same toolCallId updates one segment; stopReason finishes streaming; summaries; thought segment; permission request creates tool call with options (status awaiting_permission, request id .int(0)); end-to-end permission (response wire shape, options cleared, later tool_call_update completes with output, stopReason); Gemini e2e (authMethods, list -32601 fallback, streaming chunks concat, end_turn); resume → session/list sent; tool call + permission e2e (pending→awaiting→in_progress→completed with rawOutput); last-message preview truncation rules; mode change log.CodexServerViewModelTests (:48-1196): ServerViewModel→Codex switch after initialize, stays ACP for ACP; agent info synced; pending session always false; default preset; Full access → dangerous overrides; command title unwrapping (sh/env/direct script/PowerShell/cmd/unknown flags); selectedServerViewModelAny both types; summaries not migrated on switch; set/open session; streaming tracks turn lifecycle; interruptible without streaming row; item delta realigns stale turn; stale local turn not in-flight w/o recent activity; recent turn still in-flight; reset keeps partial composer text; resume-derived turn uses Reset until live event; plan delta + plan updated render .plan; completed message with <proposed_plan> becomes plan; structured plan delta suppresses raw; plan delta preserves whitespace; collaborationMode default/plan; switching away saves in-flight turn, reopening same thread doesn't; background turn/completed cleans saved turn; other background notifs dropped; active turn/completed cleanup; removeAll clears saved turns; full session-switch streaming isolation.CodexThreadReadMergeFixtureTests (:9-247): data-driven — for every Fixtures/CodexThreadReadMerge/*.json seed messages+keys+active turn, apply thread_read merges and update notifications, assert count / ordered contains / per-role contains counts. Fixtures pin: user-before-rich-assistant after read; same-prefix snapshot keeps rich local; overlapping in-flight streaming merges new items; partial-thought and combined-reasoning de-dup; new-turn reasoning not suppressed; markdown growth then updates; background turn completion doesn't duplicate opening text. AgmenteTests/Fixtures/CodexThreadReadMerge/*.json, Agmente/specs/codex-load-resume-merge.md:138-221 [docs].ServerViewModelTests (:242-880): pending cwd flows into session/new and session/prompt uses resolved id, no session/load; fresh empty session never triggers load; failed creation never prompts with placeholder; resolved id replaces placeholder in storage and persists transcript; cached sessions reopen from storage for load-capable agents; open stored session sends load when supported / skips when not; prompt preflight loads non-materialized session.SessionIsolationTests (:53-330): tool confirmation stays with its session across switches; messages preserved; placeholder migration keeps VM instance; delete cleans VM; mode/streaming/prompt text isolated; lazy VM creation; coexistence; isStreaming reflects current session.ViewModelSyncTests (:90-346): agent info synced on add / ACP init / Codex init; capability change propagates; connected protocol; pending session delegation; list fetch defaults; session cwd updated on open, timestamp preserved; selecting another server doesn't overwrite its connection details.SessionStorageTests:5 empty cwd not persisted as root; ChatRenderingTests (:7-176): mapper kinds, file-change extraction, list diff insert/update/remove, scroll animation policy (first render, hydration, tail append, >3 inserts, streaming update, reorder).AgmenteUITests:63 Codex direct-WebSocket flow (opt-in), :44,53 launch smoke/perf.wiedymi/swift-acp + local spawn)#| File | Verdict | Notes |
|---|---|---|
ACPClient/Sources/ACPClient/ACP/PermissionRequestParsing.swift |
copy | Pure ACP.Value reads; swap the ACP.Value accessor names if swift-acp's JSON enum differs. Keep the proceed_*/cancel alias map. |
ACP/ACPMessageBuilder.swift |
adapt | Replace ACP.AnyResponse/ACPError/InitializedNotification.name with swift-acp's response + error types; keep the two outcome shapes verbatim. |
ACP/SessionUpdateParsing.swift + SessionUpdateHandler.swift |
adapt (extend) | Keep shape; add plan (entries[]{content,status,priority} → replace-whole-plan event), usage_update, session_info_update; make tool_call/tool_call_update carry content[] (text/diff/terminal), locations[], rawInput, rawOutput; make extractText concatenate all text blocks instead of returning the first; drop the "unknown kind with text → agent message" fallback. |
ACP/ResponseDispatcher.swift, SessionResponseParsing.swift, SessionListParsing.swift, InitializeParsing.swift, Models/SessionConfigOption.swift, Models/AgentInfo.swift |
adapt | Logic is protocol-correct and well tested; only the ACP.Value surface changes. Strip Codex userAgent detection and AgentBehaviorRules (qwen/claude version gates) if you only host Claude Code/Codex/Gemini locally. |
ACP/PromptBuilder.swift, ACPServiceModels.swift, ACPMethods.swift |
adapt | Payload builders are trivial; swift-acp likely has typed request structs — use those instead and keep only the capability-warning logic. |
ACP/ACPService.swift, ACPClientManager.swift, ACPClient.swift, Support/*, Models/ACPWireMessage.swift, Models/JSONRPC*.swift |
skip | WebSocket transport, reconnect/backoff, X-Client-Id, Cloudflare headers, brace-depth framing — all replaced by swift-acp's stdio transport over a spawned process. Keep only the idea of materializedSessions reset on transport restart. |
Agmente/AppViewModel.swift:2865-3038 (ChatMessage, AssistantSegment, ToolCallDisplay, codable mirrors) |
adapt | Copy the shape; add contentBlocks: [ToolCallContent] (text/diff/terminal), locations, rawInput, and a PlanSegment {entries} kind; make toolCallId non-optional for ACP. |
Agmente/ACPSessionViewModel.swift |
adapt | Keep ensureStreamingAssistantMessage, appendAssistantText, appendToolCall, applyToolCallUpdate (change fallback (3) so an update with an unknown id never lands on the last tool row), appendUserChunk, permission bookkeeping, cancelPendingPermissionRequests. Drop the hasSuffix("characters)") hack and the Codex approval fields unless you host Codex app-server. Remove fs/* + terminal/* refusals — a Mac host should implement them. |
Agmente/ChatRendering/ChatEntry.swift, ChatEntryMapper.swift, ChatRenderDiff.swift, ChatHeightCache.swift, ChatScrollAnimationPolicy.swift, ChatMarkdownPackageCache.swift, ChatTranscriptState.swift |
copy | Platform-neutral. Fix the mapper so file-change tool calls without a diff still get a tool row. |
HighPerformanceChatListView/*.swift |
adapt (port) | Everything is #if canImport(UIKit). Port to AppKit: NSTableView/NSCollectionView or ListViewKit's macOS support if it exists; MarkdownView 3.6.2 does ship macOS targets (Litext/Highlightr are cross-platform) — verify before relying on it. Keep the deferral-while-scrolling, height cache, render-token, and thought-expansion salt patterns. |
ToolCallRowView.swift |
adapt | Port measurements; actually use iconForToolKind/colorForToolKind; add completed/failed glyphs and a spinner for in_progress; render content[] diff blocks. |
FileChangesRows.swift, FileChangesSummaryView.swift |
adapt | Keep dedupe; add a real diff view (hunk parsing, +/- colouring, line numbers). |
PlanModeViews.swift |
copy (Codex) / adapt (ACP) | UserInputQuestionsSheet is reusable as-is. For ACP plans build a checklist view from entries[] (status pending/in_progress/completed, priority high/medium/low). |
SessionSupplementalViews.swift |
copy | groupedThoughtSegments, truncatedToolOutput, truncatedLabel, bubbles. Replace MarkdownText with the MarkdownView-backed renderer on macOS. |
SessionDetailView.swift |
adapt | macOS composer, mode picker, config-option controls, command picker, working-directory sheet, ⌘↩/⌘. wiring are directly reusable; remove PhotosUI. |
PromptComposerCommands.swift, SessionWindowStore.swift, AgmenteApp.swift:20-29 |
copy | Menu commands via FocusedValues; multi-window session payload store. |
ServerViewModel.swift, ServerViewModelProtocol.swift |
adapt | Placeholder-session lifecycle, session/list per-cwd fan-out, load/resume preflight, mode/commands caches are valuable; replace connection plumbing with "agent process handle" and per-process materialization. |
CodexServerViewModel.swift, CodexSessionDetailView.swift, AppServerClient/*, CodexSessionLogger.swift, specs/codex-load-resume-merge.md, fixtures |
copy (if hosting Codex app-server) | The thread hydration/merge machinery and its JSON fixtures are the most battle-tested part of the repo. If you only run codex via ACP (codex-acp), skip. |
SessionStorage.swift, Persistence.swift |
adapt or skip | Core Data with segmentsData JSON blob; replace with SwiftData/JSON files. Keep "never persist placeholder ids" and "never overwrite with nil" rules. |
AppViewModel.swift (rest), ContentView.swift, ServerManagementViews.swift, SettingsView.swift |
skip | Multi-remote-server management, Cloudflare Access fields, add-server validation probes. |
e2e/, .agents/skills/*, .github/skills/*, AgmenteUITests |
adapt | The scenario/backends/assertions split and the "execute-only, always clean up, accessibility ids as contract" discipline transfer directly; replace stdio-to-ws + simulator with spawned CLI + macOS XCUITest. |
ACPClient/Tests/*, AgmenteTests/* |
adapt | Port the handler/dispatcher/permission/session-lifecycle tests with swift-acp types; keep the Codex merge fixtures byte-for-byte. |
Suggested port order (each step is independently testable against the oracle in §G):
ChatMessage/AssistantSegment/ToolCallDisplay + new content-block/plan types) and ACPSessionViewModel fold, driven by SessionUpdateHandlerTests + AgentViewModelTests semantics rewritten for swift-acp's typed SessionUpdate.ChatEntry/ChatEntryMapper/ChatRenderDiff/height cache/scroll policy (copy, pass ChatRenderingTests).MarkdownView rows on macOS, thought chip, tool-call row with real kind icons and diff blocks.ChatEntryActionHandlers, then session/cancel semantics (cancel pending permissions first).ServerViewModel placeholder → materialized) mapped onto a spawned-process handle; ServerViewModelTests + SessionIsolationTests are the oracle.CodexServerViewModel + fixtures verbatim).SessionDetailView.swift:181-187, HighPerformanceChatListView.swift:1,558-602. The macOS fallback inside ChatTranscriptContainerView (plain Text) is dead code. ChatTranscriptContainerView.swift:97-199.AttributedString; every line gets two trailing spaces appended (hard breaks), which also mangles fenced code. SessionSupplementalViews.swift:208-219.plan updates are silently dropped (no entries handling; extractText reads content). SessionUpdateHandler.swift:112-160, SessionUpdateParsing.swift:59-77. usage_update / session_info_update likewise. Any unknown kind that happens to carry content text is rendered as agent prose. SessionUpdateHandler.swift:152-158.tool_call(_update).content[]: only the first text block is read; diff blocks (path/oldText/newText), terminal blocks, locations, rawInput are ignored; output is replaced not appended. SessionUpdateParsing.swift:66-75,114-117, ACPSessionViewModel.swift:767-769.applyToolCallUpdate falls back to the last tool-call segment when the id doesn't match, so an update for a tool call that was never announced (or lives in an earlier message) mutates the wrong row. ACPSessionViewModel.swift:732-748.appendAssistantText starts a new segment whenever the previous text ends with "characters)" — an undocumented coupling to the truncation marker. ACPSessionViewModel.swift:821-825.ToolCallRowView never uses its kind→icon/colour tables; every row shows a hammer; no completed/failed/in-progress indicator. ToolCallRowView.swift:227,779-804.ChatEntryMapper removes them from content segments and FileChangeSummary.items skips entries with empty diff, so an in-progress edit (ACP kind edit, or title Edit: …) renders nothing until a diff arrives. Same in both SwiftUI paths. ChatEntryMapper.swift:34-36,63-67, FileChangesSummaryView.swift:13-56, SessionDetailView.swift:749-753, CodexSessionDetailView.swift:1006-1017.CodexSessionDetailView never sets .focusedSceneValue(\.promptComposerActions). PromptComposerCommands.swift:23-43, SessionDetailView.swift:518 (sole usage)."notifications/initialized", AppServerMethods.initialized = "initialized", add-server validation sends the SDK's InitializedNotification.name. CodexServerViewModel.swift:494, AppServerMethods.swift:3, ACPMessageBuilder.swift:6. Also approval policy string "on-request" in the VM vs onRequest in the package. CodexServerViewModel.swift:142, AppServerPayloads.swift:49.AppServerClient transport/service/event-parser are dead at runtime; CodexServerViewModel re-implements parsing over raw JSON (Item enum, parseThreadItem). Two identical 529-line URLSessionWebSocketProvider.swift copies. CodexServerViewModel.swift:5-8,3836-3906.ACPClientManager for all servers → one connection, server switch = disconnect (acknowledged TODO). AppViewModel.swift:363.Task { @MainActor } from the socket task; two chunks can in principle be applied out of order under contention. ACPClient.swift:294-300, ACPService.swift:129-146.session/request_permission is attached to the selected session VM regardless of the request's sessionId; a permission for a background session lands in the foreground transcript. AppViewModel.swift:2343-2344, ACPSessionViewModel.swift:517-536.AppViewModel.withTimeout and sendRawRequest are unused. AppViewModel.swift:2090,2471.ACPClientManager.swift:443-447.session/load {sessionId:"capability-probe"} to probe support — a side-effecting probe. AppViewModel.swift:728-733.authMethods parsed but authenticate is never issued; auth-required errors are just logged. InitializeParsing.swift:45-58.PromptBuilder.swift:133-136, CodexServerViewModel.swift:1959-1961.ThoughtRowView.configure(isStreaming _:) ignores its parameter; streaming-expansion is decided by the list from entry.isStreaming. MarkdownRows.swift:110-117, HighPerformanceChatListView.swift:215-217.HighPerformanceChatListView.render is always called with animated: false, so diffable animations never run. ChatTranscriptContainerView.swift:73-77.SessionSidebarView (connection form + FS/terminal toggles) is a leftover dev panel not reachable from ContentView. Agmente/SessionSidebarView.swift.Agmente/AGENTS.md:61 embeds the author's absolute path /Users/lvpeng/...; docs/acp-agent-compatibility.md likewise [docs].ChatMessage.sanitizedUserContent strips a Codex-injected "## My request for Codex:" prefix from user echoes — worth knowing when comparing transcripts. AppViewModel.swift:2872,2929-2934.# Agmente extraction — rendering, view-model, permission UI, sessions, Codex app-server
Source: `rebornix/Agmente` @ `87f224e7d5884d450f4d54cc1d72724416e6d750` (2026-03-21, "Enhance session handling in ServerViewModel and add tests for session re-materialization"), MIT. Local clone `/Users/robertboulos/projects/cloned-repos/Agmente`. All paths below are relative to that root; every claim is `[src]` unless tagged `[docs]`.
Read this first — the five things that change how you use this repo:
1. The "high-performance" transcript (`ListViewKit` + `MarkdownView`) is **UIKit-only**. On macOS `SessionDetailView` falls back to a SwiftUI `LazyVStack` bubble path, and `MarkdownText` there is `AttributedString(markdown:)` inline-only (no code blocks, no headings). `Agmente/SessionDetailView.swift:181-187`, `Agmente/ChatRendering/HighPerformanceChatListView/HighPerformanceChatListView.swift:1,558-602`, `Agmente/SessionSupplementalViews.swift:195-239`.
2. The package-level ACP fold (`ACPSessionUpdateHandler`) is shallow: it emits text/tool events only. ACP `plan`, `usage_update`, `session_info_update`, `tool_call.content[]` kinds `diff`/`terminal`, `locations`, `rawInput` are never read. `ACPClient/Sources/ACPClient/ACP/SessionUpdateHandler.swift:112-160`.
3. There is no unified-diff renderer. `ChatRenderDiff` is a list-row diff, not a text diff. Diffs are shown as raw monospaced `Text`. `Agmente/ChatRendering/ChatRenderDiff.swift:12-36`, `Agmente/FileChangesSummaryView.swift:207-214`.
4. The app never spawns a process (0 hits for `Process(`/`NSTask`/`posix_spawn` in sources). Transport is `URLSessionWebSocketProvider` only; agents are bridged by `@rebornix/stdio-to-ws` and optionally a Cloudflare Tunnel. `ACPClient/Sources/ACPClient/Support/URLSessionWebSocketProvider.swift`, `docs/remote-agent.md:12-30` [docs].
5. The `AppServerClient` package's transport/service/event-parser is **not used at runtime**. Codex traffic goes through `ACPService` + a JSON-RPC shim, and `CodexServerViewModel` re-parses raw JSON. `ACPClient/Sources/ACPClient/Codex/ACPService+CodexJSONRPC.swift:1-17`, `Agmente/CodexServerViewModel.swift:5-8,2614-2620`.
---
## A. Topology
### Targets and platforms
| Item | Value | Cite |
|---|---|---|
| App targets | `Agmente`, `AgmenteTests`, `AgmenteUITests` | `Agmente.xcodeproj/project.pbxproj:150,173,196` |
| `SUPPORTED_PLATFORMS` | `iphoneos iphonesimulator macosx` (native macOS, not Catalyst) | `project.pbxproj:469` |
| Deployment targets | iOS 18.0, macOS 15.0 | `project.pbxproj:366,464` |
| `SWIFT_VERSION` | 5.0 (app target; packages are `swift-tools-version: 6.0`) | `project.pbxproj:473`, `ACPClient/Package.swift:1` |
| Sandbox | `ENABLE_APP_SANDBOX = YES`, bundle `com.example.Agmente` | `project.pbxproj:445,466` |
| Persistence | Core Data model `Agmente` (`StoredServer`, `StoredSession`, `StoredMessage`) | `Agmente/Persistence.swift:28`, `Agmente/SessionStorage.swift:13-32` |
### Local packages
| Package | Platforms | Deps | Purpose |
|---|---|---|---|
| `ACPClient` | iOS 17 / macOS 13 | `rebornix/acp-swift-sdk` @ `branch: main` (pinned rev `b800b3f`) | WebSocket transport + typed ACP service, parsers, response dispatcher. Also carries the Codex JSON-RPC shim and a hand-rolled `JSONRPC.swift`. `ACPClient/Package.swift:8-11,19`, `Agmente.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` |
| `AppServerClient` | iOS 17 / macOS 10.15, no deps | Typed Codex app-server methods/payloads/parsers/event parser + its own copy of `URLSessionWebSocketProvider`. Only the model structs (`AppServerModel`, `AppServerReasoningEffortOption`, `AppServerSkill`, `AppServerSkillScope`) are imported by the app. `AppServerClient/Package.swift:7-17`, `Agmente/CodexServerViewModel.swift:5-8` |
### External dependencies (why each exists)
| Dep | Version | Why |
|---|---|---|
| `Lakr233/ListViewKit` | 1.1.8 | Diffable UIKit list (`ListView`, `ListViewDiffableDataSource`, `ListRowView`, `ListViewAdapter`) driving the iOS transcript. `project.pbxproj:643-646`, `HighPerformanceChatListView.swift:59-60,353-411` |
| `Lakr233/MarkdownView` | 3.6.2 | `MarkdownTextView`, `MarkdownParser`, `MarkdownTheme`, `PreprocessedContent` — pre-parsed markdown rows with off-main parsing and content-offset binding. `project.pbxproj:651-654`, `Agmente/ChatRendering/ChatMarkdownPackageCache.swift:2-3,45-46` |
| `rebornix/acp-swift-sdk` | main @ b800b3f | Supplies `ACP.Value`, `ACP.ID`, `ACP.AnyRequest/AnyResponse/AnyMessage`, `ACPError`, `InitializedNotification`. Usage counts: `ACP.Value` ×109, `ACP.ID` ×30, `ACP.AnyResponse` ×30, `ACPError` ×17, `ACP.AnyRequest` ×16, `ACP.AnyMessage` ×11 (grep). |
| `swift-log` 1.9.1, `swift-system` 1.6.4 | transitive | Not imported by any Agmente/ACPClient/AppServerClient source (packages use their own `ACPLogger`/`PrintLogger`, `ACPClient/Sources/ACPClient/Support/Logging.swift`). Pulled in by `acp-swift-sdk`. |
| Transitive via MarkdownView | Highlightr 2.3.0, Litext 0.5.6, LRUCache 1.2.1, MSDisplayLink 2.0.8, SpringInterpolation 1.4.0, swift-cmark, swift-collections 1.3.0, SwiftMath 1.7.3 | Syntax highlighting, text layout, display-link throttling, math. `Package.resolved` |
### How the app reaches agents
- Only `WebSocketProviding` implementations: `URLSessionWebSocketProvider` and `MockWebSocketProvider`. `ACPClient/Sources/ACPClient/Support/WebSocket.swift:6`, `Support/URLSessionWebSocketProvider.swift:5`, `Sources/ACPClientMocks/MockWebSocket.swift:44`.
- Connect: headers = optional `Authorization: Bearer` (token provider) + `CF-Access-Client-Id/Secret` + persistent `X-Client-Id` (UUID stored in UserDefaults `ACPClientManager.clientId`). `ACPClient.swift:45-62,158-169`, `ACPClientManager.swift:143-162,469-486`.
- Outbound JSON gets a trailing `\n` by default (`appendNewline: true`) for stdio bridges. `ACPClientConfiguration.swift:10-11,18`.
- Inbound framing: try single JSON, then newline-split (all-or-nothing), then a brace-depth/string-aware scanner over a byte buffer for fragmented frames. `ACPClient.swift:171-271`.
- Slash-escape knob: `setWithoutEscapingSlashesEnabled` because `codex-acp` rejects `session\/list`. `ACPClient.swift:88-101`, `Models/AgentInfo.swift:50-55`.
- Heartbeat ping every `pingInterval` (15s from the app), health check ping with 8s timeout on resume. `ACPClient.swift:139-156`, `ACPClientManager.swift:88,403-432`, `AppViewModel.swift:1551`.
- Bridge command shape [docs]: `npx -y @rebornix/stdio-to-ws --persist --grace-period 604800 "<agent cmd>" --port 8765`; `--persist` + `X-Client-Id` keeps the child alive across iOS backgrounding. `docs/remote-agent.md:12-26`, `Agents.md:127`.
---
## B. The fold: `session/update` → chat items
### Package layer (`ACPClient`) — thin
- Parse: `sessionId = params.sessionId`; `update = params.update ?? params.sessionUpdate`; `kind = update.sessionUpdate ?? update.type`. `ACP/SessionUpdateParsing.swift:5-11`.
- `extractText(from:)` returns the **first** of: `content` string, `content.text`, first `content[]` element's `.content.text` or `.text`. Multi-block content is truncated to one block. `SessionUpdateParsing.swift:59-77`.
- `toolCallOutput = rawOutput ?? extractText`. `SessionUpdateParsing.swift:114-117`.
- Event enum `ACPSessionUpdateEvent`: `agentThought(text)`, `userMessage(text)`, `agentMessage(text)`, `toolCall(ACPToolCallInfo)`, `toolCallUpdate(ACPToolCallUpdate)`, `modeChange(modeId)`, `configOptionsUpdate([ACPSessionConfigOption])`, `availableCommandsUpdate([SessionCommand])`. `ACP/SessionUpdateHandler.swift:8-31`.
- `ACPToolCallInfo { toolCallId?, title, kind?, status }` (status defaults `"pending"`); `ACPToolCallUpdate { toolCallId?, status?, title?, kind?, output? }`. `SessionUpdateHandler.swift:36-65,164-192`.
- `handle(params:activeSessionId:)` filters by session; nil kind → text fallback → `.agentMessage`. `SessionUpdateHandler.swift:91-109`.
- Kind switch handles exactly: `agent_thought_chunk`, `user_message_chunk`, `agent_message_chunk`, `tool_call`, `tool_call_update`, `current_mode_update`, `config_option_update`, `available_commands_update`. **Unknown kinds with any extractable text are emitted as `agentMessage`**; otherwise dropped. `SessionUpdateHandler.swift:112-160`.
- Not handled anywhere (grep 0 in `ACPClient/Sources` + `Agmente`): ACP `plan` (`entries[]`, `status`, `priority`), `usage_update`, `session_info_update`, `locations`, `rawInput`, `content[]` kinds `diff` / `terminal`. A `plan` update has no `content` key, so it yields zero events. The only `case "plan":` in the ACP package is the log summarizer. `SessionUpdateParsing.swift:24-26`.
- `available_commands_update` → `SessionCommand(id:name, name, description, inputHint: input.hint)`. `SessionUpdateHandler.swift:194-205`.
- `config_option_update` → `ACPSessionConfigOptionParser.parse(from: update)` (reads `configOptions[]`; `type` select/boolean/flag; `options[]` may be grouped; a `mode` id/category is the mode selector). `Models/SessionConfigOption.swift:94-187`.
### App data model (`Agmente/AppViewModel.swift`)
| Type | Fields | Cite |
|---|---|---|
| `ChatMessage: Identifiable, Equatable` | `id: UUID`, `role: Role {user, assistant, system}`, `content: String`, `isStreaming`, `segments: [AssistantSegment]`, `images: [ChatImageData]`, `isError` | `AppViewModel.swift:2865-2939` |
| `ChatMessage.sanitizedUserContent` | strips everything before `"## My request for Codex:"` on user rows | `AppViewModel.swift:2872,2929-2934` |
| `AssistantSegment: Identifiable, Equatable` | `id: UUID`, `kind: Kind {message, thought, toolCall, plan}`, `text`, `toolCall: ToolCallDisplay?` | `AppViewModel.swift:3002-3021` |
| `ToolCallDisplay: Equatable` | `toolCallId?`, `title`, `kind?`, `status?`, `output?`, `permissionOptions: [ACPPermissionOption]?`, `acpPermissionRequestId: ACP.ID?`, `permissionRequestId: JSONRPCID?`, Codex-only `approvalRequestId: JSONRPCID?`, `approvalKind?`, `approvalReason?`, `approvalCommand?`, `approvalCwd?` | `AppViewModel.swift:3023-3038` |
| `CodableSegment` / `CodableToolCall` | persistence mirror; permission/approval fields deliberately not persisted | `AppViewModel.swift:2942-3000` (comment at 2989) |
| `ChatImageData` | `id`, `thumbnail: UIImage` (200px), `mimeType`; equality by id | `Agmente/ImageAttachment.swift:227-242` |
| `PendingUserInputRequest`, `UserInputQuestion`, `UserInputOption` | Codex plan-mode questions | `Agmente/PlanModeViews.swift:6-32` |
| `SessionSummary` | `id`, `title?`, `cwd?`, `updatedAt?` | `ACPClient/Sources/ACPClient/Models/SessionSummary.swift:3-15` |
| `AgentProfile` / `AgentCapabilityState` / `PromptCapabilityState` / `AgentModeOption` / `SessionCommand` | initialize result model; `AgentBehaviorRules` hardcodes qwen-code cwd requirement and claude version warnings | `Models/AgentInfo.swift:7-130,206-275` |
### App-side fold (`Agmente/ACPSessionViewModel.swift`, `@MainActor`)
- Entry: `handleChatUpdate` re-checks session id, runs handler, applies each event. `ACPSessionViewModel.swift:346-358,669-715`.
- **Streaming message**: `ensureStreamingAssistantMessage()` returns the index of the message whose id is `streamingMessageId`; if a *user* row was appended after it, the pointer is considered stale, the old row is un-streamed and a fresh assistant row is appended. `ACPSessionViewModel.swift:931-949`.
- **`agent_message_chunk` / `agent_thought_chunk` merge**: `appendAssistantText(text, kind:)` — if the message has no segments but has plain `content` (restored snapshot), seed a `.message` segment first; then if the last segment has the same kind and no toolCall, `text.append(delta)`, else push a new segment. Quirk: if the last segment's text ends with `"characters)"` (the truncation marker), a new segment is started instead of appending. `ACPSessionViewModel.swift:804-831`.
- **`tool_call`** → `appendToolCall`: search the streaming message's segments by `toolCallId`; if found overwrite `title` (if non-empty), `kind` (if non-empty), `status` (always), and rewrite segment text to `"[kind] title"`; else append `AssistantSegment(kind: .toolCall, text: "[kind] title", toolCall:)`. `in_progress`/`pending` set `isStreaming = true`. `ACPSessionViewModel.swift:879-929`.
- **`tool_call_update`** → `applyToolCallUpdate`. Target resolution order: (1) segment with same `toolCallId`; (2) if id + non-empty title → new segment; (3) else the **last tool-call segment**, unless incoming title/kind differ (then new segment if title non-empty); (4) else new segment if title. Then `status = update.status ?? old`, `kind = update.kind ?? old`, `title = update.title ?? old`; `output` **replaces** (`= output`), never appends. `ACPSessionViewModel.swift:717-773`.
- Codex reuses the same path via `upsertToolCallFromAppServer(toolCallId:title:kind:status:output:)`. `ACPSessionViewModel.swift:862-877`.
- **`user_message_chunk`** (replay from `session/load`): sanitize; if a streaming row exists and the previous user row already contains the text, drop it (dedupe), else finish streaming; append to last user row or create one. `ACPSessionViewModel.swift:775-802`.
- `current_mode_update` → `currentModeId` + per-(server,session) mode cache + delegate. `ACPSessionViewModel.swift:695-701,184-207`.
- `config_option_update` → `applySessionConfigOptions` (also derives modes from the `mode` selector). `ACPSessionViewModel.swift:172-186`.
- `available_commands_update` → replace list, cache per session, clear stale selection. `ACPSessionViewModel.swift:216-238`.
- **Plan (Codex only)**: `completePlanItem(id:text:)` replaces the last `.plan` segment's text or appends one (replace semantics); deltas go through `appendAssistantText(kind: .plan)`. `ACPSessionViewModel.swift:843-854`; callers `CodexServerViewModel.swift:3154-3183,3427-3460`.
- `rebuildAssistantContent(at:)` regenerates `content` as joined lines: message/thought/plan text verbatim; tool calls as `"Tool call: [kind] title (status)"`. This string is what persists and what `ChatEntryMapper.resolvedSegments` re-parses when `segments` is empty. `ACPSessionViewModel.swift:972-1000`, `Agmente/ChatRendering/ChatEntryMapper.swift:80-151`.
- `saveChatState()` after every mutation → in-memory cache + Core Data (non-streaming rows only). `ACPSessionViewModel.swift:1079-1090`, `AppViewModel.swift:1394-1408`.
### ACP `sessionUpdate` kind coverage (package handler + app VM combined)
| ACP kind | Package event | App mutation | Missing |
|---|---|---|---|
| `agent_message_chunk` | `.agentMessage(text)` (first text block only) | append to last `.message` segment of streaming row | multi-block content, `image`/`audio`/`resource` blocks |
| `agent_thought_chunk` | `.agentThought(text)` | append to last `.thought` segment | same |
| `user_message_chunk` | `.userMessage(text)` | replay dedupe → append/create user row, ends streaming row | images in replay |
| `tool_call` | `.toolCall(id,title,kind,status)` | upsert tool segment by id; `isStreaming=true` if pending/in_progress | `content[]`, `locations[]`, `rawInput` |
| `tool_call_update` | `.toolCallUpdate(id,status?,title?,kind?,output?)` | merge; output replaced | `content[]` kinds `diff`/`terminal`, `locations`, `rawInput`, `rawOutput` when non-string |
| `plan` | none (falls to text fallback; no `content` key → nothing) | — | whole feature |
| `available_commands_update` | `.availableCommandsUpdate([SessionCommand])` | replace list, cache, clear stale selection | — |
| `current_mode_update` | `.modeChange(modeId)` | `currentModeId`, mode cache, delegate log | — |
| `config_option_update` | `.configOptionsUpdate([...])` | replace options; derive modes from `mode` selector | — |
| `usage_update` | none | — | token/cost display |
| `session_info_update` | none | — | title/metadata refresh |
| anything else with `content` text | `.agentMessage(text)` | rendered as agent prose | should be dropped/logged |
Cites: `SessionUpdateHandler.swift:112-160`, `ACPSessionViewModel.swift:669-773,775-831,879-929`.
### Worked delta sequence (what the model looks like after each event)
1. `session/prompt` sent → `addUserMessage` + `startNewStreamingResponse` (empty assistant row, `isStreaming=true`, `streamingMessageId` set). `ServerViewModel.swift:1190-1193`, `ACPSessionViewModel.swift:1056-1062`.
2. `agent_thought_chunk "Let me"` → segments `[thought:"Let me"]`; content rebuilt `"Let me"`.
3. `agent_thought_chunk " check"` → `[thought:"Let me check"]` (same kind, appended).
4. `tool_call {id:tc1,title:"git status",kind:execute,status:pending}` → `[thought, toolCall(tc1,pending)]`; segment text `"[execute] git status"`.
5. `session/request_permission` (request, id 7) → tc1 gets `permissionOptions`, `acpPermissionRequestId=7`, status `awaiting_permission`; row shows buttons. `ACPSessionViewModel.swift:493-536`.
6. User taps Allow → options cleared, status back to `pending`, response `{outcome:{outcome:selected,optionId}}` sent. `ACPSessionViewModel.swift:361-384,613-638`.
7. `tool_call_update {id:tc1,status:in_progress}` → status only. 8. `tool_call_update {id:tc1,status:completed,rawOutput:"On branch main"}` → status + `output`.
9. `agent_message_chunk "Clean tree."` → new `.message` segment (last segment is a tool call, so no merge). `ACPSessionViewModel.swift:817-828`.
10. `session/prompt` response `{stopReason:end_turn}` → `.stopReason` action → `handleStopReason` → `isStreaming=false`, `stopReason` cached. `ResponseDispatcher.swift:183-185`, `ACPSessionViewModel.swift:1140-1145`.
11. Persisted `content` string: `"Let me check\nTool call: [execute] git status (completed)\nClean tree."` — this is what re-parses if segments are lost. `ACPSessionViewModel.swift:972-1000`.
### Responses (`ACPResponseDispatcher`)
- `dispatchSuccess(result:method:context:)` returns `[ACPResponseAction]`: `sessionMigrated(from:to:)` (placeholder → real id on `session/new`), `sessionActivated(ACPSessionActivation{sessionId,cwd,modes,configOptions})`, `sessionMaterialized`, `modeChanged`, `configOptionsChanged`, `initialized(ACPInitializeResult)`, `stopReason(String)` (any result with `stopReason`), `sessionListReceived`, `sessionLoadCompleted`, `capabilityConfirmed(.listSessions)`. `ACP/ResponseDispatcher.swift:8-44,133-200`.
- `dispatchError`: `-32601` on `session/load|resume|list` → `capabilityDisabled`. No handling for `-32000`/auth-required (grep 0). `ResponseDispatcher.swift:208-233`.
- `stopReason` → `handleStopReason` sets `stopReason`, `finishStreamingMessage()`, notifies delegate. `ACPSessionViewModel.swift:1140-1145`; applied at `AppViewModel.swift:2392-2394`.
### Threading
- Socket receive loop is a detached `Task`; every inbound message/state/error is forwarded with its own `Task { @MainActor in delegate?... }`. `ACPClient.swift:109-137,286-308`.
- `ACPService` hops again to `MainActor` for delegate calls and resumes continuations inside a `PendingRequestStore` actor; request ids from a `RequestIDSequence` actor (`.int(counter)`). `ACPService.swift:121-151,179-205`.
- `ACPClientManager`, `ACPSessionViewModel`, `ServerViewModel`, `CodexServerViewModel`, `AppViewModel` are all `@MainActor`. `ACPClientManager.swift:67`, `ACPSessionViewModel.swift:62`, `ServerViewModel.swift:12`, `CodexServerViewModel.swift:12`, `AppViewModel.swift:10`.
- Consequence: there is no explicit FIFO guarantee between independently spawned MainActor tasks for successive chunks (see §I.13).
- `AppViewModel.handleIncoming` is the single switchboard: responses → dispatcher; notifications → Codex VM (if Codex) then `session/update` → `handleChatUpdate` (also bumps session timestamp); requests → Codex approvals / `session/request_permission` / `fs/*` / `terminal/*` else `-32601`. `AppViewModel.swift:2242-2356`.
---
## C. Rendering
### Two renderers, chosen by platform
- iOS: `ChatTranscriptContainerView` (`UIViewRepresentable`) → `HighPerformanceChatListView` (UIKit + ListViewKit). macOS: `SessionDetailView.chatTranscript` — plain SwiftUI `ScrollView { LazyVStack { chatBubble } }`. `SessionDetailView.swift:181-187,199-282`; same split in `CodexSessionDetailView.swift:235-241,304-381`.
- The macOS branch of `ChatTranscriptContainerView`/`HighPerformanceChatListView` is a `Text(message.content)` stub reachable only from the DEBUG gallery, which itself is `#if DEBUG && canImport(UIKit)`. `ChatTranscriptContainerView.swift:97-199`, `HighPerformanceChatListView.swift:558-602`, `Agmente/Debug/ChatComponentGalleryView.swift:1`.
- No source comment states *why* ListViewKit; the design evidence: a dedicated `updateQueue` (`qos: .userInteractive`) that maps messages → entries and pre-parses markdown off-main, then diffs on main; per-row height cache; a sizing `MarkdownTextView` with `throttleInterval = 1/60`; snapshot application deferred while the user is dragging. `HighPerformanceChatListView.swift:57,72-76,151-177,258-306`. A removed UserDefaults toggle `Agmente.useHighPerformanceChatRenderer` shows it was once optional. `AppViewModel.swift:228,320`.
### Entry model and diffing (`Agmente/ChatRendering/`)
- `ChatEntry.Kind`: `userText, userImages, assistantMarkdown, assistantThought, assistantPlan, toolCall, fileChanges, system, error, streamingIndicator`. Stable ids `"message.<uuid>.<kind>[.<segmentId>]"`; equality/hash = `id + contentHash`. `ChatEntry.swift:4-15,49-156`.
- `contentHash` inputs: text (markdown), `text + isStreaming` (thought/plan), 17-part signature for tool calls (id, title, kind, status, output, option ids/names, request ids, approval fields, isStreaming). `ChatEntry.swift:158-184`.
- `ChatEntryMapper.entries(from:)`: user → images row + text row; assistant → segments, with file-change tool calls **split out** into one `fileChanges` row; a `streamingIndicator` row appended while streaming (`suffix` `tail`/`solo`). `ChatEntryMapper.swift:4-78`.
- `ChatRenderDiff.make(old:new:)` = set difference on ids + `contentHash` inequality → `inserted/removed/updated` id lists; used only as an "anything changed?" gate before `applySnapshot`. `ChatRenderDiff.swift:12-36`, `HighPerformanceChatListView.swift:166-170`.
- `ChatHeightCache` keyed `(entryId, rounded width, contentHash)` under `NSLock`. `ChatHeightCache.swift:4-39`.
- `ChatMarkdownPackageCache`: `MarkdownParser().parse(content)` → `MarkdownTextView.PreprocessedContent(parserResult:theme:)`, cached by entry id + content hash + theme identity. `ChatMarkdownPackageCache.swift:15-53`.
- `ChatScrollAnimationPolicy`: animate only when the last message changed in place (streaming) or ≤3 tail appends with identical prefix ids; never on first render or history hydration. `ChatScrollAnimationPolicy.swift:4-33`.
### Streaming update path (iOS)
`render(messages:)` → background: map + pre-parse markdown → main: prune thought-expansion state, compute visible entries (expanded thoughts get `contentHash ^ 0x5F3759DF` so the row re-measures), diff, `applyEntries` → `dataSource.applySnapshot(using:animatingDifferences:)` → `scrollToBottom` if auto-scrolling. If the user is tracking/dragging/decelerating and not pinned, the snapshot is parked in `pendingEntries` and flushed on scroll end. `HighPerformanceChatListView.swift:56,143-178,224-306,333-350`. The container always passes `animated: false`, so `animatingDifferences` is effectively never true. `ChatTranscriptContainerView.swift:73-77`.
### Render loop, step by step (`HighPerformanceChatListView.render`)
1. SwiftUI `updateUIView` fires only when `lastMessages != messages` (array `Equatable`), computes `shouldAnimateScrollToBottom` via the policy, calls `render(messages:animated:false,scrollToBottomAnimated:)`. `ChatTranscriptContainerView.swift:59-83`.
2. On `updateQueue`: `mapper.entries(from:)` then pre-warm `markdownCache.package(...)` for every markdown/thought/plan entry (thoughts use the footnote theme). `HighPerformanceChatListView.swift:151-159`.
3. Hop to main: store `rawEntries`, prune expansion state, compute `visibleEntries` (salted hashes for expanded thoughts). `:161-165,224-233`.
4. `ChatRenderDiff.make(old: renderedEntries, new:)`; if empty, just try to flush parked entries. `:166-170`.
5. `applyEntries`: if the user is dragging and not pinned, park in `pendingEntries` (OR-ing animation flags); else `dataSource.applySnapshot(using:animatingDifferences:)` and `scrollToBottom` when pinned. `:258-286`.
6. ListViewKit asks `rowKindFor`, `listViewMakeRow`, `heightFor` (cache → measure), `configureRowView` (render tokens prevent re-parsing). `:353-556`.
7. Scroll-end/decelerate → `updateAutoScrollingFromCurrentOffset` → flush parked entries. `:196-203,338-346`.
### Third-party API surface actually used (find equivalents for AppKit)
| Package API | Used at |
|---|---|
| `ListViewKit.ListView` (+ `.delegate`, `.adapter`, `.contentInset`, `.maximumContentOffset`, `.scroll(to:)`, `.reloadData()`, `isTracking/isDragging/isDecelerating`) | `HighPerformanceChatListView.swift:59,100,109-126,180-191,285` |
| `ListViewDiffableDataSource<ChatEntry>.applySnapshot(using:animatingDifferences:)` | `:60,284,302` |
| `ListViewAdapter` (`rowKindFor`, `listViewMakeRow`, `heightFor`, `configureRowView`), `ListRowView` (`prepareForReuse`, `superListView`), `RowKind` | `:353-556`, `RowViewSupport.swift:5`, `MarkdownRows.swift:44,221` |
| `MarkdownParser().parse(_)`, `MarkdownTextView.PreprocessedContent(parserResult:theme:)` | `ChatMarkdownPackageCache.swift:45-46` |
| `MarkdownTextView` (`theme`, `throttleInterval`, `setMarkdown(_)`, `setMarkdownManually(_)`, `boundingSize(for:)`, `bindContentOffset(from:)`) | `MarkdownRows.swift:12-16,37,47`, `HighPerformanceChatListView.swift:72-76,439-462` |
| `MarkdownTheme` (`.default`, `fonts.body/bold/italic/footnote/codeInline/code`, `align(to:)`, `MarkdownTheme.codeScale`) | `HighPerformanceChatListView.swift:315-329` |
### Row views (`HighPerformanceChatListView/`)
- `BaseRowView` insets rows 14/12/14; `ActionButton` closure-target button. `RowViewSupport.swift:5-44`.
- `UserTextRowView`: right-aligned tinted bubble, max width `row - 50`. `UserRows.swift:4-42`. `UserImagesRowView`: 80×80 thumbnails right-aligned. `UserRows.swift:44-87`.
- `MarkdownRowView(style:)`: card (`assistant`/`thought` gray6, `plan` blue 8%), `setMarkdown(_, renderToken:)` skips re-render when token `"<id>#<hash>"` unchanged, binds content offset to the enclosing scroll view. `MarkdownRows.swift:6-50`, `HighPerformanceChatListView.swift:21-52,500-502`.
- `ThoughtRowView`: 34pt purple chip "Thinking" + chevron; expanded → full-width card with a footnote-weight `MarkdownTheme` (`makeThoughtMarkdownTheme`); expanded automatically while `entry.isStreaming`, toggle disabled while streaming; chevron/width animate 0.2s. `MarkdownRows.swift:52-247`, `HighPerformanceChatListView.swift:215-256,315-329`.
- `SystemRowView` centered capsule (info / red error), `StreamingIndicatorRowView` spinner + "Thinking…". `SystemRows.swift:4-104`.
- `ToolCallRowView` — see table. `ToolCallRowView.swift`.
| Aspect | Behaviour | Cite |
|---|---|---|
| Payload | title, status, kind, output, ACP request id + options, JSONRPC request id, Codex approval id/reason/command/cwd, isStreaming | `ToolCallRowView.swift:77-133` |
| Compact vs regular | compact when no output, no approval detail lines, no actions; compact command-like (`execute`/`command`/`shell`) uses monospaced 15pt (14pt if multi-line: contains `\n` or >72 chars) | `ToolCallRowView.swift:121-132,583-610` |
| Leading icon | **always** `hammer.fill` in orange-brown; `iconForToolKind`/`colorForToolKind` (read/edit/delete/move/search/execute/think/fetch) are defined but unused in this row | `ToolCallRowView.swift:227-228,779-804` |
| Status visual | only `awaiting_permission` (with options or approval id) → `exclamationmark.shield.fill` trailing icon; a spinner slot exists but is never started; no completed/failed glyph | `ToolCallRowView.swift:224-238,299,328-337` |
| Output | `UILabel` limited to 5 lines of `output.truncatedToolOutput(maxLines: 6, maxChars: 1200)` | `ToolCallRowView.swift:178,247-248`, `SessionSupplementalViews.swift:267-292` |
| Detail lines | `Reason:` / `Command:` / `CWD:` (Codex approvals) | `ToolCallRowView.swift:626-638` |
| Buttons | Approve/Decline row (Codex) then one button per ACP option, wrapped into rows by measured width; green for allow_*, red for reject_*, gray unknown | `ToolCallRowView.swift:463-581,742-777` |
| Height estimate | mirrors layout: title bounding rect + details + output + button rows (34pt each) + insets; min 58 (regular) / 34 (compact) | `ToolCallRowView.swift:384-461` |
| Expand/collapse | none; no tap-to-expand on tool rows | — |
- `FileChangesRowView`: "N file(s) changed" + `Undo` (bordered) + `Review` (prominent) + one `FileChangePreviewRowView` per unique path (dedupe by lowercased last path component, prefer the one with a separator / non-diff verb / longer path). `FileChangesRows.swift:5-251,166-231`.
- SwiftUI twins: `FileChangesSummaryView` (same dedupe) and `FileChangesReviewSheet` — diff shown as `Text(diff)` monospaced caption inside a gray card; no parsing, colours, line numbers or hunk headers. `FileChangesSummaryView.swift:73-243`.
- `FileChangeSummary.isFileChangeSegment`: kind token ∈ {edit,file,files,patch,diff,apply,write} (split on `.`, `/`, `-`, `_`→`-`) or title prefix ∈ {edit:,create:,delete:,rename:,move:,add:,update:}; `items(from:)` **skips any segment whose `output` is empty** (see §I.8). `FileChangesSummaryView.swift:13-56`.
- `Undo` → alert "Agmente can't undo file changes yet." `SessionDetailView.swift:171-175,326-328`.
### Markdown
- iOS: `MarkdownView` package (full CommonMark via swift-cmark, code highlighting via Highlightr, math via SwiftMath), parsed once per content hash off-main; streaming re-renders the whole row's `PreprocessedContent` (no incremental append API used). `ChatMarkdownPackageCache.swift:45-46`, `HighPerformanceChatListView.swift:155-159,500-502`.
- macOS / SwiftUI path: `MarkdownText` = `AttributedString(markdown:options: .inlineOnlyPreservingWhitespace)` after unescaping `\n`/`\t`, appending two spaces to every line (hard breaks), truncating at 400 lines / 20k chars, falling back to verbatim text above 20k. **No code blocks, headings, lists, tables.** `SessionSupplementalViews.swift:195-264`.
- `MarkdownStyle` paddings: assistant 10/8, thought & plan 10/10. `HighPerformanceChatListView.swift:21-52`.
### Plan rendering
- ACP `plan` updates: not modelled, not rendered (§B).
- Codex: `.plan` segments render as `MarkdownRowView(style: .plan)` on iOS, and as `ProposedPlanCard` on the SwiftUI path (blue card, "Proposed Plan" header, "Planning..." spinner while streaming, buttons **Implement this plan** → sends prompt `"Implement the plan."` with plan mode off, **Continue planning** no-op). `HighPerformanceChatListView.swift:398-399,455-462`, `PlanModeViews.swift:36-106`, `CodexSessionDetailView.swift:1031-1038,619-627`.
- `turn/plan/updated` steps are flattened to `"- step: description (status)"` lines. `CodexServerViewModel.swift:3193-3238`.
- Codex `item/tool/requestUserInput` → `UserInputQuestionsSheet`: paged `TabView`, single/multi-select rows, `isOther` free text (`SecureField` if `isSecret`), Skip submits `{}`; macOS frame 680×560 min. `PlanModeViews.swift:112-400`, `CodexSessionDetailView.swift:140-144`.
### Thought / reasoning
- iOS: collapsed chip per thought segment (above).
- SwiftUI ACP path: `thoughtBubble` — brain icon, caption markdown, `lineLimit(6)` when >240 chars or >5 lines with "Show more/less". `SessionDetailView.swift:1201-1246`.
- SwiftUI Codex path: `groupedThoughtSegments()` groups consecutive thoughts *plus any tool calls that follow them* into a `ThoughtGroup`; `thoughtGroupCard` auto-expands while streaming, and when streaming ends stays expanded only if the group contains a tool call. `SessionSupplementalViews.swift:99-190`, `CodexSessionDetailView.swift:1062-1129`.
### Terminal output, message actions, scroll
- Terminal: none. `terminal/*` requests are refused with `-32001`. `ACPSessionViewModel.swift:466-475`.
- Copy/retry: no buttons; only `.textSelection(.enabled)` on SwiftUI markdown (`SessionDetailView.swift:778`, `SessionSupplementalViews.swift:405,424`). Session rows on macOS get a context menu (Archive / Open in New Window). `ContentView.swift:635-647,1849-1861`.
- Follow-stream (iOS): `isAutoScrollingToBottom` cleared on drag begin, restored when offset is within `autoScrollTolerance` (4pt) of `maximumContentOffset`; `onAtBottomChanged` feeds a floating "scroll to bottom" button (glass on iOS 26/macOS 26). `HighPerformanceChatListView.swift:85-86,180-213,333-350`, `ChatTranscriptState.swift:38-44`, `SessionDetailView.swift:105-127`.
- Follow-stream (SwiftUI): `scrollPosition(id:anchor:.bottom)` + `onChange` of count/last content/last segments with 50–150ms `asyncAfter` nudges; `isAtBottom` threshold 80pt. `SessionDetailView.swift:199-320`.
### macOS vs iOS deltas
| Concern | macOS | Cite |
|---|---|---|
| Shell | `NavigationSplitView` (sidebar min/ideal 350) with `splitSelection`; iOS uses `NavigationStack` path | `ContentView.swift:22-44,65-71` |
| Windows | second `WindowGroup("Session", id:"session-detail-window", for: String.self)`; "Open in New Window" stores a `SessionWindowPayload` (model + VMs) in `SessionWindowStore` keyed `"<serverId>::<sessionId>"`, sets `NSWindow.title` via an `NSViewRepresentable` | `AgmenteApp.swift:20-29`, `SessionWindowStore.swift:4-93`, `ContentView.swift:552-593` |
| Keyboard | `CommandGroup(after: .textEditing)`: **Send Message ⌘↩**, **Cancel Response ⌘.** via `@FocusedValue(\.promptComposerActions)`; only `SessionDetailView` publishes the focused value (Codex view does not, §I.9) | `PromptComposerCommands.swift:22-43`, `SessionDetailView.swift:518` |
| Composer height | heuristic `charsPerLine = (width-20)/8`, 22pt lines, max 110 (iOS measures a `UITextView`) | `SessionDetailView.swift:32-58` |
| Menus | `.menuStyle(.borderlessButton)` on iOS vs `.buttonStyle(.plain)` on macOS for pickers | `CodexSessionDetailView.swift:712-717,798-803,861-866` |
| Sheets | fixed `frame(minWidth:…)` instead of `presentationDetents` | `PlanModeViews.swift:172-177`, `ContentView.swift:1527-1531` |
| Colours | `NSColor+UISemantic.swift` shims `Color(.systemGray6)`-style names | `Agmente/NSColor+UISemantic.swift:3-33` |
| Search | `.toolbar` placement | `ContentView.swift:650-659` |
---
## D. Permissions UI
### Parsing
- `ACPPermissionRequest { sessionId?, toolCallId?, toolCallTitle (default "Unknown operation"), toolCallKind?, options: [ACPPermissionOption{optionId,name,kind}] }`. `ACP/PermissionRequestParsing.swift:4-16,41-71`.
- `ACPPermissionOptionKind`: `allow_once`, `allow_always`, `reject_once`, `reject_always`, `unknown`; alternate spellings mapped: `proceed_once`→allowOnce, `proceed_always`→allowAlways, `cancel`→rejectOnce. `PermissionRequestParsing.swift:18-38`.
- Tests: `PermissionRequestParsingTests.swift:6-51`.
### Request → UI
- `AppViewModel.handleIncoming` routes `session/request_permission` to the **currently selected** session VM without checking the request's `sessionId`. `AppViewModel.swift:2343-2344`.
- `handlePermissionRequest`: record `pendingPermissionRequests[requestId] = (sessionId, toolCallId)`, then `updateToolCallWithPermission` — find the tool-call segment by id across *all* assistant messages, else in the streaming message, else append a new tool-call segment; set `permissionOptions`, `acpPermissionRequestId`, `status = "awaiting_permission"`. `ACPSessionViewModel.swift:409-433,493-536`.
- Presentation: buttons in server order, one per option, label `name.truncatedLabel(maxChars: 24)`, icon `checkmark.circle` (allow_*), `xmark.circle` (reject_*), `questionmark.circle` (unknown); green/red/gray palettes with explicit dark-mode RGB. iOS wraps by measured width; SwiftUI ACP uses an `HStack`; SwiftUI Codex uses `LazyVGrid(.adaptive(minimum: 102))`. `ToolCallRowView.swift:491-515,742-777`, `SessionDetailView.swift:970-1061`, `CodexSessionDetailView.swift:1370-1444`.
- No default/focused option, no keyboard handling (no `.keyboardShortcut`/`defaultAction` on option buttons), no timeout, no "auto approve" setting (grep `autoApprove|approveAll|yolo` only hits the mode-icon switch `SessionDetailView.swift:1173,1192`).
- Header text "Permission required" (SwiftUI ACP only). `SessionDetailView.swift:972-975`.
### End-to-end permission flow (ACP)
```
agent ─ request session/request_permission {sessionId, toolCall{toolCallId,title,kind,status}, options[]} ─▶ ACPClient.handleIncomingData
─▶ ACPService.acpClient(didReceiveMessage:) (.request is NOT resolved as a response; forwarded raw) ACPService.swift:140-144
─▶ ACPClientManager ─▶ AppViewModel.handleIncoming(.request) AppViewModel.swift:2326-2355
─▶ sessionViewModel.handlePermissionRequest(request) AppViewModel.swift:2343-2344
pendingPermissionRequests[id] = (sessionId, toolCallId) ACPSessionViewModel.swift:422
updateToolCallWithPermission → segment.toolCall.{permissionOptions, acpPermissionRequestId, status="awaiting_permission"}
─▶ ChatEntry.toolCall contentHash changes (option ids in signature) → row re-configured with buttons ChatEntry.swift:158-184
user taps option ─▶ ChatEntryActionHandlers.onACPPermissionResponse(requestId, optionId) HighPerformanceChatListView.swift:531-533
─▶ AppViewModel.sendPermissionResponse ─▶ sessionViewModel.sendPermissionResponse AppViewModel.swift:2081-2083
remove pending, clear options (status→"pending"), send ACPMessageBuilder.permissionResponseSelected
user taps Stop ─▶ AppViewModel.sendCancel ─▶ cancelPendingPermissionRequests(for: sessionId) (cancelled outcome per request)
─▶ session/cancel ─▶ abandonStreamingMessage AppViewModel.swift:2050-2069
```
### Response
- Selected → `{"outcome":{"outcome":"selected","optionId":…}}`; cancelled → `{"outcome":{"outcome":"cancelled"}}`; errors → `ACPError` by code. `ACP/ACPMessageBuilder.swift:9-45`; tests `ACPMessageBuilderTests.swift:6-57`.
- `sendPermissionResponse`: remove pending entry, `clearPermissionOptionsForToolCall` (options → nil, request id → nil, status `awaiting_permission` → `pending`), send. `ACPSessionViewModel.swift:361-384,613-638`.
- **Turn cancel**: `AppViewModel.sendCancel` → `cancelPendingPermissionRequests(for: sessionId)` sends a `cancelled` outcome for every pending request in that session, then `session/cancel`, then `abandonStreamingMessage()` (drops an empty streaming row or un-streams it). `AppViewModel.swift:2050-2069`, `ACPSessionViewModel.swift:387-406,1021-1037`.
- Codex approvals are a separate track: `item/commandExecution/requestApproval` / `item/fileChange/requestApproval` → `updateToolCallWithApproval` (fields `approvalRequestId/Kind/Reason/Command/Cwd`, status `awaiting_permission`); reply `{"decision":"accept"|"decline"[, "acceptSettings":{"forSession":bool}]}`; UI passes `acceptForSession: nil`. `ACPSessionViewModel.swift:538-611`, `CodexServerViewModel.swift:2852-2881,3636-3677`, `ToolCallRowView.swift:467-498`.
- Codex per-turn policy picker (`PermissionPreset`): Default → `approvalPolicy:"on-request"` + `sandboxPolicy:{type:"workspaceWrite"}`; Full access → `"never"` + `dangerFullAccess`; persisted per server in UserDefaults. `CodexServerViewModel.swift:124-158`, `AppViewModel.swift:471-490`, `CodexSessionDetailView.swift:825-868`.
---
## E. Codex app-server side
### Detection and handshake
- Server type is chosen explicitly in Add Server (`ServerType.acp|codexAppServer`) but also auto-detected from `initialize`: ACP markers (`protocolVersion`, `agentCapabilities`, `agentInfo`, `agent`) → `.acp`; else `userAgent` present → `.codexAppServer`; version parsed from `"codex/1.0.0"`. `ACP/InitializeParsing.swift:36-43,90-119`, `AppViewModel.swift:2659-2676`.
- On Codex detection `AppViewModel` swaps `ServerViewModel` for `CodexServerViewModel` and flags an `initialized` ack; the ack is sent lazily as `"notifications/initialized"` before the next Codex call. `AppViewModel.swift:2191-2213,449-478`, `CodexServerViewModel.swift:199-201,490-502`. (Add-server validation sends the SDK's `InitializedNotification.name` instead; `AppServerMethods.initialized` is `"initialized"` — three spellings, §I.10.)
- Initialize params carry both ACP `clientCapabilities {fs, terminal}` and Codex `capabilities {experimentalApi:true}`. `AppViewModel.swift:1721-1742`, `ACPServiceModels.swift:33-52`.
### Method / event map actually used by the app (`CodexServerViewModel.swift`)
| Direction | Method | Params / handling | Cite |
|---|---|---|---|
| → | `thread/start` | `approvalPolicy`, `persistExtendedHistory:true`, `cwd`; result `thread.id` | 2622-2647 |
| → | `turn/start` | `threadId`, `input:[{type:text,text}]`, `model`, `effort`, `skills[]`, `approvalPolicy`, `sandboxPolicy`, `collaborationMode {mode: plan\|default, settings{model, reasoning_effort:null, developer_instructions:null}}`; result `turn.id` → `activeTurnId`, binds a streaming row | 2649-2714 |
| → | `turn/interrupt` | `{threadId, turnId}` | 2716-2724 |
| → | `thread/resume` | `{threadId, persistExtendedHistory:true}` → full turns/items | 2726-2742 |
| → | `thread/read` | `{threadId, includeTurns:true}` | 2744-2757 |
| → | `thread/loaded/list` | paginated `data[]` of loaded thread ids | 2759-2789 |
| → | `addConversationListener` | `{conversationId, experimentalRawEvents:false}` | 2796-2804 |
| → | `thread/list` | `{cursor:null, limit:50}` → `data[] {id, preview, cwd, updatedAt/createdAt}` | 2812-2821, 3681-3693 |
| → | `thread/archive`, `model/list`, `skills/list` | | 2823-2850 |
| ← notif | `turn/started` | `activeTurnId`, interruptible, bind streaming row | 3060-3078 |
| ← notif | `item/agentMessage/delta` | `appendAssistantText(.message)` | 2986-2996 |
| ← notif | `item/plan/delta` | `.plan` delta | 2997-2999, 3154-3169 |
| ← notif | `item/started` / `item/completed` | `handleCodexItemEvent(status: in_progress/completed)` | 3000-3025, 3240-3367 |
| ← notif | `turn/plan/updated` | flatten steps → `completePlanItem` | 3026-3028, 3171-3238 |
| ← notif | `turn/diff/updated`, `codex/event/turn_diff` | synthetic tool call `toolCallId:"turn_diff:<turnId>"`, kind `edit`, title `"diff: <path from diff --git>"`, output = unified diff | 2974-2985, 3123-3152 |
| ← notif | `turn/completed` | clears active turn, unbinds streaming, emits stop reason `turn_completed` | 3029-3059 |
| ← notif | `error` | `willRetry` → inline "⚠️ … (retrying…)" else terminal system error | 3079-3080, 3101-3121 |
| ← req | `item/commandExecution/requestApproval`, `item/fileChange/requestApproval` | `handleApprovalRequest` (routed from AppViewModel) | 3636-3677, `AppViewModel.swift:2330-2335` |
| ← req | `item/tool/requestUserInput` | questions sheet | 3085-3094, 2885-2942 |
- Notifications for a non-active thread are dropped except `turn/completed` (clears saved turn, unbinds background VM). `CodexServerViewModel.swift:2956-2971`.
### Item → common model
`parseThreadItem` normalises `type` (strip `_`, lowercase) and maps: `usermessage`/`message(role user)` → user; `agentmessage`/`assistantmessage` → assistant text; `plan`; `reasoning|thought|analysis` → thought (text ∥ content[] ∥ summary[]); `commandexecution|command|exec|shell` → tool call kind `execute` (title via `commandExecutionDisplayTitle`, which unwraps `zsh/bash/sh -c|-lc`, `/usr/bin/env`, PowerShell `-Command`, `cmd /c`); `filechange|file|diff|patch` → tool call kind `edit`, output = diff from `changes[].diff|patch|content[type:diff]`; `toolcall|tool|functioncall|function` → generic tool call; substring heuristics as fallback. `CodexServerViewModel.swift:3836-4176,352-488`.
- Live `item/completed` for `agentMessage` de-duplicates against the streaming row (exact / suffix / contains / whitespace-normalised / prefix-extension) so delta+final don't double-render; `<proposed_plan>…</proposed_plan>` in a message becomes a `.plan` segment. `CodexServerViewModel.swift:3369-3425,3462-3472,194-197`.
- Reasoning items are de-duplicated per `itemId` via `reasoningCache`. `CodexServerViewModel.swift:3247-3267`.
### One chat UI, two protocols
- Shared: `ChatMessage`/`AssistantSegment`/`ToolCallDisplay`, `ACPSessionViewModel` (per session), `ChatEntryMapper`, all row views, `ChatEntryActionHandlers {onACPPermissionResponse, onJSONRPCPermissionResponse, onApproveRequest, onDeclineRequest, onUndoFileChanges, onReviewFileChanges}`. `ChatTranscriptState.swift:27-36`.
- `ServerViewModelProtocol` lets `AppViewModel` treat both VMs uniformly (`sessionSummaries`, `sendPrompt`, `openSession`, `archiveSession`, …). `Agmente/ServerViewModelProtocol.swift:7-114`.
- Leaks into UI: Codex has no modes (`availableModes` is `[]`), instead model/effort, skills (grouped by scope user→repo→system→admin), permissions preset, plan toggle, archive, log export; images are text-only ("Codex app-server: image attachments are not supported yet"); `isStreaming` is `hasStreamingRow || canInterruptActiveTurn`; the send button is tri-state Send / Stop (`turn/interrupt`) / Reset (`clearLikelyInFlightState`) when a resume-derived turn is not confirmed live. `CodexServerViewModel.swift:59-96,1959-1961,2020-2047`, `CodexSessionDetailView.swift:474-495,564-601`.
### Codex resume model and turn state
- `CodexThreadResumeResult { id, preview?, cwd?, createdAt?, activeTurnId?, turns: [Turn{id,status?,items}] }`; `Item` = `userMessage(id,text) | agentMessage(id,text) | plan(id,text) | reasoning(id,text) | commandExecution(id,command?,output?) | fileChange(id,path?,changeType?,diff?) | toolCall(id,title,kind?,status?,output?) | unknown(type)`. `activeTurnId` = first turn whose status normalises to `inprogress|running|pending|started`. `CodexServerViewModel.swift:326-350,3761-3834`.
- Merge keys: `turn:<turnId>:<reasoning|command|file|tool|user|assistant|plan>:<itemId>` or `turn:<turnId>:idx:<i>:<kind>` when no item id; stored per session in `sessionMessageKeys[threadId][messageId]` so later `thread/read`s can reuse rows. `CodexServerViewModel.swift:1750-1776,170,1510`.
- Within a turn, user items are ordered before non-user items regardless of server order. `CodexServerViewModel.swift:1566-1573`.
- Turn state machine (all in `CodexServerViewModel`):
| State var | Set by | Cleared by | Effect |
|---|---|---|---|
| `activeThreadId` | `setActiveSession`, `startThread`, `resumeThread`, `readThread` | `deleteSession` | notifications for other threads dropped (except `turn/completed`) `:2956-2971` |
| `activeTurnId` | `turn/start` result, `turn/started`, `alignActiveTurnIfNeeded` (any `item/*`/delta with a new `turnId`), `thread/resume` | `turn/completed`, `turn/interrupt`, terminal `error`, `clearLikelyInFlightState` | drives `canInterruptActiveTurn` `:63-79` |
| `activeTurnIsInterruptible` | true on live events; false when only inferred from `thread/resume` | with `activeTurnId` | Stop vs Reset button `:81-83,2296-2316` |
| `turnStreamingMessageIds[turnId] → UUID` | `bindStreamingMessageForTurn` (memory → current streaming → resume-key lookup → ensure new) | `turn/completed`, interrupt | which assistant row receives deltas `:2363-2403` |
| `lastStreamingEventAtByThreadId` | every delta/item/plan event | `turn/completed`, reset | 15s window for "likely in flight" `:2326-2345` |
| `savedTurnByThread` | switching away from a thread with an active turn | reopening that thread, background `turn/completed` | restores in-flight detection when navigating back `:2249-2285` |
### Hydration (open / reconnect)
- Preferred path: `thread/loaded/list` → if loaded, `addConversationListener` + `thread/read(includeTurns:true)`; else `thread/resume`. `CodexServerViewModel.swift:2806-2810,689-713`, `AppServerClient/codex-thread-hydration.md:17-33` [docs].
- Stale-snapshot guard: if local state is "likely in flight" (streaming activity within 15s) and the snapshot lacks the active turn → skip merge, keep local. `CodexServerViewModel.swift:192,721-745,2336-2345`.
- Merge (`mergeChatFromThreadHistory`): resume nodes keyed `turn:<id>:<kind>:<itemId>`; reuse existing rows by key, `mergeResumeMessagePayload` refuses to downgrade a richer local row (prefix snapshot / dropped tool rows / dropped output); unmatched local rows are carried forward near their neighbours; duplicate assistant/thought text suppressed by normalised containment and tool-id subset checks. `CodexServerViewModel.swift:1292-1557,1096-1297`. Spec: `Agmente/specs/codex-load-resume-merge.md` [docs].
- Follow-up refresh after resume-based hydration: 1–3 `thread/resume` calls 2s apart until item count stabilises. `CodexServerViewModel.swift:2143-2247`.
- `CodexSessionLogger` (actor) writes JSONL per session to `~/Library/Application Support/Agmente/logs/codex/`, including wire frames, merge stats and chat snapshots; export/zip from the session menu. `Agmente/CodexSessionLogger.swift:4-114`, `CodexSessionDetailView.swift:1446-1518`.
### `AppServerClient` package (unused transport, useful reference)
- `AppServerMethods` constants incl. `review/start`, `command/exec`, `config/*`, `mcpServer/*`. `AppServerMethods.swift:1-27`.
- `AppServerEventParser` → `AppServerEvent` (`threadStarted, turnStarted, turnCompleted, agentMessageDelta, itemStarted, itemCompleted, diffUpdated, planUpdated, tokenUsageUpdated, approvalRequested, notification, request`). `AppServerEventParser.swift:3-141`.
- Typed payloads: approval policy (`unlessTrusted|untrusted|onRequest|onFailure|never`), sandbox (`readOnly|workspaceWrite|dangerFullAccess|externalSandbox`), reasoning effort/summary, review targets, config writes. `AppServerPayloads.swift:40-131,346-411,451-546`.
- Note the naming mismatch: package uses `onRequest`, the live VM sends `"on-request"`. `AppServerPayloads.swift:49`, `CodexServerViewModel.swift:142`.
### E2E skills (verification practice)
- `.agents/skills/codex-local-cli-e2e/`: runs one XCUITest `testCodexDirectWebSocketConnectInitializeAndSessionFlow` against a real `codex app-server --listen ws://127.0.0.1:8788`; env contract `AGMENTE_E2E_CODEX_ENABLED=1`, `AGMENTE_E2E_CODEX_ENDPOINT` (or `_HOST`), optional `_PROMPT`, optional `_CONFIG_PATH` file; test `XCTSkip`s when disabled. Script `run_codex_local_e2e.sh` boots the sim, uninstalls the app for a clean first-run, optionally starts the server (`nohup`, `nc -z` port wait), runs `xcodebuild -only-testing:`, greps failures, treats an unexpected skip as failure, always cleans up (kill server, uninstall app, optional shutdown). `SKILL.md:12-83`, `references/agmente-codex-e2e-contract.md:14-27`, `scripts/run_codex_local_e2e.sh:139-244`, `AgmenteUITests/AgmenteUITests.swift:63-160,190-227`.
- `.github/skills/run-agmente-codex-e2e/`: agent-driven variant using `stdio-to-ws` bridge on port 9000 (`start_codex.sh` with pid/log files), XcodeBuildMCP `build_run_sim`, `describe_ui` before every tap, validates the RPC sequence `initialize → initialized → thread/list → thread/start → turn/start → turn/started → turn/completed` and `item/*` streaming, mandatory `cleanup.sh`. `SKILL.md:18-40`, `scripts/start_codex.sh:17-40`, `references/ui-checklist.md:27-64`.
- `e2e/` is the source of truth (scenario front-matter + shared assertion vocabulary); skills are execute-only and must not edit the repo to make a run pass. `e2e/README.md:16-27`, `e2e/assertions/common.md`, `e2e/scenarios/codex/local-cli-smoke.md`.
- `.agents/skills/upstream-protocol-drift-watch/`: diff upstream ACP/Codex repos (`codex-rs/app-server-protocol`, ACP `docs/schema`) against local method constants and score risk. `SKILL.md:44-93`.
- Accessibility ids the UI test depends on: `emptyStateAddServerButton`, `ServerNameField`, `ServerTypeCodex`/`ServerTypePicker`, `ProtocolPicker`, `HostField`, `newSessionButton`, `codexPromptEditor`, `codexSendButton`, `codexUserBubble`, `codexAssistantBubble`, `codexThinkingBubble`, `codexSystemBubble`. `CodexSessionDetailView.swift:543,593,982,1049,1053,1058`, `ServerManagementViews.swift:88-111`.
---
## F. Sessions
### List
- ACP: `session/list` fanned out once per *used working directory* (Core Data `usedWorkingDirectories` on the server row), results accumulated in `pendingMultiCwdFetch` and merged/sorted by `updatedAt` desc then id. `ServerViewModel.swift:998-1152`, `SessionStorage.swift:142-176`.
- Timestamp parsing accepts `mtime`, `updatedAt` (number / numeric string / ISO8601 ± fractional), `startTime`; unix values auto-scaled from ns/µs/ms. `ACP/SessionListParsing.swift:54-96`. Title = `title ?? prompt`. `SessionListParsing.swift:21-24`.
- `-32601` on `session/list` → capability off, fall back to cache; when the agent supports list, the fetched list prunes stale Core Data rows. `AppViewModel.swift:2264-2268`, `ServerViewModel.swift:436-455`.
- Codex: `thread/list` limit 50; `cwd` missing on older servers is back-filled from cache/storage. `CodexServerViewModel.swift:2051-2078,2525-2562`.
- Sidebar groups by time or folder (toggle), search, "New Session" split button with custom cwd sheet. `ContentView.swift:700-770,1441-1533`.
### Create / open / load / resume (ACP)
- New session = local placeholder UUID in `pendingLocalSessions`; if connected+initialized, `session/new {cwd, mcpServers:[]}` fires immediately (task tracked in `creatingSessionTasks`), else deferred to first prompt. Response → `finalizePendingSessionCreation`: resolve id, migrate VM/cache/storage from placeholder, mark materialized, remove placeholder. Failure → placeholder removed and error row. `ServerViewModel.swift:858-918,514-653,977-995`.
- `openSession(id)`: map placeholder→resolved; Codex → `setActiveSession` only; if already materialized this connection → nothing; if local messages exist and agent can `session/load` → clear messages and `session/load` (server replays history as `user_message_chunk`/`agent_message_chunk`); if no load support → show cached; if not connected → `pendingSessionLoad`. `ServerViewModel.swift:782-850,727-779`.
- Prompt preflight: if not materialized, `session/load` first (or `session/resume` when load unsupported), `-32601` flips the capability flag. `ServerViewModel.swift:1282-1351`.
- Materialized/resuming sets live in `ACPClientManager` and reset on every connect/disconnect/failure. `ACPClientManager.swift:114-115,513-516,524-555`.
- Mode restore order: response modes → cached mode per (server,session) → `defaultModeId` from initialize. `ServerViewModel.swift:687-708`.
### ACP session states (one server, one connection)
| State | How you get there | Prompt path | Cite |
|---|---|---|---|
| Local placeholder (`pendingLocalSessions`) | New Session while disconnected/uninitialized, or immediately before `session/new` returns | awaits `creatingSessionTasks[id]` or creates now; never persisted; failure removes it | `ServerViewModel.swift:858-918,1207-1273,352-353` |
| Materialized this connection (`connectionManager.isSessionMaterialized`) | `session/new` / `session/load` / `session/resume` success, or `sessionLoadDidComplete` | direct `session/prompt` | `:577,1304,1339,1422-1426` |
| Known but not materialized | app relaunch, reconnect (sets reset), server switch | preflight `session/load` (or `resume`), `-32601` flips capability then falls through | `:1282-1351`, `ACPClientManager.swift:513-516` |
| Cached-only (agent lacks load/resume) | e.g. Gemini | shows Core Data transcript; new prompts go to a session the server may not know (`Limited session recovery`) | `:840-843`, `AppViewModel.swift:1867-1884` |
| Loading (`pendingSessionLoad == sessionId`) | open while disconnected | "Loading session..." overlay until connected | `SessionDetailView.swift:272-273`, `AppViewModel.swift:1487-1523` |
### Persistence
- Core Data: `StoredServer {id,name,scheme,host,token,cf*,workingDirectory,serverType,usedWorkingDirectories:NSArray}`, `StoredSession {sessionId,title,cwd,updatedAt}`, `StoredMessage {messageId,role,content,createdAt,orderIndex,segmentsData}`; `saveMessages` replaces all rows for a session; `saveSession` never overwrites with nil; `updatedAt` only touched locally for agents without `session/list`. `SessionStorage.swift:53-433`, `ServerViewModel.swift:474-476`.
- In-memory: `chatCache[server][session]`, `stopReasonCache`, `updatesCache` (log lines), `sessionSummaryCache`, `initializationCache`, `agentInfoCache`. `AppViewModel.swift:276-283,204`.
- Transient (not persisted): permission options / request ids / approval fields, `isStreaming`, images. `AppViewModel.swift:2882-2889,2989`.
- UserDefaults: last server id, dev mode, Codex logging, per-server permission preset, `ACPClientManager.clientId`, `lastConnectedAt`. `AppViewModel.swift:225-229`, `ACPClientManager.swift:131-132`.
### Reconnect
- `ACPClientManager`: `NWPathMonitor` (offline → disconnect + `.failed(NetworkOfflineError)`; online → reconnect), exponential backoff `1s·2^(n-1)` capped at 3 attempts then silent stop, `verifyConnectionHealth` ping with 8s timeout before reuse, `connectAndWait`/`initializeAndWait` continuations, "already initialized" RPC error treated as success. `ACPClientManager.swift:81-92,171-209,339-386,403-465`.
- App: `scenePhase == .active` → `resumeConnectionIfNeeded` (1s throttle) → health check → reconnect → initialize → `fetchSessionList(force:)` → Codex `fetchModels` + `resubscribeActiveSessionAfterReconnect`. `ContentView.swift:51-56`, `AppViewModel.swift:1639-1657,1688-1719`.
- After reconnect ACP sessions are no longer materialized, so the next prompt re-issues `session/load`/`resume` (transcript is kept locally, not cleared). `ServerViewModel.swift:1282-1351`.
### Multiple servers / switching / cancel / errors
- One `ACPClientManager` shared by all server VMs (TODO acknowledged); `selectServer` persists the old server's state, disconnects, applies the new config non-destructively (`isApplyingSelectedServerConfig` guard), reconnects. Only one live socket. `AppViewModel.swift:363,878-909,1029-1047`.
- Per-session `ACPSessionViewModel` instances are created lazily and kept per server, with `objectWillChange` forwarding; placeholder ids are migrated in place. `ServerViewModel.swift:47-75,206-241`.
- Cancel: ACP `session/cancel` after cancelling pending permissions; Codex `turn/interrupt` or local reset. `AppViewModel.swift:2050-2069`, `CodexServerViewModel.swift:1986-2047`.
- Error surfaces: `ACPServiceError {disconnected, rpc(id,error), unsupportedMessage}` → `formatPromptError` → system error row via `failPendingTurn`; `authMethods` are parsed from initialize but never surfaced or used (`authenticate` is never sent); no `-32000` handling; add-server validation maps `NSURLErrorDomain -1001…-1200` on local hosts to a "local network permission" hint and probes `session/list`/`session/load` support with a fake id `capability-probe`. `ACPService.swift:153-177`, `AppViewModel.swift:2454-2466,2045-2048`, `InitializeParsing.swift:45-58`, `AppViewModel.swift:689-756`.
---
## G. Tests (oracle)
### `ACPClient/Tests/ACPClientTests`
- `SessionUpdateHandlerTests` (`:10-405`): message chunk from `content` string and from `content.text`; thought chunk; user chunk; `tool_call` full and minimal (status defaults `pending`); `tool_call_update` with `rawOutput`, with title/kind, with `content[]` text (`"On branch main"`); `current_mode_update` with/without `modeId`; `available_commands_update` incl. `input.hint`; session filtering (other session → 0 events, match → 1, nil filter → pass); unknown type with text → `agentMessage`, without text → nothing; nil params, empty update, empty text → nothing; `sessionId(from:)` extraction and nil.
- `SessionUpdateParsingTests` (`:6-160`): summariser strings for message/tool_call/tool_call_update/mode/commands; `extractText` from array payload; `parse` returns session+kind; tool helpers prefer `title` and `rawOutput`; output from `content[]`; user text from `content.text`.
- `PermissionRequestParsingTests` (`:6-51`): full request incl. `proceed_always`→allowAlways; missing title → "Unknown operation" and empty options.
- `ACPMessageBuilderTests` (`:6-57`): selected outcome shape; cancelled outcome has no `optionId`; initialized notification uses SDK name with null params; error response carries code/message.
- `ResponseDispatcherTests` (`:9-568`): session/new, load, resume dispatch; placeholder migration vs same id; set_mode via `currentModeId` and `modeId`; set_config_option; initialize via method and via `agent` fallback; stopReason; session/list from `sessions` and `items` keys; cwd transform; `-32601` disables load/resume/list; other codes only `rpcError`; session/new with modes; fallback session id from other methods; empty/nil results; pending cwd fallback.
- `SessionResponseParsingTests` (`:9-500`): session/new id keys (`sessionId`/`session`/`id`), cwd/workingDirectory, modes, configOptions→mode synthesis, fallbacks, nil; session/load basic, `history`/`messages` arrays, timestamps, modes, nil; set_mode variants; config options; `parseModes` edge cases; equality.
- `InitializeParsingTests` (`:6-115`): ACP extracts modes + authMethods; Codex from `userAgent`; ACP preferred when markers exist; Codex version edge cases.
- `AgentInfoParsingTests` (`:6-70`): AgentProfile from JSON; available commands parse.
- `SessionListParsingTests` (`:6-73`): `mtime`+`prompt`; `updatedAt` string; sort desc; cwd transform.
- `PromptBuilderTests` (`:9-329`): text/whitespace/images/audio/context blocks, capability warnings, JSON shapes, debug descriptions, validate/makePayload.
- `ServiceModelTests` (`:9-230`): params encoding for load/resume/create/list/set_mode/cancel/initialize payloads.
- `ACPServiceTests` (`:39-158`, swift-testing): initialize resolves; RPC error surfaces; load; set_config_option.
- `ACPClientTests` (`:10-90`, swift-testing): wire encode/decode; connect+receive; bearer header from provider; ping when configured; unescaped-slash toggle.
- `ACPClientManagerTests` (`:56-333`): client id generate/persist/reuse/provided; initial state; persisted lastConnectedAt; config defaults/all options; reconnect settings; disconnect resets; delegate logs; connectAndWait; health ping; initializeAndWait; session tracking reset; failed clears connecting.
- `ACPClientManagerRaceTests` (`:32`): disconnect/connect race.
### `AppServerClient/Tests`
- `EventParserTests:5` agentMessage delta event; `JSONRPCTests:5,15` decode without / encode with `jsonrpc` header; `ResponseParsingTests:7-140` skills sorted by scope then name, scope `Comparable`, display names, `allCases` order.
### `AgmenteTests`
- `ACPSessionViewModelTests` (`:155-633`): save/load chat state via cache delegate, with stop reason, without context; load from storage when cache empty; reset; setChatMessages; stopReason and load-complete call delegates; mode change delegate; addUserMessage / startNewStreamingResponse / error row; mode set/cache/migrate (no overwrite); state transitions; multi-session cache isolation; streaming state restore; commands update/restore/migrate.
- `AgentViewModelTests` (`:156-1125`): capability nil before init; ACP init populates agent info; Codex init sets caps; ACP preferred; qwen-code modes+caps; prompt caps default false; session list → summaries, marks support true/false, `items`+`prompt`+`mtime`; commands update; message + tool output; **same toolCallId updates one segment**; stopReason finishes streaming; summaries; thought segment; **permission request creates tool call with options** (status `awaiting_permission`, request id `.int(0)`); **end-to-end permission** (response wire shape, options cleared, later `tool_call_update` completes with output, stopReason); Gemini e2e (authMethods, list `-32601` fallback, streaming chunks concat, end_turn); resume → `session/list` sent; **tool call + permission e2e** (pending→awaiting→in_progress→completed with rawOutput); last-message preview truncation rules; mode change log.
- `CodexServerViewModelTests` (`:48-1196`): ServerViewModel→Codex switch after initialize, stays ACP for ACP; agent info synced; pending session always false; default preset; Full access → dangerous overrides; command title unwrapping (sh/env/direct script/PowerShell/cmd/unknown flags); `selectedServerViewModelAny` both types; summaries not migrated on switch; set/open session; streaming tracks turn lifecycle; interruptible without streaming row; item delta realigns stale turn; stale local turn not in-flight w/o recent activity; recent turn still in-flight; reset keeps partial composer text; resume-derived turn uses Reset until live event; plan delta + plan updated render `.plan`; completed message with `<proposed_plan>` becomes plan; structured plan delta suppresses raw; plan delta preserves whitespace; collaborationMode default/plan; switching away saves in-flight turn, reopening same thread doesn't; background `turn/completed` cleans saved turn; other background notifs dropped; active `turn/completed` cleanup; removeAll clears saved turns; full session-switch streaming isolation.
- `CodexThreadReadMergeFixtureTests` (`:9-247`): data-driven — for every `Fixtures/CodexThreadReadMerge/*.json` seed messages+keys+active turn, apply `thread_read` merges and `update` notifications, assert count / ordered `contains` / per-role contains counts. Fixtures pin: user-before-rich-assistant after read; same-prefix snapshot keeps rich local; overlapping in-flight streaming merges new items; partial-thought and combined-reasoning de-dup; new-turn reasoning not suppressed; markdown growth then updates; background turn completion doesn't duplicate opening text. `AgmenteTests/Fixtures/CodexThreadReadMerge/*.json`, `Agmente/specs/codex-load-resume-merge.md:138-221` [docs].
- `ServerViewModelTests` (`:242-880`): pending cwd flows into `session/new` and `session/prompt` uses resolved id, no `session/load`; fresh empty session never triggers load; failed creation never prompts with placeholder; resolved id replaces placeholder in storage and persists transcript; cached sessions reopen from storage for load-capable agents; open stored session sends load when supported / skips when not; prompt preflight loads non-materialized session.
- `SessionIsolationTests` (`:53-330`): tool confirmation stays with its session across switches; messages preserved; placeholder migration keeps VM instance; delete cleans VM; mode/streaming/prompt text isolated; lazy VM creation; coexistence; `isStreaming` reflects current session.
- `ViewModelSyncTests` (`:90-346`): agent info synced on add / ACP init / Codex init; capability change propagates; connected protocol; pending session delegation; list fetch defaults; session cwd updated on open, timestamp preserved; selecting another server doesn't overwrite its connection details.
- `SessionStorageTests:5` empty cwd not persisted as root; `ChatRenderingTests` (`:7-176`): mapper kinds, file-change extraction, list diff insert/update/remove, scroll animation policy (first render, hydration, tail append, >3 inserts, streaming update, reorder).
- `AgmenteUITests:63` Codex direct-WebSocket flow (opt-in), `:44,53` launch smoke/perf.
---
## H. Copy / adapt / skip (for a Swift-native macOS host on `wiedymi/swift-acp` + local spawn)
| File | Verdict | Notes |
|---|---|---|
| `ACPClient/Sources/ACPClient/ACP/PermissionRequestParsing.swift` | copy | Pure `ACP.Value` reads; swap the `ACP.Value` accessor names if swift-acp's JSON enum differs. Keep the `proceed_*`/`cancel` alias map. |
| `ACP/ACPMessageBuilder.swift` | adapt | Replace `ACP.AnyResponse`/`ACPError`/`InitializedNotification.name` with swift-acp's response + error types; keep the two `outcome` shapes verbatim. |
| `ACP/SessionUpdateParsing.swift` + `SessionUpdateHandler.swift` | adapt (extend) | Keep shape; add `plan` (`entries[]{content,status,priority}` → replace-whole-plan event), `usage_update`, `session_info_update`; make `tool_call`/`tool_call_update` carry `content[]` (text/diff/terminal), `locations[]`, `rawInput`, `rawOutput`; make `extractText` concatenate all text blocks instead of returning the first; drop the "unknown kind with text → agent message" fallback. |
| `ACP/ResponseDispatcher.swift`, `SessionResponseParsing.swift`, `SessionListParsing.swift`, `InitializeParsing.swift`, `Models/SessionConfigOption.swift`, `Models/AgentInfo.swift` | adapt | Logic is protocol-correct and well tested; only the `ACP.Value` surface changes. Strip Codex `userAgent` detection and `AgentBehaviorRules` (qwen/claude version gates) if you only host Claude Code/Codex/Gemini locally. |
| `ACP/PromptBuilder.swift`, `ACPServiceModels.swift`, `ACPMethods.swift` | adapt | Payload builders are trivial; swift-acp likely has typed request structs — use those instead and keep only the capability-warning logic. |
| `ACP/ACPService.swift`, `ACPClientManager.swift`, `ACPClient.swift`, `Support/*`, `Models/ACPWireMessage.swift`, `Models/JSONRPC*.swift` | skip | WebSocket transport, reconnect/backoff, `X-Client-Id`, Cloudflare headers, brace-depth framing — all replaced by swift-acp's stdio transport over a spawned process. Keep only the idea of `materializedSessions` reset on transport restart. |
| `Agmente/AppViewModel.swift:2865-3038` (`ChatMessage`, `AssistantSegment`, `ToolCallDisplay`, codable mirrors) | adapt | Copy the shape; add `contentBlocks: [ToolCallContent]` (text/diff/terminal), `locations`, `rawInput`, and a `PlanSegment {entries}` kind; make `toolCallId` non-optional for ACP. |
| `Agmente/ACPSessionViewModel.swift` | adapt | Keep `ensureStreamingAssistantMessage`, `appendAssistantText`, `appendToolCall`, `applyToolCallUpdate` (change fallback (3) so an update with an unknown id never lands on the last tool row), `appendUserChunk`, permission bookkeeping, `cancelPendingPermissionRequests`. Drop the `hasSuffix("characters)")` hack and the Codex approval fields unless you host Codex app-server. Remove `fs/*` + `terminal/*` refusals — a Mac host should implement them. |
| `Agmente/ChatRendering/ChatEntry.swift`, `ChatEntryMapper.swift`, `ChatRenderDiff.swift`, `ChatHeightCache.swift`, `ChatScrollAnimationPolicy.swift`, `ChatMarkdownPackageCache.swift`, `ChatTranscriptState.swift` | copy | Platform-neutral. Fix the mapper so file-change tool calls without a diff still get a tool row. |
| `HighPerformanceChatListView/*.swift` | adapt (port) | Everything is `#if canImport(UIKit)`. Port to AppKit: `NSTableView`/`NSCollectionView` or ListViewKit's macOS support if it exists; `MarkdownView` 3.6.2 does ship macOS targets (Litext/Highlightr are cross-platform) — verify before relying on it. Keep the deferral-while-scrolling, height cache, render-token, and thought-expansion salt patterns. |
| `ToolCallRowView.swift` | adapt | Port measurements; actually use `iconForToolKind`/`colorForToolKind`; add completed/failed glyphs and a spinner for `in_progress`; render `content[]` diff blocks. |
| `FileChangesRows.swift`, `FileChangesSummaryView.swift` | adapt | Keep dedupe; add a real diff view (hunk parsing, +/- colouring, line numbers). |
| `PlanModeViews.swift` | copy (Codex) / adapt (ACP) | `UserInputQuestionsSheet` is reusable as-is. For ACP plans build a checklist view from `entries[]` (status pending/in_progress/completed, priority high/medium/low). |
| `SessionSupplementalViews.swift` | copy | `groupedThoughtSegments`, `truncatedToolOutput`, `truncatedLabel`, bubbles. Replace `MarkdownText` with the MarkdownView-backed renderer on macOS. |
| `SessionDetailView.swift` | adapt | macOS composer, mode picker, config-option controls, command picker, working-directory sheet, ⌘↩/⌘. wiring are directly reusable; remove PhotosUI. |
| `PromptComposerCommands.swift`, `SessionWindowStore.swift`, `AgmenteApp.swift:20-29` | copy | Menu commands via `FocusedValues`; multi-window session payload store. |
| `ServerViewModel.swift`, `ServerViewModelProtocol.swift` | adapt | Placeholder-session lifecycle, `session/list` per-cwd fan-out, load/resume preflight, mode/commands caches are valuable; replace connection plumbing with "agent process handle" and per-process materialization. |
| `CodexServerViewModel.swift`, `CodexSessionDetailView.swift`, `AppServerClient/*`, `CodexSessionLogger.swift`, `specs/codex-load-resume-merge.md`, fixtures | copy (if hosting Codex app-server) | The thread hydration/merge machinery and its JSON fixtures are the most battle-tested part of the repo. If you only run `codex` via ACP (`codex-acp`), skip. |
| `SessionStorage.swift`, `Persistence.swift` | adapt or skip | Core Data with `segmentsData` JSON blob; replace with SwiftData/JSON files. Keep "never persist placeholder ids" and "never overwrite with nil" rules. |
| `AppViewModel.swift` (rest), `ContentView.swift`, `ServerManagementViews.swift`, `SettingsView.swift` | skip | Multi-remote-server management, Cloudflare Access fields, add-server validation probes. |
| `e2e/`, `.agents/skills/*`, `.github/skills/*`, `AgmenteUITests` | adapt | The scenario/backends/assertions split and the "execute-only, always clean up, accessibility ids as contract" discipline transfer directly; replace `stdio-to-ws` + simulator with spawned CLI + macOS XCUITest. |
| `ACPClient/Tests/*`, `AgmenteTests/*` | adapt | Port the handler/dispatcher/permission/session-lifecycle tests with swift-acp types; keep the Codex merge fixtures byte-for-byte. |
Suggested port order (each step is independently testable against the oracle in §G):
1. Data model (`ChatMessage`/`AssistantSegment`/`ToolCallDisplay` + new content-block/plan types) and `ACPSessionViewModel` fold, driven by `SessionUpdateHandlerTests` + `AgentViewModelTests` semantics rewritten for swift-acp's typed `SessionUpdate`.
2. `ChatEntry`/`ChatEntryMapper`/`ChatRenderDiff`/height cache/scroll policy (copy, pass `ChatRenderingTests`).
3. AppKit list host (NSTableView or NSCollectionView with diffable data source) reusing the entry ids + render tokens; then `MarkdownView` rows on macOS, thought chip, tool-call row with real kind icons and diff blocks.
4. Permission UI + `ChatEntryActionHandlers`, then `session/cancel` semantics (cancel pending permissions first).
5. Session lifecycle (`ServerViewModel` placeholder → materialized) mapped onto a spawned-process handle; `ServerViewModelTests` + `SessionIsolationTests` are the oracle.
6. Optional: Codex app-server host (copy `CodexServerViewModel` + fixtures verbatim).
---
## I. Bugs, gaps, oddities
1. macOS transcript is the slow SwiftUI path; the "high-performance" renderer never runs on Mac. `SessionDetailView.swift:181-187`, `HighPerformanceChatListView.swift:1,558-602`. The macOS fallback inside `ChatTranscriptContainerView` (plain `Text`) is dead code. `ChatTranscriptContainerView.swift:97-199`.
2. macOS markdown is inline-only `AttributedString`; every line gets two trailing spaces appended (hard breaks), which also mangles fenced code. `SessionSupplementalViews.swift:208-219`.
3. ACP `plan` updates are silently dropped (no `entries` handling; `extractText` reads `content`). `SessionUpdateHandler.swift:112-160`, `SessionUpdateParsing.swift:59-77`. `usage_update` / `session_info_update` likewise. Any unknown kind that happens to carry `content` text is rendered as agent prose. `SessionUpdateHandler.swift:152-158`.
4. `tool_call(_update).content[]`: only the first text block is read; `diff` blocks (`path/oldText/newText`), `terminal` blocks, `locations`, `rawInput` are ignored; `output` is replaced not appended. `SessionUpdateParsing.swift:66-75,114-117`, `ACPSessionViewModel.swift:767-769`.
5. `applyToolCallUpdate` falls back to the **last** tool-call segment when the id doesn't match, so an update for a tool call that was never announced (or lives in an earlier message) mutates the wrong row. `ACPSessionViewModel.swift:732-748`.
6. `appendAssistantText` starts a new segment whenever the previous text ends with `"characters)"` — an undocumented coupling to the truncation marker. `ACPSessionViewModel.swift:821-825`.
7. `ToolCallRowView` never uses its kind→icon/colour tables; every row shows a hammer; no completed/failed/in-progress indicator. `ToolCallRowView.swift:227,779-804`.
8. File-change tool calls **without** output vanish: `ChatEntryMapper` removes them from content segments and `FileChangeSummary.items` skips entries with empty diff, so an in-progress `edit` (ACP kind `edit`, or title `Edit: …`) renders nothing until a diff arrives. Same in both SwiftUI paths. `ChatEntryMapper.swift:34-36,63-67`, `FileChangesSummaryView.swift:13-56`, `SessionDetailView.swift:749-753`, `CodexSessionDetailView.swift:1006-1017`.
9. ⌘↩ / ⌘. menu commands only work in the ACP detail view; `CodexSessionDetailView` never sets `.focusedSceneValue(\.promptComposerActions)`. `PromptComposerCommands.swift:23-43`, `SessionDetailView.swift:518` (sole usage).
10. Three spellings of the initialized notification: runtime Codex sends `"notifications/initialized"`, `AppServerMethods.initialized = "initialized"`, add-server validation sends the SDK's `InitializedNotification.name`. `CodexServerViewModel.swift:494`, `AppServerMethods.swift:3`, `ACPMessageBuilder.swift:6`. Also approval policy string `"on-request"` in the VM vs `onRequest` in the package. `CodexServerViewModel.swift:142`, `AppServerPayloads.swift:49`.
11. `AppServerClient` transport/service/event-parser are dead at runtime; `CodexServerViewModel` re-implements parsing over raw JSON (`Item` enum, `parseThreadItem`). Two identical 529-line `URLSessionWebSocketProvider.swift` copies. `CodexServerViewModel.swift:5-8,3836-3906`.
12. One shared `ACPClientManager` for all servers → one connection, server switch = disconnect (acknowledged TODO). `AppViewModel.swift:363`.
13. Every inbound frame is delivered via an independent `Task { @MainActor }` from the socket task; two chunks can in principle be applied out of order under contention. `ACPClient.swift:294-300`, `ACPService.swift:129-146`.
14. `session/request_permission` is attached to the *selected* session VM regardless of the request's `sessionId`; a permission for a background session lands in the foreground transcript. `AppViewModel.swift:2343-2344`, `ACPSessionViewModel.swift:517-536`.
15. `AppViewModel.withTimeout` and `sendRawRequest` are unused. `AppViewModel.swift:2090,2471`.
16. Reconnect gives up after 3 attempts with only a log line; no user-visible state until network change or app foreground. `ACPClientManager.swift:443-447`.
17. Add-server validation sends `session/load {sessionId:"capability-probe"}` to probe support — a side-effecting probe. `AppViewModel.swift:728-733`.
18. `authMethods` parsed but `authenticate` is never issued; auth-required errors are just logged. `InitializeParsing.swift:45-58`.
19. Images: ACP sends image blocks even when the agent advertises no image support (warning only); Codex drops them. `PromptBuilder.swift:133-136`, `CodexServerViewModel.swift:1959-1961`.
20. `ThoughtRowView.configure(isStreaming _:)` ignores its parameter; streaming-expansion is decided by the list from `entry.isStreaming`. `MarkdownRows.swift:110-117`, `HighPerformanceChatListView.swift:215-217`.
21. `HighPerformanceChatListView.render` is always called with `animated: false`, so diffable animations never run. `ChatTranscriptContainerView.swift:73-77`.
22. `SessionSidebarView` (connection form + FS/terminal toggles) is a leftover dev panel not reachable from `ContentView`. `Agmente/SessionSidebarView.swift`.
23. `Agmente/AGENTS.md:61` embeds the author's absolute path `/Users/lvpeng/...`; `docs/acp-agent-compatibility.md` likewise [docs].
24. `ChatMessage.sanitizedUserContent` strips a Codex-injected `"## My request for Codex:"` prefix from user echoes — worth knowing when comparing transcripts. `AppViewModel.swift:2872,2929-2934`.
Date 2026-09-02. Repo /Users/robertboulos/projects/snappy-os-app @ dffc5616c, branch
codex/clinical-operating-face-20260831. Read-only pass; nothing modified.
Every claim below carries path:line. Paths are absolute; $R = /Users/robertboulos/projects/snappy-os-app.
The product already spawns the real claude and codex binaries as batch agent runtimes,
already translates their structured stdout into a typed AG-UI event stream, already folds that
stream into a run transcript on the glass, already records every tool call as a run step, already
mints receipts, and already has a four-state permission vocabulary per caller × action.
What it does not have is an interactive turn. The whole road runs
claude -p --dangerously-skip-permissions ($R/state/lib/agent-launch-contract.ts:813) and
approval_policy = "never" (:300) — permission is turned off at the source because there is no
channel to ask over. ACP is exactly that channel.
So the question this report answers is not "how do we add agents", it is
"where does an ACP host attach to the existing road, and what does it displace?"
There is currently zero ACP code in the tree: grep for acp/swift-acp/AgentHost over
apps/snappy-os/Sources + Package.swift + Package.resolved matches only bundled web assets and
the 267 MB node-universal binary. $R/research/ACP-HOST-PLAN.md (75 lines, uncommitted) is a
proposal; §7 records that a Swift probe against claude-agent-acp 0.73.0 did a permission
round-trip and a file write in 10.4 s.
apps/snappy-os/Sources/SnappyOS/** — 82 Swift files, ~23,913 lines, plus a second executable
SnappyCUHelper (7 files, own TCC grants).
Launch/HeadScreenSubprocess.swift (897) + +HealthProbe.swift (456)#final class HeadScreenSubprocess: @unchecked Sendable (:4), guarded by let stateLock = NSLock()
(:145). Not an actor — Package.swift:22-27 documents this as deliberate.
defaultHeadScreenPort = 3147, defaultLivenessWorkerPort = 3150(:13-16); resolution from PORT ?? HEAD_SCREEN_PORT, LIVENESS_WORKER_PORT (:186-199).
runtime/node-universal first, then /opt/homebrew/bin/node,/usr/local/bin/node (:19-28); script from a snappy-os-runtime.path pointer file →
<root>/state/bin/head-screen/server.mjs (:29-43, :67-82). Three spawn roads in start()
(:345-415): bundled AOT (spawnAot, :533-596), dev AOT (runs build-server.sh with a 12 s wall,
:393-395), tsx fallback (npx tsx <script>, :668-731).
applyHeadScreenRuntimeDefaults, :461-488, PATH at :545-546):PATH (prefixed /opt/homebrew/bin:/usr/local/bin:), PORT, HEAD_SCREEN_PORT,
LIVENESS_WORKER_PORT, HEAD_SCREEN_HOST=127.0.0.1, SNAPPY_WEB_ROOT, SNAPPY_MASTER_KEY,
SNAPPY_NATIVE_CU_CAPABILITY, SNAPPY_NATIVE_CU_SOCKET, CANVAS_RENDER_VALIDATE_ACTIVE,
SNAPPY_STATE_ROOT (~/Library/Application Support/Snappy OS/state, bundle launches only).
Identity is injected before startAsync() (AppDelegate.swift:181-185) from
ServerConfig.swift:127-165 (defaults → env → ~/.claude/skills/snappy-settings/.env.cache →
24-byte SecRandomCopyBytes) into HeadScreenSubprocess.swift:457-459.
com.snappy.head-screen-app (:18), attach branch :352-362 (setOwnsLifecycle(false), never
spawns/kills), recovery by launchctl kickstart -k gui/<uid>/<label> (:284-305); ownership from
externalBackend (:100-113) / externalBackendOwner (:114-127).
decideHealthProbeAction(...)(+HealthProbe.swift:268-283) → `.leaveAlone / .terminateOwnedChild / .takeOver /
.recoverLaunchdDaemon / .leaveDeveloperLoopAlone`; an out-of-band liveness worker on :3150 vetoes
every kill (:119-143). Restart: crashWindow = 60 s, crashLimit = 3 (:129-130), backoff
pow(2, n-1) * 0.5 s, then permanent give-up (:762-778). Tree kill: stop() walks
pgrep -P <pid> recursively, SIGTERMs in reverse, 2 s grace, SIGKILL (:605-667);
AppDelegate.installSignalHandlers routes SIGTERM/SIGINT through NSApp.terminate so nothing is
orphaned (AppDelegate.swift:557-590).
Bridge/ChatBridge.swift (549) + BridgeKeys.generated.swift (51)#@Observable @MainActor final class ChatBridge (:21-23). The wire vocabulary is **generated from
TypeScript**: BridgeKeys.generated.swift:1-3 is derived from $R/state/lib/bridge-keys.ts:64-107
by state/bin/generate-bridge-keys-swift.mts, verified by npm run bridge:check. 42 keys.
handleJSMessage(type:payload:) (:175-270) — 26 handled cases. The ones that matter here:
| line | case | effect |
|---|---|---|
:192 |
dispatch.action |
POST http://127.0.0.1:3147/dispatch-action, echoes dispatch.result (:396-457) |
:209 |
agent.select |
persists UserDefaults["snappy:os:selectedAgentId"] and nothing else (:336-343); replayed to JS on the ready handshake (:272-293) |
:232/:234/:236 |
computer-use.aside/restore/cursor |
window docking + AgentCursorOverlay (ChatBridgeComputerUse.swift:5-74) |
:238 |
cu.session |
ScreenGlowOverlay.shared.show()/hide() (:377-384) |
:215 |
operator-session.remint |
OperatorSessionInjector.mintAndInject (:364-370) |
:259-267 |
default |
non-fatal; DEBUG log only. context.toggle and folder.pick are declared and unhandled |
Outbound is one function: sendToJS(type:payload:) (:526-548) calling
window.bridge.recv({type,payload}) via callAsyncJavaScript, with a bounded 32-envelope queue
until the ready handshake (:44-48, :530-535). The one typed native→web command union is
VoiceSessionCommand (Voice/VoiceOrbBus.swift:322-347), Encodable → JSONSerialization
(:426-432) — the pattern an agent.* union should copy.
WKWebView: loopback HTTP, no custom scheme handler — http://127.0.0.1:3147/app/index.html
(ChatWebView.swift:357,415) or SNAPPY_WEB_URL for vite. Two message-handler names, "bridge"
and "authSession" (:165-166). Injected globals: window.snappyServer (:89-138),
window.__SNAPPY_OPERATOR_SESSION (post-didFinish), window.__snappyRuntimeErrors.
NativeComputerUse* — the socket pattern an AgentHost should copy verbatim#Swift is the server; the Node head-screen is the client. NativeComputerUseBridge.swift:7-10:
"only exact-PID observe and actuation cross this socket so macOS attributes TCC to Snappy OS."
Path /tmp/snappy-cu-<uuid8>/bridge.sock, dir 0700, socket 0600 (:15-18, :31-41, :78),
listen(fd, 8) (:72).
start() at AppDelegate.swift:72 (before head-screen spawns), stop() at :451.Detached accept Task (:80-82, :167-185), one detached task per client.
requestDeadline = 2 s, maximumRequestBytes = 65_536(NativeComputerUseSocketIO.swift:5-6); poll-based read/write with FD_CLOEXEC, O_NONBLOCK,
SO_NOSIGPIPE (:8-28, :30-88).
:217-233): per-launchcapabilityToken = UUID().uuidString (:11) must match; issuedAtMs within ±5 s; request id not
in the replay set (capped 1024).
registerWithHeadScreen POSTs <serverURL>/computer-use/native-bridge/register with
Authorization: Bearer <masterKey> and `{socketPath, capability, instanceId, startedAtMs, pid,
bundleId} (:104-165`), renewing every 30 s. Server side
$R/state/bin/head-screen/routes/computer-use-permission-actions.ts:38-60.
$R/state/lib/native-computer-use-bridge.ts — createConnection({path}), oneJSON line out, one in, id-matched, 15 s default timeout.
An AgentHost gets the whole IPC problem solved for free by copying this file.
Native/MenuBarOrb.swift (597): one NSStatusItem, install() idempotent (:191); sole writer ofUserDefaults["voicePresenceMode"] / "voicePresenceQuietUntil" (:361, :497, :518-528).
The natural launch point for "ask the hosted agent something" (plan P1).
Native/PushToTalkHotKey.swift (160): Carbon RegisterEventHotKey on ⌃⌥Space, both edges(:42-57, :121-128). Menus/WidgetHotKey.swift:160-200 already owns ⌘⇧W, ⌘⌥O, ⌥⇧Space. A
Carbon chord registers once per app — a collision fails silently, it does not error.
OperatorSessionInjector.swift (98): the only road the raw master key travels — POST<serverURL>/operator/session with the bearer and
{"principal":{"kind":"human_ui","surface":"native-shell-cockpit"}} (:20, :84-97); injects only
the returned session_token as window.__SNAPPY_OPERATOR_SESSION (:63-70).
AuthSessionKeeper.swift (175): owns the "authSession" WK handler (:48) and a 0600Application Support/Snappy OS/state/auth-session.json (:54-58); monkey-patches
localStorage.setItem/removeItem to capture Convex Auth keys (:122-162). Explicitly not the
Keychain — rationale :1-45, cost recorded at docs/TRAPS.md:111-126.
AppDelegate.swift (647): owns let headScreen = HeadScreenSubprocess() (:49) and the whole bootorder (:66-200).
Package.swift — adding swift-acp is a two-line change#// swift-tools-version:6.0 (:1), platforms: [.macOS(.v14)] (:39, with a "DO NOT BUMP"
rationale at :3-31; note Resources/Info.plist says LSMinimumSystemVersion = 13.0 — they
disagree). Exactly one dependency: Sparkle from: "2.6.0" (:53-57), pinned at 2.9.2 in
apps/snappy-os/Package.resolved. Two executable targets + one test target (:58-84). No vendoring,
no mirror, no --offline — scripts/build-app.sh:423,425 runs plain swift build -c release.
Caveat: .unsafeFlags at :80-83 (embedding Info.plist into __TEXT,__info_plist) already means
this package can never be consumed as a dependency; and scripts/native-build-cache.sh needs a cold
pass after a dependency change.
Sources/SnappyOS/SnappyEntitlements.entitlements (used by scripts/build-app.sh:629,
scripts/sign-devid.sh:25,76-79 with --options runtime) has **no com.apple.security.app-sandbox
key at all** — arbitrary fork/exec is legal, which is exactly why node, npx, /bin/bash,
lsof, pgrep, launchctl already work. It carries cs.allow-jit,
cs.allow-unsigned-executable-memory, cs.disable-library-validation, device.audio-input,
automation.apple-events (:8-26).
The MAS profile (SnappyMASEntitlements.entitlements, scripts/mas-build.sh:36,183-186) is
sandboxed and hostile to spawning a user-installed claude. The template for an embedded child is
HelperEntitlements.entitlements — exactly two keys (app-sandbox, inherit), and the file's own
header warns a third key kills the child at launch (:5-7).
AgentHost goes#There is not one actor declaration in the whole Swift codebase. Two patterns only:
@MainActor singletons (51 of 82 files), and @unchecked Sendable + NSLock for the two
process/socket supervisors — HeadScreenSubprocess (:4, :145) and NativeComputerUseBridge
(:10). Package.swift:32-36 states the second is deliberate.
Put it at Sources/SnappyOS/Launch/AgentHost.swift, as
final class AgentHost: @unchecked Sendable with an NSLock — not actor, because the existing
code hands @Sendable closures to Process.terminationHandler, Pipe.readabilityHandler and
DispatchSource handlers, none of which can await into an actor. There is a hard ~900-line file
cap enforced by scripts/lint-god-objects.ts; both existing supervisors were split at it
(HeadScreenSubprocess.swift:779 names its own split).
Reuse rather than rewrite: spawnAot (:533-596), recordCrashAndMaybeRespawn (:762-778),
stop/collectDescendants (:605-667), installSignalHandlers (AppDelegate.swift:557-590),
decideHealthProbeAction (+HealthProbe.swift:268-283), the AF_UNIX server + capability token
(NativeComputerUseBridge.swift:24-233), BridgeKeys.generated.swift generation, VoiceSessionCommand
(VoiceOrbBus.swift:322-432), ApprovalDispatchGuard (Voice/ApprovalDispatchGuard.swift:10-51),
NativeWorkStream SSE-with-backoff (Voice/NativeWorkStream.swift:26-53).
Entry $R/state/bin/head-screen/server.ts (100 lines, deliberately tiny — :4-29 explains the
bind-before-preload boot). Binds HOST = process.env.HOST ?? "127.0.0.1" (:47), port from
resolveHeadScreenPort() (:44). Ports authority: $R/state/lib/ports.ts — 3147 daemon (:22),
3148 bundle smoke (:38), 3149 install candidate (:50), 3150 out-of-band liveness (:71),
3151 browser bridge (:56).
| door | file:line | what a "run" is |
|---|---|---|
GET /runs, GET /runs/:id, POST /runs/:id/{cancel,retry,resume,steps/*} |
routes/scripts-automations.ts:174,181,183,188-298 |
Script runs: each pins a frozen ScriptVersion; the answer unions the raw ScriptRun store with the agent-fire tails (:226-240) and always states its own window (:247-278) |
POST/GET /work/runs, GET /work/runs/:id, GET /work/runs/:id/events (SSE), POST /work/runs/:id/control, POST /work/runs/:id/input |
routes/work-runs.ts:8-13, :267-359 |
Work-harness runs, with A2A task_state added to every projection (:47-56) |
GET /agent/invocations/:runId/output, .../output/events, .../ag-ui |
routes/delegation-tail.ts:9-10, 27-30, 73, 114-160 |
Delegation runs — the ones the executor spawns. The canonical row lives in Convex, not here |
GET /events (routes/events-sse-route.ts:34-45) is a different thing: a native-shell push stream
of evals.ndjson / dispatches.ndjson / journal.ndjson tails, not per-run.
The delegation tail is the seam an ACP host must not duplicate. delegation-tail.ts:27-44:
.../output/events carries two SSE legs — event: output {line} (prose, no replay) and the default
message event carrying AG-UI frames, replayed from the beginning on every connection because
the frames are deltas. The AG-UI leg reads <workspace>/ag-ui.jsonl
(agent-run-ag-ui.ts:544-548).
GET /provider-approvals (+ ?approval_id=), POST /provider-approvals/:id/{revise,approve,deny,reconcile}— routes/provider-approvals.ts:2-7.
GET /approvals/lifecycle?status=&limit= — the ONE unified projection over provider + plan +draft + Convex lanes (routes/drafts-approvals.ts:379-441); unknown status is a typed 400 (:401-404).
GET /needs-you and /needs-you/census (routes/drafts-approvals.ts:495-529) → one computation in$R/state/lib/needs-you-read.ts:1-28 over ~13 founder-actionable lanes (:30-55), plus a triage
ordering (:508-522). count: null means a lane went quiet — never a zero nobody measured.
GET /what-needs-me is operator-private and 403s without operatorProven (routes/needs-me.ts:32-45).evaluateWriteApply in routes/write-apply-gate.ts:44-55, returning{staged:true} | {execute:true, approvalId} | {error,status}. Header :7-24: an action grant
authorizes STAGING, never execution; the execute lane needs either a decisionAuthority derived
from an operator session (the master-key bearer explicitly does not qualify) or a single-use
dispatchNonce minted by the human approve road.
$R/state/lib/delegated-authority.ts:41-69 actionExecutionPolicy(callerId, actionHandle) →
"stage_only" | "approve_each" | "delegated" | "legacy_unset" | "no_grant", with delegated
narrowing to approve_each on an agent version bump (:54-67).
POST /operator/session mints the short-lived session from the master-key bearer, andrequireMasterKeyBearer has no loopback exemption (routes/operator-session-route.ts:1-30).
POST /hub/connector-action (staged by default), POST /hub/create-automation
(routes/connector-ask.ts:100-108), POST /hub/tool-trace (ungated but observed,
routes/connector-ask-hub-doors.ts:26-60), POST /hub/query, POST /connector/ask
(connector-ask.ts:110-140), GET /hub/callers, POST /hub/grants/{create,revoke,restore}
(connector-ask.ts:96-99; refusal texts naming the door at e.g.
routes/artifacts-import.ts:263).
The delegated agent's bus is derived, never typed: agentBusBaseUrl =
http://127.0.0.1:${resolveHeadScreenPort(env)} ($R/state/lib/agent-bus-address.ts:81-85),
handed to a run as {SNAPPY_RENDER_BASE_URL, SNAPPY_XANO_SPINE:"off"} (:111-118).
GET /deploy-truth (routes/health-and-observability.ts:11, 176-230) compares the running process
against the last deploy stamp, names runtime_owner, state_paths, frontend.url, and bypasses the
30 s UI cache. GET /healthz, /backend-health, /system-health (routes/system-health.ts).
POST /dispatch/chat (routes-manifest.ts:808) is handled by
$R/state/bin/head-screen/dispatch-chat-handler.ts (900 lines). It opens an SSE stream
($R/state/lib/dispatch-turn-sse-open.ts:9-23, content-type: text/event-stream,
x-accel-buffering: no), emits RUN_STARTED, then the full AG-UI vocabulary through one
writeAgUI journal boundary; openRunRecord ($R/state/lib/dispatch-turn-open-run.ts:34-55) seeds
a durable in-flight thread row before the model is asked anything.
Backend selection exists but is a two-value axis, not an agent picker.
$R/state/lib/dispatch-config.ts:120-122 — KNOWN_BACKENDS = ["snappy", "claude-code"]; the
internal route resolver returns "ai-sdk" | "claude-code" (:162-167). The web carries the choice
in two window globals, __snappyChatBackend / __snappyChatModel
(apps/snappy-os/web/src/lib/requested-dispatch.ts:11-12, read at :40-50, written at :52-78), so
every programmatic /dispatch/chat caller ships the exact selection the user sees without a
round-trip.
agent.select is not that. It is a rail selection persisted in UserDefaults
(ChatBridge.swift:336-343), sent from App.tsx:526. Nothing dispatches on it.
The "talking half" is a separate, newer door: POST /agent/:slug/say {text} →
{ok, reply, spawned_run_id} (routes/agent-say.ts:1-26). Its contract is the write ORDER: the
person's row first, one harness turn, the agent's reply row, and only then a run — "the person reads
the Agent's own words before any run exists, which is the entire ruling" (:20-25).
routes/agent-messages.ts:1-6 is the group room (SPEECH, never authority).
Entry $R/state/bin/mcp/render-server.ts (104 files in that directory); baked twin
render-server.mjs (0755, #!/usr/bin/env npx tsx). Canonical relative path is asserted at
$R/state/lib/agent-runtime-provability.ts:93.
render-server.ts:740-760 — new StdioServerTransport() via serveStdio(...), imported from
@modelcontextprotocol/server/stdio (:68). It dials the daemon as a client:
$R/state/bin/mcp/render-server-config.ts:19 — `BASE_URL = SNAPPY_RENDER_BASE_URL ||
http://127.0.0.1:${HEAD_SCREEN_PORT}0SERVER_NAME = "snappy-render"`.
There is a second, different HTTP MCP server: $R/state/lib/voice/local-mcp-server.ts:30,103
(StreamableHTTPServerTransport, name snappy-local) mounted at
POST/GET/DELETE http://127.0.0.1:3147/mcp/local ($R/state/bin/head-screen/route-plane-rows.ts:421,
plane: "operator"), stateless per request. Eight tools only: run_on_this_mac, see_this_screen,
start_work/inspect_work/control_work, cli_search/cli_info/cli_run (:19-26).
It is not the governed 41-tool surface.
render-server-operator-credential.ts:70-74 — operatorBearerHeader() reads SNAPPY_MASTER_KEY or
the anchored .env.cache. :270-283 operatorRequestHeaders(callerId) is the single outbound
boundary: x-snappy-caller-id + bearer + (optional) operator session.
Env: SNAPPY_MASTER_KEY, SNAPPY_CALLER_ID (else mcp-local), SNAPPY_RUN_ID,
SNAPPY_RENDER_BASE_URL, SNAPPY_MCP_FULL_SURFACE, SNAPPY_MCP_OPERATOR_SESSION (dev only),
SNAPPY_MCP_DURABLE_WAIT_MS, SNAPPY_MCP_TASK_COMPAT. Header constants at
$R/state/lib/caller-identity.ts:9-16 (CALLER_ID_HEADER = "x-snappy-caller-id").
Identity is process-scoped: render-server-config.ts:25 resolves CALLER_ID **once at module
load**. Enforcement is at the daemon ($R/state/lib/caller-grants.ts:449-470 evaluateCallerRead),
not in the MCP process; the one in-process gate is browser hands, which removes tools rather than
refusing calls and re-reads the grant per call
($R/state/bin/mcp/render-server-browser-hands.ts:133-158). Every call is traced to
${BASE_URL}/hub/tool-trace with caller and run id — "that POST is GOVERNANCE, not telemetry"
(render-server-config.ts:102-108, :129-176).
SNAPPY_MCP_FULL_SURFACE=1)#Assembled at render-server-curated-manifest.ts:90-108, registered in a loop at
render-server.ts:616-627.
render-server-discovery-tools.ts: snappy_search (:162, find a capabilityfrom plain English), snappy_info (:184, full docs for one), snappy_list (:199, browse by area),
snappy_execute (:216, universal invoker, risk: external_write).
render-server-tool-defs.ts: render_ui (:139, live-data-bound OpenUI from NL),snappy_ask (:636, the primary read road), snappy_list_connectors (:205), snappy_query (:240,
exact-handle raw rows over the connector mirror), snappy_config (:296, gated dials stage an
approval), snappy_list_skills (:407), snappy_add_skill (:720), snappy_improve_skill (:739),
snappy_list_automations (:674), snappy_create_automation (:695), post-agent-invoke (:60,
delegate to a roster report over A2A), snappy_managed_intent
(render-managed-intent-tool.ts:22), snappy_render_proof (render-server-render-proof-manifest.ts:7).
render-server-hub-defs.ts:50+: snappy_import_file, snappy_mint_connector(destructive), snappy_diagnose_connector, snappy_connector_capabilities,
snappy_promote_connector, snappy_grants (render-server-grants-tool.ts:27), snappy_approvals,
snappy_connector_action, snappy_activity, snappy_artifacts, snappy_file, snappy_databases,
snappy_connector_health. Scripts/runs (8) — render-server-script-tools.ts:412+:
list/get/create/update/validate/run script, cancel run, retry run. Appended (3) —
snappy_builder_workflow, snappy_credentials (operator-only, secrets never returned),
snappy_libretto (render-server-hub-defs.ts:47).
browser_open, browser_snapshot, browser_status,browser_close, browser_exec; browser_connect is NEVER_EXPOSED_TOOLS
($R/state/lib/browser-hands/browser-hands.ts:41-46).
/Users/robertboulos/Projects/mcp-servers/snappy-os-mcp/ (this repo contains zero wrangler.*
files). wrangler.jsonc:3-11 — name snappy-os-mcp, main src/server.ts, nodejs_compat,
KV OAUTH_KV + TASKS_KV, no routes key ⇒ https://snappy-os-mcp.robertjboulos.workers.dev.
Mounts (src/routes.ts:60-110): /mcp, /admin/mcp, /v1/control/mcp, /v1/access/<id>/mcp,
/turn, RFC 9728 metadata, A2A card. Deploy is the gated chain in package.json:8
(gate:one-road, gate:tenancy, … then wrangler deploy).
It is standalone, not a proxy — it reads Convex (oceanic-frog-640) and Xano. Its own registry
refuses stdio records (src/generated/registry.shard-15.ts:216), and its prompts tell the model the
grants door is loopback-only and unreachable from the worker (src/prompts/craft.ts:119).
session/new be handed this MCP? — yes, if the agent runs on this Mac#The stdio shape is already the production one, and it maps 1:1 onto ACP's
{name, command, args, env}. $R/state/lib/agent-launch-contract.ts:86-206 snappyMcpSpec:
command: "npx", args: ["tsx", <resolved render-server.ts>],
env: { SNAPPY_RENDER_BASE_URL, SNAPPY_XANO_SPINE:"off",
SNAPPY_CALLER_ID: <agent:slug@vN>, SNAPPY_RUN_ID: <run>,
SNAPPY_MCP_TASK_COMPAT:"wait-terminal", SNAPPY_MCP_FULL_SURFACE:"1",
SNAPPY_STATE_ROOT?, SNAPPY_OS_ROOT?, PATH, HOME }
Rendered for claude-code as .mcp.json by composeMcpJson (:237-241) and for codex as
[mcp_servers.snappy-os] in a per-run config.toml (:286-321). A product-facing variant that
prefers the bundled node-universal + baked render-server.mjs already exists at
$R/state/lib/mcp-launcher-placement.ts:204-258, served by
$R/state/bin/head-screen/routes/mcp-connection-info.ts:135-145.
What is missing, precisely:
agent-launch-contract.ts:347-360 records themeasurement: StdioClientTransport.start() spawns with
{...getDefaultEnvironment(), ...serverParams.env}, and DEFAULT_INHERITED_ENV_VARS on POSIX is
only [HOME, LOGNAME, PATH, SHELL, TERM, USER]. A credential dropped from the env block never
reaches the tool road.
render-server-config.ts:25). One MCP processcannot serve two ACP sessions. Cheapest correct answer: **one spawned render-server per ACP
session**, exactly the existing model, with SNAPPY_CALLER_ID = the agent identity and
SNAPPY_RUN_ID = the hosted run id.
/operator/session requires the masterkey already in hand, and SNAPPY_MCP_OPERATOR_SESSION is now build-gated OFF for agents
(agent-launch-contract.ts:127-152, gate scripts/gates/agent-workspace-authority.mjs).
$R/state/lib/caller-identity.ts:29-48). A remote agent cannot reach127.0.0.1:3147 at all.
Plain React 18 + Vite 8 + HashRouter, no external state library — ~20 createContext providers
plus Convex subscriptions (apps/snappy-os/web/package.json:65-71, src/app-shell.tsx:12).
vite.config.ts:29 base: "./" with a header (:7-21) recording that WKWebView on file://
blocks runtime dynamic import() of separate chunks, so modulePreload.resolveDependencies
(:99-115) excludes genui-*, vendor-three-*, vendor-openui-lang-*. Dev proxy forwards
everything vite does not own to http://127.0.0.1:3147 (:80-86).
The bundle is staged into the Swift resources by apps/snappy-os/scripts/build-web.sh:10,127-129
(DEST="$ROOT/Sources/SnappyOS/Resources/web", rm -rf then cp -R dist/.). **Swift does not load
file://** — ChatWebView.swift:357,415 loads http://127.0.0.1:3147/app/index.html, and :351-354
says why: the localhost origin is what makes WebKit permit microphone capture for Realtime voice.
SNAPPY_WEB_URL overrides for the vite loop (:355,366-370); route restoration across installs at
:320-346.
src/bridge.ts:28-39 declares window.bridge {send, recv, on, isHosted} and
window.webkit.messageHandlers.bridge.postMessage; :21 imports BRIDGE_KEYS from
state/lib/bridge-keys.ts — the same file the Swift enum is generated from (bridge-keys.ts:11-18,
swiftIdentifierFor at :113-118). :72 isHosted, :118 sends READY on boot, :125-135
synthesizes BUNDLE_STATE from /deploy-truth in a plain browser.
Call sites that matter: App.tsx:526 (AGENT_SELECT), dispatch/lang-renderer.tsx:343-347
(DISPATCH_ACTION {tool_call_id, action, payload}), components/confirm-dialog.tsx:103-107,
computer-use-detail-io.ts:87-97, lib/install-operator-fetch.ts:93-94 and
lib/operator-authority.ts:58-61 (raw webkit access to remint a stale operator session). Inbound
routing lib/use-bridge-subscriptions.ts:58; Swift receives at ChatWebView.swift:451-459.
__snappy* globals actually in the bundle: __snappySubmitIntent (36), __snappyNav (23),
__snappyLastCard (22), __snappyPendingAddConnection (10), __snappyDebug (8), one each of
__snappyChatBackend / __snappyChatModel. There is no computeruse global — Computer Use rides
the computer-use.* bridge keys plus ${SERVER_URL}/computer-use/*.
(a) The chat surface uses the Vercel AI SDK UI stream, not AG-UI SSE.
src/surfaces/chat/use-chat-pipe.ts:34-38 — `new DefaultChatTransport({ api:
${SERVER_URL}/threads/${threadId}/ui-stream }), consumed by useChat<SdkChatMessage> (:59`).
:72 running = status === "streaming" || "submitted"; :85-94 ask() refuses mid-run and
returns "kept", so the surface queues the sentence. Main component
src/surfaces/chat/surface.tsx (880 lines), lazily routed from src/app-routes.tsx:83.
Per-turn render order (surface.tsx:578-800): human ask (:598-601) → LiveTurnActivity (:632) →
<ol class="cs-steps"> of StepRow tool rows (:633-651, folded by collapseRepeats() :94-119)
→ composed GenUI surface (:661-663) → prose answer (:673-675) → inline artifact cards (:676-687)
→ ConfirmableBinding (:688-695) → asked-choice rows (:704-727) → StagedBlock (:730-741) →
MintedBlock (:745-747) → StagedApprovalBlock (:752) → artifact badge (:753-777) →
DraftDiffReceipt (:778-785).
(b) The agent room reads the daemon's AG-UI tail door.
src/surfaces/agents/live-run.ts is the ONE reader of a moving run and the first web consumer of the
typed AG-UI contract (:1-32).
isRunLive(work) — invocation_state ∈ {claimed, launched} or status === "running" (:51-55).:183) ${SERVER_URL}/agent/invocations/<runId>; frame door once GET .../ag-ui(:189), prose prefix GET .../output (:204), live new EventSource(".../output/events") with
listeners on "output" and "message" (:222-230) and a fold reset on "open" (:234) because
the AG-UI leg replays from the beginning. Settled runs read the sidecar exactly once (:200-203).
foldLiveRunFrame (:107-133) sends AG-UI events to both foldTypedReplyEvent andfoldRunTranscript; a {line} payload starting { is JSON-parsed and re-tested (:119-131);
everything else is prose capped at 400 lines (:45). liveRunSource() → "ag-ui" | "prose" | "none"
(:144-147).
src/lib/ag-ui-transcript.ts:61-89) folded fromthe 20-member event tuple (src/lib/ag-ui-event-contract.ts:35-61, guard :277-285) by
foldRunTranscript (:218-301): said ← TEXT_MESSAGE_*; thought ← the five REASONING_*;
tool {name, args, ended, result:{text,failed}} ← TOOL_CALL_START/ARGS/END/RESULT;
diagnostic {level} ← HARNESS_TRACE/HOOK_TRACE (trace), BACKEND_NOTICE/LIVE_COMMENTARY
(notice), RUN_ERROR (error, suppressed when it merely repeats the speech, :191-198).
RUN_STARTED/RUN_METADATA add no entry (:222-224); RUN_FINISHED sets terminal (:225-226).
run-stream.tsx: Reply (:93-113, clamped past 700 words / 12 lines), RunStream(:115-186, collapses all but the last message into <details data-run-steps> once settled),
RunDetails (:201-263, data-tool / data-tool-state={"ended"|"running"|"failed"|"ok"} /
data-diagnostic / data-reasoning). toolName (:31-40) de-MCPs
mcp__snappy-os__snappy_execute → "Snappy OS · execute"; toolWords (:42-50) picks tense.
:80-84 deliberately does not draw tool arguments or result bodies — "one result ran 17,506
characters of raw payload."
conversation.tsx (894) decides which run gets which: RunEpisode (:598-791) setsstreaming = work.said === null && isRunLive(work) (:609-610) and lets the stream outrank the
ledger only when frames exist (:613); RunProcess (:240-456) draws live "Using X" (:263-269),
delegation children (:284-330), and the "Pick a different brain for the next ask" remedy
(:348-356). output-face.ts:34-59 marks a face "generated" only when
face.for_version === artifact.current_version, else "fallback" with a stated reason.
tool_call update naturally lands — two existing seams#src/surfaces/chat/sdk-adapt.ts:97-127 toolPartView(part) already accepts bothtool-<name> and the SDK's dynamic-tool shape (:102-105 — "a tool the client declared no
schema for is still a step that ran"), yielding `{toolCallId, toolName, input, state, outcome,
errorText}; :161-176 turns that into a running Step, :135-159 into a ledger entry, :178-179`
settles on output-available | output-error. Step is
src/surfaces/chat/surface-record.ts:52-84 with state: "waiting"|"running"|"done"|"refused".
StepRow (src/surfaces/chat/step-row.tsx:13-136) renders it, with a refusal block at :103-131
(role="alert", remedy doors) and a refusal-storm tally at :21-44.
ToolEntry → run-stream.tsx:242-253. ACP'sstatus: pending|in_progress|completed|failed maps 1:1 onto
TOOL_CALL_START / TOOL_CALL_END / TOOL_CALL_RESULT{isError}.
src/components/confirm-dialog.tsx:100-107— it sends BRIDGE_KEYS.DISPATCH_ACTION with `{tool_call_id, action: "approve"|"reject", channel:
"confirm"}` over the Swift bridge. That is a per-tool-call yes/no decision routed natively, which is
structurally exactly session/request_permission. Reuse this shape rather than inventing one.
src/surfaces/chat/staged-approval.tsx:16-26 deliberately draws no approve/deny — it renders a"Review it" door to Needs You, because "its whole lifecycle … is owned by the runtime's approvals
queue, and Needs You is its one room." Any ACP permission UI must decide which of these two laws it
is under: an in-turn decision (confirm-dialog) or a queued one (Needs You).
src/surfaces/needsyou/surface.tsx:14-42 —onDecide(approvalId, verb: "approve"|"decline", reason, digest, daemonLifecycleId?) behind a
build-skew decisionLock (:23-34). The portal stack of provider approvals is
src/components/pending-approvals.tsx:1-16 (reads /approvals/lifecycle?status=pending, approve →
POST /provider-approvals/:id/approve).
src/surfaces/parts/diff.tsx — the canonical word-level was/becomes diff, wordDiff() at:35-70 (LCS over whitespace-preserving tokens); its header :1-22 states it is the ONE drawing
and bans +/− glyphs. This is the natural home for ACP's {oldText, newText}.
src/genui/diff-view.tsx:15-33 — DiffView, an OpenUI Lang defineComponent taking aunified-diff patch string with mode: "unified"|"split"; registered in genuiLibrary and
canvasLibrary, so a model can already emit it.
src/surfaces/chat/draft-diff-receipt.tsx (111) — the chat turn's diff receipt with checkpointrevert. Plus the line-set src/errand-change-diff.tsx:20-35 ({removed, added, moved},
three sets on purpose because added/removed cannot see a reorder, :10-14).
docs/HANDOFF-20260902-NIGHT.md:159-160): "Needs You: 32 items each with afull-width bright Approve bar; DESIGN.md §5 wants compact buttons. Untouched."
src/mode-chips.tsx portals a ProviderModelPickerChip into the OpenUIcomposer bar via MutationObserver (:17-19). The choice lands in the window globals
(lib/requested-dispatch.ts:11-12, coercion :43-45) and is persisted server-side by
postAxis(axis:"chat"|"subagent", {backend?,model?,thinking?}) → POST /dispatch-config
(mode-chips.tsx:440-517; a backend flip forces model:"auto", :498-517). Shipped every turn at
src/lib/chat-request.ts:145.
room.tsx:384-432: Computer <select>, theProviderModelPickerChip inside <span id={BRAIN_PICKER_HOST_ID}>, and for managers a scope
<select> "this run only" | "this run and its team" (default team, :223). Sent as
{runtime_hint, model_hint?, runtime_scope} via destinations/studio-convex-bridge.tsx:44-50 into
api.agent_deployments.agentInvoke, stored on the run row, enforced at launch (§D.6).
settings-pane.tsx:112-143applyAgentBrain({definitionId, runtime, model, reasoningEffort}) → api.agent_brain.apply, which
mints a new agent version. The browser owns no model list (:141-153).
"Operating face" is imported at surfaces/agents/surface.tsx:5-10 but its mission band is
deliberately not rendered (:91-103); readModelOptions / applyAgentBrain are host seams so an
MCP App can supply native tools instead of Convex (settings-pane.tsx:104-110, room.tsx:499-501).
convex/tables/today.ts:397-600 — ONE runs table carries script runs and agent invocations alike:
invocation_state, invocation_prompt, runtime_hint, model_hint, model_effort_hint,
runtime_scope, runtime_inherited_parent_run_id (:401-425), invocation_claimed_by (:434),
invocation_reclaimed_from (:444), invocation_lease_expires_at (:445), execution_wall_ms
(:448), the six lineage fields `agent_version / effective_contract_digest / authority_revision /
computer / runtime / workspace_ref (:449-454), then status, summary, did, structured_output`
(:502, absence load-bearing), results_face / results_face_refusal (:508-509), cost_usd,
model_resolution, provider_selection, steps_total. Index by_owner_invocation (:583-599) is
what the executor polls.
convex/agent_invocations.ts:
:51-99): INVOCATION_LEASE_MS = 15 min; `LAUNCHED_LEASE_MS = wall + 5 mingrace, manager variant 20 + 5. :66-84` records the 45→20 minute correction after
agent-run-100be712 sat launched with no process behind it. clampedNow() (:94-99) discards a
wire now more than 60 s off server time.
claim (:342-456): `claimed | reclaimed | already_launched | contested | not_invocable |no_such_run. A dead lease reclaims **and nulls all six lineage fields** (:391-395`) so a stale
launch receipt can never be read as this attempt's.
reportLaunch (:491-607), the strictest door in the system, refuses unless: state claimedand holder matches (:504-509); digest matches /^[0-9a-f]{64}$/ (:510-512); provider control
presents provider_selection (:513-521); **runtime === run.runtime_hint, else "a forbidden
provider fallback"** (:522-527); model_resolution.runtime === runtime (:528-533); model hints
proven with their source (:534-559); provider_selection matches `{runtime, scope, source,
inherited_parent_run_id} (:560-579). execution_wall_ms` is a **closed union of the two wall
literals** (:473).
settle (:704-736): holder-only, and **:724-732 a claimed-but-never-launched run maynot settle finished**. applySettlement (:743-784) writes
produced: status === "finished" && outputs.length > 0, then attachOutputs().
convex/run_receipt.ts:123-173 — a receipt is: envelope (run-receipt-v1, counted:1), run
(:33-54), definition joined from immutable agent_versions first (:190-218),
execution_contract (all ten lineage fields, :69-80), skills: slug@version, decisions,
effects, outputs {artifact_id,slug,title,kind,file_url,faced,href}, orchestration, bounded
evaluations/activity that answer null rather than guess, **unjoined: string[] naming every
hole**, delegation {parent_run_id,is_child,children[],counted,complete}, and — only while the run is
live — `control {cancel:{tool_id:"post-work-control", arguments:{verb:"cancel", target_kind:"run",
target_id, reason}}} (:160-171, :403-413). **That control.cancel` is the shape a Stop button
already speaks.** The list row is convex/reads_runs_stream.ts:44-105
(population_contract:"runs-execution-instances-v1", RUN_WINDOW = 1000).
$R/state/CONSTITUTION.md — five invariants; only #3 is currently backed by a check.
:39-74): a telemetry rowmust carry actor_session_id and auditor_session_id and they must differ, enforced
synchronously in $R/state/lib/log.ts assertEvalProvenance(). **"The thing that generates output
cannot be the thing that grades it."** An auto-approving ACP host that also writes its own
"approved" receipt is on the wrong side of this line unless the approval row names a distinct
auditor. Note the precedent: agent-invocation-executor.ts:191-203 explicitly refuses to let the
executor grade its own run.
UNKNOWN — their lints were deleted (:21-27, :35-38,:83-88, :114-127). :207-223 states plainly that a "Proven by" line naming a deleted file is
worse than no claim.
:174-205): per-skill .openui files, Class A cron, the frictionsledger.
$R/program.md — the product contract:
:23-24): "Scope-only by default. External sends, posts, publishes, deletes, and remotemutations require explicit apply:true."** This is the one rule Robert's auto-approve decision
collides with. It does not forbid auto-approving a local file write by a hosted agent; it
does forbid auto-approving an outbound connector effect. The existing lane already enforces this
server-side (write-apply-gate.ts:7-24), so a hosted agent going through the snappy MCP still
stages — auto-approve at the ACP layer cannot bypass it. Say this out loud in the plan.
:27-28): actor ≠ auditor, restating CONSTITUTION #3.:25-26): "The app must be dogfooded through the installed Snappy OS app before a fix iscalled live." An ACP feature proven only by swift test is not landed by this repo's own rule.
:8): "One path: every request enters the same Snappy AI SDK harness." A hosted ACP turnthat renders into the chat is a second path unless it lands on the same AG-UI vocabulary and the
same run ledger. That is the single strongest architectural constraint in this report.
state/skills/ holds ~200 skill folders; state/index.md and program.md:63-69 fix the repo shape.
Delegation workspaces are governed by $R/state/lib/agent-workspace.ts:1-27 — every dispatched
execution starts inside an effective, DERIVED AGENTS.md whose sha256 is the execution-contract
digest, and a launch that cannot present it is refused at the Convex door (:24-27).
| Seam | Existing stream-json road (file:line) | What ACP adds | What must change | ||
|---|---|---|---|---|---|
| Run creation for a hosted turn | A run is minted in Convex, polled by runAgentInvocationPass from GET /bridge/agent-invocations (agent-invocation-executor.ts:573), claimed (:663), governed workspace materialized (:789), launch receipt reported with agent version + AGENTS.md sha + authority revision (:821-835), then launchAndSettle (:844-854). MAX_CONCURRENT = 4 (:111) |
Nothing — ACP has no run concept. session/new returns only a sessionId |
An AgentHost turn must mint a run row before the first prompt so the receipt lane, cost, steps and Activity all join. Cheapest honest road: POST /agent/:slug/say (routes/agent-say.ts:1-26) already answers {reply, spawned_run_id} — reuse it, or add a sibling that opens a run and returns its id to Swift. reportLaunch will refuse a hosted launch that does not present a 64-hex contract digest and a runtime equal to the run's runtime_hint (convex/agent_invocations.ts:510-527), and execution_wall_ms is a closed union of two literals (:473) — a hosted turn either reuses a wall or the union grows |
||
| Event streaming to the chat | Two roads. Agent runs: stdout → createAgentRunAgUiWriter (agent-run-ag-ui.ts:567-601) → <workspace>/ag-ui.jsonl (:544-548) → GET /agent/invocations/:id/output/events SSE default message leg (delegation-tail.ts:130-160) → foldLiveRunFrame (live-run.ts:107-133) → RunTranscript → run-stream.tsx. Chat: DefaultChatTransport over ${SERVER_URL}/threads/:id/ui-stream (use-chat-pipe.ts:34-38) → sdk-adapt.toolPartView (:97-127) → StepRow |
Push, in-process, with messageId boundaries and per-toolCallId status (pending→in_progress→completed/failed) that stream-json does not carry |
Do not invent agent.text / agent.tool bridge keys. Pick one of the two existing roads and speak its vocabulary: the AG-UI sidecar (write the JSONL from Swift, or POST frames to a small daemon door) for a run-shaped turn, or the SDK dynamic-tool part shape (sdk-adapt.ts:102-105) for a chat-shaped one. A native AgentEventProjector emitting ChatBridge.sendToJS("agent.update") (plan §1) would be a third protocol reader, which live-run.ts:26-31 and program.md:8 both exist to prevent |
||
| Permission → approval route | There is none for agent runs. claude -p --dangerously-skip-permissions (agent-launch-contract.ts:813); codex approval_policy = "never" + default_tools_approval_mode = "approve" + trust_level = "trusted" (:300, :311, :316-317). In chat, the per-tool-call affordance does exist: components/confirm-dialog.tsx:100-107 sends `dispatch.action {tool_call_id, action:"approve"\ |
"reject", channel:"confirm"}` over the bridge | session/request_permission {toolCall, options[{optionId, name, kind}]} → `{outcome:{outcome:"selected"\ |
"cancelled", optionId}}`. Option ids are agent-defined strings; codex-acp fails closed on unadvertised ids | Robert chose auto-approve. Still: (a) copy confirm-dialog's {tool_call_id, action} shape rather than inventing one — it is already a per-tool-call native decision; (b) every auto-approval must land as a visible event — the honest home is a DiagnosticEntry (ag-ui-transcript.ts:84-89) via BACKEND_NOTICE, plus one Activity row like the settle already writes (agent-invocation-executor.ts:452-483); (c) the four ACP option kinds map exactly onto actionExecutionPolicy's stage_only / approve_each / delegated / no_grant (delegated-authority.ts:41-69), so flipping to approve-each later is a lookup, not a redesign; (d) decide explicitly whether a permission is an in-turn decision (confirm-dialog) or a queued one — staged-approval.tsx:16-26 states the standing law that "Needs You is its one room"; (e) program.md #7 (program.md:23-24) still binds — a connector write through the snappy MCP stages regardless of the ACP answer (write-apply-gate.ts:7-24) |
| MCP injection of connectors | Written to disk per run: .mcp.json + --mcp-config … --strict-mcp-config for claude (agent-launch-contract.ts:237-241, :832), [mcp_servers.snappy-os] in a per-run CODEX_HOME/config.toml for codex (:286-321, :710) |
session/new {cwd, mcpServers:[{name, command, args, env:[{name,value}]}]} — passed in the request, no file, env is an array not an object |
Reuse snappyMcpSpec (:86-206) unchanged and marshal it into ACP's array shape. Per-session SNAPPY_CALLER_ID + SNAPPY_RUN_ID because MCP identity is process-scoped (render-server-config.ts:25). Keep SNAPPY_MCP_OPERATOR_SESSION absent — the build gate scripts/gates/agent-workspace-authority.mjs fails on any spelling of it (:127-152) |
||
| Session persistence | Per-run directory <stateRoot>/delegations/<runId>/workspace/ (agent-invocation-executor.ts:169-171) holding AGENTS.md, TEAM.md, task.json, authority.json, skills/, output.md, ag-ui.jsonl, and the isolation roots .codex/, .home/, .claude-config/, .mcp.json, .pi-agent/ (agent-launch-contract.ts:225-233). Mode 0700/0600 (:367-371) |
session/load / session/resume / session/list / session/close — a session outlives a turn |
The plan proposes ~/Library/Application Support/Snappy OS/agents/<id>/ (ACP-HOST-PLAN.md:40). That is a third state root beside the delegation workspace and SNAPPY_STATE_ROOT. Put hosted sessions under the same stateRoot() the daemon uses (state-root.ts), or agent-launch-contract.ts:183-199 repeats itself — that comment records 571 stray grant stores on this disk from exactly this mistake |
||
| Login / auth surfaces | resolveClaudeCredential over a slot ladder + credentialBaseEnv; an absence REFUSES with runtime_credential_unavailable (agent-launch-contract.ts:771-779). Codex auth is a symlink of the machine auth.json into the per-run CODEX_HOME (:380-383). POST /codex-login exists (routes/codex-login.ts), and routes/claude-subscriptions.ts holds the slot store |
-32000 + authMethods, including {type:"terminal", id, args, env} for a TUI login the client must run as a separate interactive process |
A PTY is new surface — nothing in the Swift tree runs one today. authMethods of terminal type are only advertised if the client sets clientCapabilities.auth.terminal; do not set it in P1, and route a -32000 to the existing /codex-login / claude-slot roads instead |
||
| Cost / usage display | cost_usd: stream.result()?.cost_usd ?? null at settle (agent-invocation-executor.ts:438), harvested from claude's terminal result frame total_cost_usd (agent-run-ag-ui.ts:327-335). Codex publishes no cost — cost_usd: null deliberately (:387-394). A null means nobody measured, never zero |
usage_update {used, size, cost?} (cumulative) and prompt-response _meta.quota — a live number mid-turn, which stream-json never gives |
Map usage_update to a new AG-UI event or to RUN_METADATA; do not stuff it into BACKEND_NOTICE. Preserve the null-vs-zero law. This finally gives codex a cost |
||
| Cancel semantics | cancelDelegation(root, runId, reason) (agent-invocation-executor.ts:869-893): SIGTERM the held child then SIGKILL after 10 s, exit path settles cancelled_by_control; no local child ⇒ claim the row and settle. The wall is SIGTERM + SIGKILL after 5 s (:498-503). The UI-facing shape already exists: run_receipt.ts:160-171, 403-413 serves control.cancel = {tool_id:"post-work-control", arguments:{verb:"cancel", target_kind:"run", target_id, reason}} only while a run is live |
session/cancel is a notification, cooperative; the prompt MUST return stopReason:"cancelled", and the client MUST answer every pending request_permission with the cancelled outcome |
Keep cancelDelegation as the outer authority (reachable from the governed work-control verb, and already spelled for the glass by run_receipt.control.cancel) and add an inner cooperative step: session/cancel → wait ≤5 s → tree kill via the existing collectDescendants (HeadScreenSubprocess.swift:635-667). Never SIGTERM mid-tool-call (ACP-HOST-PLAN.md:67) |
||
| Orphan / process supervision | Three named death modes in $R/state/lib/agent-invocation-recovery.ts:7-24: the tsx pipe leak (5,316 dead pipes at 96 % volume), the silent ENOSPC that launches a toolless agent, and the dead launcher (an install killed the daemon holding a run). Preflight probes a unix-socket listen before claiming anything (agent-invocation-executor.ts:627-648); a reclaimed row with a pre-existing workspace always settles rather than relaunching (:675-682) |
Nothing — ACP has no supervision story | An AgentHost inherits all three. It also removes one: an in-process ACP client does not spawn npx tsx per MCP call the way… actually it still does (the MCP server is spawned per session), so the pipe reaper stays load-bearing. Reuse decideHealthProbeAction's shape: a pure function over {childAlive, sessionOpen, lastFrameAtMs} |
||
| Deploy-truth implications | GET /deploy-truth compares the running process to the last deploy stamp and names runtime_owner + state_paths (routes/health-and-observability.ts:176-230). resolveRenderServerPath exists because every install mints a new runtime dir and reaps the old — 41 of 41 workspaces named a file that no longer existed (agent-invocation-executor.ts:296-311) |
Nothing | An AgentHost living in the Swift binary changes the truth question from "which runtime dir" to "which app binary". /deploy-truth must gain the host's own identity, or "is the ACP host live" becomes unanswerable — and this repo's whole method (docs/HANDOFF-20260902-NIGHT.md:88-91) is curl /deploy-truth. Also: an ACP adapter resolved by absolute path has the same reaping problem as render-server.ts did |
The ask: "write notes.md in the workspace." Runtime claude-code. What actually happens now:
runAgentInvocationPass polls, claims, materializes<stateRoot>/delegations/agent-run-XXXX/workspace/ with AGENTS.md + TEAM.md + authority.json
+ skills/, and emits claim-time receipts (agent-invocation-executor.ts:723-799).
materializeToolRoad writes .mcp.json (0600) namingnpx tsx <render-server.ts> with SNAPPY_CALLER_ID=agent:<slug>@vN, SNAPPY_RUN_ID=<run>
(agent-launch-contract.ts:333-385). toolRoadIsOpen re-stats it right before spawn
(agent-invocation-executor.ts:330).
agent_invocation_launch to Convex with model resolution, agent version,AGENTS.md sha256, authority revision, computer, runtime, workspace ref, wall
(:821-835). A launch without lineage is refused at the door.
"Agent,ListAgents,SendMessage,Task","--output-format","stream-json","--verbose",
"--mcp-config",<path>,"--strict-mcp-config","--",prompt], {cwd: workspacePath, env, stdio:
["ignore","pipe","pipe"]}) (:384-388, argv at agent-launch-contract.ts:812-834`). 15-minute
wall armed (:498-503, DELEGATION_WALL_MS at :121).
ag-ui.jsonl (agent-run-ag-ui.ts):RUN_STARTED {threadId: <session_id>, runId} ← system/init :245
RUN_METADATA {threadId, runId, harnessMode:"delegation"} :246
HARNESS_TRACE {backend:"claude-code", model, internalRoute:"agent-invocation-executor"} :247-252
TEXT_MESSAGE_START {messageId, role:"assistant"} ← assistant/text :275
TEXT_MESSAGE_CONTENT {messageId, delta:"I'll write that file."} :276
TEXT_MESSAGE_END {messageId} :277
TOOL_CALL_START {toolCallId:"toolu_01…", toolCallName:"Write"} ← tool_use :293
TOOL_CALL_ARGS {toolCallId, delta:'{"file_path":"…/notes.md","content":"…"}'} :294
TOOL_CALL_END {toolCallId} :295
TOOL_CALL_RESULT {toolCallId, toolName:"Write", content:"…", isError:false} ← user/tool_result :306-312
TEXT_MESSAGE_START/CONTENT/END {messageId2, "Done — notes.md written."} :275-277
RUN_FINISHED {threadId, runId, outcome:"success"} ← result frame :346
There is no permission frame anywhere in that list, because the flag turned it off.
createAgentStepRecorder (agent-run-steps.ts:47-96) foldsTOOL_CALL_START → open, TOOL_CALL_RESULT → one persisted RunStepRecord:
`{run_id, index:1, step_id:"toolu_01…", tier:"capability", binding_digest:"",
description:"Used Write", effect:"local-write" (LOCAL_WRITE_TOOLS matches Write, :26-30),
status:"ok", started_at, finished_at, duration_ms, output_digest: sha256(first 500 chars),
output_bytes, cloned_from_run_id:null, result:{step_id, tier:"capability", ok:true, output:<preview>}}`
→ persistRunStep (script-run-steps.ts:56-90), which also bridges to the store's run_steps
table (:1-25).
output.md(agent-invocation-executor.ts:519-525), rewritten wholesale at settle (:406).
settlementForExit (agent-run-answer.ts:228-273) turns exit 0 + text intofinished; agent_invocation_settle carries summary, did (the whole final answer, never a
beheaded tail), cost_usd from the result frame, steps_total: 1, `model:
describeLaunchRecord(...), and outputs: [<deliverable with its composed OpenUI face>]`
(agent-invocation-executor.ts:423-451; face at $R/state/lib/agent-output-face.ts:18-30).
activity_event row: kind:"run", lifecycle_state:"succeeded",effect:"produces-content", href:"#/library/activity?run_id=…", latency_ms
(:452-483), a local agent-delegations log line (:484-489), and a mechanical outcome row
written by a different function than the one that produced the work (:191-203).
live-run hook attaches to .../output/events, resets on open,folds every frame, and run-stream.tsx draws: one said bubble, one tool row reading "Wrote a
file" / "Used Write", one said bubble, then the settled face.
Under ACP the same turn gains a tool_call {kind:"edit", status:"pending"} before the write, a
session/request_permission the host answers, a `tool_call_update {status:"completed",
content:[{type:"diff", path, oldText, newText}]}, a usage_update, and {stopReason:"end_turn"}`.
Steps 1–3 and 6–9 are unchanged work that must still happen — they are not ACP's job, and skipping
them is how a hosted turn becomes invisible to Today, Outputs, Activity and the receipt rail.
git worktree list | wc -l → 111, including 7 locked/private/tmp/snappy-fable-audit-* and ~90 under ~/.codex/worktrees/. docs/TRAPS.md:218-226:
never git stash with worktrees (one shared refs/stash), bootstrap before any test, and
positional vitest args match worktree copies too. A swift test run from the wrong tree proves
nothing.
/Users/robertboulos/projects/snappy-os-app; git worktree list and the running daemon both say
/Users/robertboulos/Projects/snappy-os-app (capital P). macOS's case-insensitive filesystem
hides it, but SNAPPY_OS_ROOT string comparisons and detectRuntimeOwner
(routes/health-and-observability.ts:178-185) do not.
apps/snappy-os/Sources/SnappyOS/Resources/web/ are in git; right now git status shows
build-stamp.txt and index.html modified. docs/HANDOFF-20260902-NIGHT.md:88-89: a full
app:install "refuses on ANY dirty tracked file". So the fast web swap and the full install are
mutually exclusive until you commit or check out.
node-universal is 267 MB and gitignored (apps/snappy-os/.gitignore:20). A fresh clonecannot build a working app; the Swift shell then falls back to /opt/homebrew/bin/node
(HeadScreenSubprocess.swift:19-28). A hosted ACP adapter that assumes the bundled node is
present will fail on exactly the machines that already fail.
`/Applications/SnappyOS.app/…/runtime/node-universal
/Users/robertboulos/Projects/snappy-os-app/apps/snappy-os/.build/runtimes/<hash>/state/bin/head-screen/server.mjs`.
This is precisely the failure resolveRenderServerPath was written for — 41 of 41 workspaces
named a deleted file (agent-invocation-executor.ts:296-311). **Any absolute path to an ACP
adapter must be resolved, not pinned.**
com.snappy.head-screen-app(HeadScreenSubprocess.swift:18), an app-spawned child (:533-596), and a dev
npm run app:preview on the same port — docs/TRAPS.md:96-101: the preview daemon claims :3147
with the repo-tree state root and every later app:install then fails the durable-state gate and
rolls back. One full install already burned on this.
apps/snappy-os/dist/ holds both SnappyChat.app andSnappyOS.app; docs/TRAPS.md:171-172 — "SnappyChat is a dead name; quitting it silently does
nothing." The icon resource is still SnappyChat.icns, and a UserDefaults suite ai.snappy.chat
is still read (HeadScreenSubprocess.swift:100-113).
(docs/TRAPS.md:102-110) — which is why AuthSessionKeeper exists, and why the login keychain
was abandoned after it cost a rolled-back install and a night of password dialogs (:111-126).
command_execution, but not to MCP.$R/state/lib/agent-bus-address.ts:120-141: run agent-run-1ca12b01 reported "the A2A endpoint
was unreachable on port 3147" while the door answered 200 the whole time — a shell curl to
loopback cannot connect, while the MCP server spawned outside the sandbox could. Any hosted
codex-acp session inherits this asymmetry.
render-server-config.ts:25), soa long-lived shared MCP process across ACP sessions would attribute every governed read to one
caller. One process per session, or a new per-request seam.
SNAPPY_MCP_OPERATOR_SESSION is a landmine with a build gate on it. It once made everydelegated agent be Robert — /me answered `person:local, authority ["read","stage","execute",
"approve"]` — and falsified three separate safety comments at once
(agent-launch-contract.ts:127-182). Never set it from an ACP host.
docs/HANDOFF-20260902-NIGHT.md:151-155: one sentence from anagent is 42.6 s wall, runs has no claimed_at/launched_at, AG-UI frames carry no
timestamps, and the executor logs only the settle. An ACP host will be judged on latency and the
instrument is missing. Adding a timestamp to each AG-UI frame is the cheap fix and would benefit
both roads.
output_contract (:196-198), so typed GenUI outcomesalmost never render. An ACP host does not change this.
docs/HANDOFF-20260902-NIGHT.md:68-72) — do notread them as your regression, and do not .skip() them.
grep in this session's shell is a function that returns silence for files it calls binary(docs/TRAPS.md:11-23). Use /usr/bin/grep for any search whose empty result you intend to
report. Two files in Sources/SnappyOS/Resources/ are exactly that shape.
pickRuntime returns null rather than substituting a provider (agent-invocation-executor.ts:242-254:
"A provider is authority and billing identity, not a preference"), and reportLaunch refuses a
runtime that differs from run.runtime_hint as "a forbidden provider fallback"
(convex/agent_invocations.ts:522-527). An AgentHost that quietly falls back from claude to
codex when a login is missing will be refused at the door — correctly.
(use-chat-pipe.ts:34-38, /threads/:id/ui-stream); the agent room is the AG-UI SSE tail
(live-run.ts:183-234). A hosted ACP turn must pick one; landing frames in both is a third road.
Also: ask() refuses mid-run and returns "kept" (use-chat-pipe.ts:85-94), so an ACP
session that expects to accept a prompt while a turn is open has no chat surface today.
claimed-but-never-launched run may not settle finished (convex/agent_invocations.ts:724-732).A hosted turn that spawns, talks, and finishes without ever writing a launch receipt cannot record
success — it can only record failure. This is the single sharpest reason the run-creation seam
(§F row 1) has to be built before P1's "text streams into the web chat" milestone is called done.
Stated plainly, because they are cheap to fix now and expensive later.
ACP-HOST-PLAN.md:29 routes hosted-turn events throughChatBridge.sendToJS("agent.update", …) into the web chat.** That is a second stream protocol
beside AG-UI, which live-run.ts:26-31 and ag-ui-event-contract.ts:1-27 were both written to
prevent, and which program.md:8 ("one path") forbids. Emit the existing AG-UI vocabulary —
the web fold, the transcript, the tool row and the step recorder all come free, and the only
genuinely new frames are permission, plan, diff, terminal and usage.
ACP-HOST-PLAN.md:34 says "the Node runtime is not in the loop for hosted turns; it onlyreceives run receipts."** But the run ledger, the approval mint, the grant store, the Activity
stream, the deliverable face and steps_total are all Node-side, and the Convex launch door
refuses a launch that cannot present its lineage (agent-workspace.ts:24-27). A hosted turn
that skips them is invisible to Today, Outputs, Needs You and the receipt rail. Swift should own
the process and the protocol; the run's identity and governance stay where they are.
ACP-HOST-PLAN.md:40 puts sessions in~/Library/Application Support/Snappy OS/agents/<id>/.** That is a third state root beside
SNAPPY_STATE_ROOT and the delegation workspace. agent-launch-contract.ts:183-199 records what
a divergent state root already cost: 571 stray grant stores on this disk, 24 of them minted in
delegation workspaces in a single day, because a child resolved its own root and
loadCallerStore() seeds on absence rather than refusing.
codex-acp inherits the seatbelt sandbox's loopback denial (§H.9) — needs one live probe.swift-acp 0.1.0 builds under this package's swift-tools-version:6.0 strict concurrencywith .unsafeFlags present (Package.swift:80-83); no resolve was attempted (read-only pass).
POST /threads/:id/ui-stream (the chat transport) can carry ACP-shaped tool parts, orwhether a hosted turn must go down the agent-run road instead. The client side already tolerates
dynamic-tool (sdk-adapt.ts:102-105); the server side of that door was not read.
api.agent_brain.apply (per-definition brain, settings-pane.tsx:112-143) would accept anacp runtime word, or whether EditableAgentRuntime and the store's model-policy vocabulary
(convex/tables/agent_model_policy.ts) need a new member first.
# Lane P1 — SnappyOS.app seams for a Swift-native ACP agent host
Date 2026-09-02. Repo `/Users/robertboulos/projects/snappy-os-app` @ `dffc5616c`, branch
`codex/clinical-operating-face-20260831`. Read-only pass; nothing modified.
Every claim below carries `path:line`. Paths are absolute; `$R` = `/Users/robertboulos/projects/snappy-os-app`.
---
## 0. THE FRAME — this is not a new feature, it is a second road onto an existing one
The product **already spawns the real `claude` and `codex` binaries** as batch agent runtimes,
already translates their structured stdout into a typed AG-UI event stream, already folds that
stream into a run transcript on the glass, already records every tool call as a run step, already
mints receipts, and already has a four-state permission vocabulary per caller × action.
What it does **not** have is an interactive turn. The whole road runs
`claude -p --dangerously-skip-permissions` (`$R/state/lib/agent-launch-contract.ts:813`) and
`approval_policy = "never"` (`:300`) — permission is *turned off at the source* because there is no
channel to ask over. ACP is exactly that channel.
So the question this report answers is not "how do we add agents", it is
**"where does an ACP host attach to the existing road, and what does it displace?"**
There is currently **zero ACP code in the tree**: `grep` for `acp`/`swift-acp`/`AgentHost` over
`apps/snappy-os/Sources` + `Package.swift` + `Package.resolved` matches only bundled web assets and
the 267 MB `node-universal` binary. `$R/research/ACP-HOST-PLAN.md` (75 lines, uncommitted) is a
proposal; §7 records that a Swift probe against `claude-agent-acp` 0.73.0 did a permission
round-trip and a file write in 10.4 s.
---
## A. THE SWIFT SHELL
`apps/snappy-os/Sources/SnappyOS/**` — 82 Swift files, ~23,913 lines, plus a second executable
`SnappyCUHelper` (7 files, own TCC grants).
### A.1 `Launch/HeadScreenSubprocess.swift` (897) + `+HealthProbe.swift` (456)
`final class HeadScreenSubprocess: @unchecked Sendable` (`:4`), guarded by `let stateLock = NSLock()`
(`:145`). **Not an actor** — `Package.swift:22-27` documents this as deliberate.
- **Ports are fixed, not allocated**: `defaultHeadScreenPort = 3147`, `defaultLivenessWorkerPort = 3150`
(`:13-16`); resolution from `PORT` ?? `HEAD_SCREEN_PORT`, `LIVENESS_WORKER_PORT` (`:186-199`).
- **Executable resolution**: bundled `runtime/node-universal` first, then `/opt/homebrew/bin/node`,
`/usr/local/bin/node` (`:19-28`); script from a `snappy-os-runtime.path` pointer file →
`<root>/state/bin/head-screen/server.mjs` (`:29-43`, `:67-82`). **Three spawn roads** in `start()`
(`:345-415`): bundled AOT (`spawnAot`, `:533-596`), dev AOT (runs `build-server.sh` with a 12 s wall,
`:393-395`), tsx fallback (`npx tsx <script>`, `:668-731`).
- **Env handed to the child** (`applyHeadScreenRuntimeDefaults`, `:461-488`, `PATH` at `:545-546`):
`PATH` (prefixed `/opt/homebrew/bin:/usr/local/bin:`), `PORT`, `HEAD_SCREEN_PORT`,
`LIVENESS_WORKER_PORT`, `HEAD_SCREEN_HOST=127.0.0.1`, `SNAPPY_WEB_ROOT`, `SNAPPY_MASTER_KEY`,
`SNAPPY_NATIVE_CU_CAPABILITY`, `SNAPPY_NATIVE_CU_SOCKET`, `CANVAS_RENDER_VALIDATE_ACTIVE`,
`SNAPPY_STATE_ROOT` (`~/Library/Application Support/Snappy OS/state`, bundle launches only).
Identity is injected before `startAsync()` (`AppDelegate.swift:181-185`) from
`ServerConfig.swift:127-165` (defaults → env → `~/.claude/skills/snappy-settings/.env.cache` →
24-byte `SecRandomCopyBytes`) into `HeadScreenSubprocess.swift:457-459`.
- **The external-daemon path is real and already the launchd story**: label
`com.snappy.head-screen-app` (`:18`), attach branch `:352-362` (`setOwnsLifecycle(false)`, never
spawns/kills), recovery by `launchctl kickstart -k gui/<uid>/<label>` (`:284-305`); ownership from
`externalBackend` (`:100-113`) / `externalBackendOwner` (`:114-127`).
- **Supervision is a pure, unit-testable decision** — `decideHealthProbeAction(...)`
(`+HealthProbe.swift:268-283`) → `.leaveAlone / .terminateOwnedChild / .takeOver /
.recoverLaunchdDaemon / .leaveDeveloperLoopAlone`; an out-of-band liveness worker on :3150 vetoes
every kill (`:119-143`). **Restart**: `crashWindow = 60 s`, `crashLimit = 3` (`:129-130`), backoff
`pow(2, n-1) * 0.5` s, then permanent give-up (`:762-778`). **Tree kill**: `stop()` walks
`pgrep -P <pid>` recursively, SIGTERMs in reverse, 2 s grace, SIGKILL (`:605-667`);
`AppDelegate.installSignalHandlers` routes SIGTERM/SIGINT through `NSApp.terminate` so nothing is
orphaned (`AppDelegate.swift:557-590`).
### A.2 `Bridge/ChatBridge.swift` (549) + `BridgeKeys.generated.swift` (51)
`@Observable @MainActor final class ChatBridge` (`:21-23`). The wire vocabulary is **generated from
TypeScript**: `BridgeKeys.generated.swift:1-3` is derived from `$R/state/lib/bridge-keys.ts:64-107`
by `state/bin/generate-bridge-keys-swift.mts`, verified by `npm run bridge:check`. 42 keys.
`handleJSMessage(type:payload:)` (`:175-270`) — 26 handled cases. The ones that matter here:
| line | case | effect |
|---|---|---|
| `:192` | `dispatch.action` | POST `http://127.0.0.1:3147/dispatch-action`, echoes `dispatch.result` (`:396-457`) |
| `:209` | `agent.select` | **persists `UserDefaults["snappy:os:selectedAgentId"]` and nothing else** (`:336-343`); replayed to JS on the `ready` handshake (`:272-293`) |
| `:232/:234/:236` | `computer-use.aside/restore/cursor` | window docking + `AgentCursorOverlay` (`ChatBridgeComputerUse.swift:5-74`) |
| `:238` | `cu.session` | `ScreenGlowOverlay.shared.show()/hide()` (`:377-384`) |
| `:215` | `operator-session.remint` | `OperatorSessionInjector.mintAndInject` (`:364-370`) |
| `:259-267` | `default` | non-fatal; DEBUG log only. `context.toggle` and `folder.pick` are declared and unhandled |
Outbound is one function: `sendToJS(type:payload:)` (`:526-548`) calling
`window.bridge.recv({type,payload})` via `callAsyncJavaScript`, with a bounded 32-envelope queue
until the `ready` handshake (`:44-48`, `:530-535`). The one *typed* native→web command union is
`VoiceSessionCommand` (`Voice/VoiceOrbBus.swift:322-347`), `Encodable` → `JSONSerialization`
(`:426-432`) — the pattern an `agent.*` union should copy.
WKWebView: loopback HTTP, **no custom scheme handler** — `http://127.0.0.1:3147/app/index.html`
(`ChatWebView.swift:357,415`) or `SNAPPY_WEB_URL` for vite. Two message-handler names, `"bridge"`
and `"authSession"` (`:165-166`). Injected globals: `window.snappyServer` (`:89-138`),
`window.__SNAPPY_OPERATOR_SESSION` (post-`didFinish`), `window.__snappyRuntimeErrors`.
### A.3 `NativeComputerUse*` — the socket pattern an `AgentHost` should copy verbatim
**Swift is the server; the Node head-screen is the client.** `NativeComputerUseBridge.swift:7-10`:
"only exact-PID observe and actuation cross this socket so macOS attributes TCC to Snappy OS."
- **AF_UNIX SOCK_STREAM, newline-delimited JSON, one request per connection.** No port.
Path `/tmp/snappy-cu-<uuid8>/bridge.sock`, dir 0700, socket 0600 (`:15-18`, `:31-41`, `:78`),
`listen(fd, 8)` (`:72`).
- **Lifecycle**: `start()` at `AppDelegate.swift:72` (before head-screen spawns), `stop()` at `:451`.
Detached accept `Task` (`:80-82`, `:167-185`), one detached task per client.
- **Deadlines**: `requestDeadline = 2` s, `maximumRequestBytes = 65_536`
(`NativeComputerUseSocketIO.swift:5-6`); poll-based read/write with `FD_CLOEXEC`, `O_NONBLOCK`,
`SO_NOSIGPIPE` (`:8-28`, `:30-88`).
- **Consent gating — three guards before any command** (`:217-233`): per-launch
`capabilityToken = UUID().uuidString` (`:11`) must match; `issuedAtMs` within ±5 s; request id not
in the replay set (capped 1024).
- **Registration for the launchd case** (a sibling process cannot inherit the env vars):
`registerWithHeadScreen` POSTs `<serverURL>/computer-use/native-bridge/register` with
`Authorization: Bearer <masterKey>` and `{socketPath, capability, instanceId, startedAtMs, pid,
bundleId}` (`:104-165`), renewing every 30 s. Server side
`$R/state/bin/head-screen/routes/computer-use-permission-actions.ts:38-60`.
- Node client: `$R/state/lib/native-computer-use-bridge.ts` — `createConnection({path})`, one
JSON line out, one in, id-matched, 15 s default timeout.
**An `AgentHost` gets the whole IPC problem solved for free by copying this file.**
### A.4 Other native owners
- `Native/MenuBarOrb.swift` (597): one `NSStatusItem`, `install()` idempotent (`:191`); sole writer of
`UserDefaults["voicePresenceMode"]` / `"voicePresenceQuietUntil"` (`:361`, `:497`, `:518-528`).
The natural launch point for "ask the hosted agent something" (plan P1).
- `Native/PushToTalkHotKey.swift` (160): Carbon `RegisterEventHotKey` on ⌃⌥Space, both edges
(`:42-57`, `:121-128`). `Menus/WidgetHotKey.swift:160-200` already owns ⌘⇧W, ⌘⌥O, ⌥⇧Space. A
Carbon chord registers once per app — a collision fails silently, it does not error.
- `OperatorSessionInjector.swift` (98): the **only** road the raw master key travels — POST
`<serverURL>/operator/session` with the bearer and
`{"principal":{"kind":"human_ui","surface":"native-shell-cockpit"}}` (`:20`, `:84-97`); injects only
the returned `session_token` as `window.__SNAPPY_OPERATOR_SESSION` (`:63-70`).
- `AuthSessionKeeper.swift` (175): owns the `"authSession"` WK handler (`:48`) and a 0600
`Application Support/Snappy OS/state/auth-session.json` (`:54-58`); monkey-patches
`localStorage.setItem/removeItem` to capture Convex Auth keys (`:122-162`). Explicitly **not** the
Keychain — rationale `:1-45`, cost recorded at `docs/TRAPS.md:111-126`.
- `AppDelegate.swift` (647): owns `let headScreen = HeadScreenSubprocess()` (`:49`) and the whole boot
order (`:66-200`).
### A.5 `Package.swift` — adding `swift-acp` is a two-line change
`// swift-tools-version:6.0` (`:1`), `platforms: [.macOS(.v14)]` (`:39`, with a "DO NOT BUMP"
rationale at `:3-31`; note `Resources/Info.plist` says `LSMinimumSystemVersion = 13.0` — they
disagree). **Exactly one dependency**: Sparkle `from: "2.6.0"` (`:53-57`), pinned at `2.9.2` in
`apps/snappy-os/Package.resolved`. Two executable targets + one test target (`:58-84`). No vendoring,
no mirror, no `--offline` — `scripts/build-app.sh:423,425` runs plain `swift build -c release`.
Caveat: `.unsafeFlags` at `:80-83` (embedding Info.plist into `__TEXT,__info_plist`) already means
this package can never be consumed as a dependency; and `scripts/native-build-cache.sh` needs a cold
pass after a dependency change.
### A.6 Sandbox / entitlements — the Developer ID build can spawn anything
`Sources/SnappyOS/SnappyEntitlements.entitlements` (used by `scripts/build-app.sh:629`,
`scripts/sign-devid.sh:25,76-79` with `--options runtime`) has **no `com.apple.security.app-sandbox`
key at all** — arbitrary `fork`/`exec` is legal, which is exactly why `node`, `npx`, `/bin/bash`,
`lsof`, `pgrep`, `launchctl` already work. It carries `cs.allow-jit`,
`cs.allow-unsigned-executable-memory`, `cs.disable-library-validation`, `device.audio-input`,
`automation.apple-events` (`:8-26`).
The MAS profile (`SnappyMASEntitlements.entitlements`, `scripts/mas-build.sh:36,183-186`) **is**
sandboxed and hostile to spawning a user-installed `claude`. The template for an embedded child is
`HelperEntitlements.entitlements` — exactly two keys (`app-sandbox`, `inherit`), and the file's own
header warns a third key kills the child at launch (`:5-7`).
### A.7 Where `AgentHost` goes
**There is not one `actor` declaration in the whole Swift codebase.** Two patterns only:
`@MainActor` singletons (51 of 82 files), and `@unchecked Sendable` + `NSLock` for the two
process/socket supervisors — `HeadScreenSubprocess` (`:4`, `:145`) and `NativeComputerUseBridge`
(`:10`). `Package.swift:32-36` states the second is deliberate.
Put it at **`Sources/SnappyOS/Launch/AgentHost.swift`**, as
`final class AgentHost: @unchecked Sendable` with an `NSLock` — *not* `actor`, because the existing
code hands `@Sendable` closures to `Process.terminationHandler`, `Pipe.readabilityHandler` and
`DispatchSource` handlers, none of which can `await` into an actor. There is a hard ~900-line file
cap enforced by `scripts/lint-god-objects.ts`; both existing supervisors were split at it
(`HeadScreenSubprocess.swift:779` names its own split).
Reuse rather than rewrite: `spawnAot` (`:533-596`), `recordCrashAndMaybeRespawn` (`:762-778`),
`stop`/`collectDescendants` (`:605-667`), `installSignalHandlers` (`AppDelegate.swift:557-590`),
`decideHealthProbeAction` (`+HealthProbe.swift:268-283`), the AF_UNIX server + capability token
(`NativeComputerUseBridge.swift:24-233`), `BridgeKeys.generated.swift` generation, `VoiceSessionCommand`
(`VoiceOrbBus.swift:322-432`), `ApprovalDispatchGuard` (`Voice/ApprovalDispatchGuard.swift:10-51`),
`NativeWorkStream` SSE-with-backoff (`Voice/NativeWorkStream.swift:26-53`).
---
## B. THE NODE RUNTIME DOORS
Entry `$R/state/bin/head-screen/server.ts` (100 lines, deliberately tiny — `:4-29` explains the
bind-before-preload boot). Binds `HOST = process.env.HOST ?? "127.0.0.1"` (`:47`), port from
`resolveHeadScreenPort()` (`:44`). Ports authority: `$R/state/lib/ports.ts` — 3147 daemon (`:22`),
3148 bundle smoke (`:38`), 3149 install candidate (`:50`), 3150 out-of-band liveness (`:71`),
3151 browser bridge (`:56`).
### B.1 Runs — there are **three** run ledgers, not one
| door | file:line | what a "run" is |
|---|---|---|
| `GET /runs`, `GET /runs/:id`, `POST /runs/:id/{cancel,retry,resume,steps/*}` | `routes/scripts-automations.ts:174,181,183,188-298` | **Script** runs: each pins a frozen `ScriptVersion`; the answer unions the raw `ScriptRun` store with the agent-fire tails (`:226-240`) and always states its own window (`:247-278`) |
| `POST/GET /work/runs`, `GET /work/runs/:id`, `GET /work/runs/:id/events` (SSE), `POST /work/runs/:id/control`, `POST /work/runs/:id/input` | `routes/work-runs.ts:8-13`, `:267-359` | **Work-harness** runs, with A2A `task_state` added to every projection (`:47-56`) |
| `GET /agent/invocations/:runId/output`, `.../output/events`, `.../ag-ui` | `routes/delegation-tail.ts:9-10, 27-30, 73, 114-160` | **Delegation** runs — the ones the executor spawns. The canonical row lives in Convex, not here |
`GET /events` (`routes/events-sse-route.ts:34-45`) is a different thing: a native-shell push stream
of `evals.ndjson` / `dispatches.ndjson` / `journal.ndjson` tails, not per-run.
**The delegation tail is the seam an ACP host must not duplicate.** `delegation-tail.ts:27-44`:
`.../output/events` carries two SSE legs — `event: output {line}` (prose, no replay) and the default
`message` event carrying AG-UI frames, **replayed from the beginning on every connection** because
the frames are deltas. The AG-UI leg reads `<workspace>/ag-ui.jsonl`
(`agent-run-ag-ui.ts:544-548`).
### B.2 Approvals — staged-by-default, minted, single-use, human-gated
- `GET /provider-approvals` (+ `?approval_id=`), `POST /provider-approvals/:id/{revise,approve,deny,reconcile}`
— `routes/provider-approvals.ts:2-7`.
- `GET /approvals/lifecycle?status=&limit=` — the ONE unified projection over provider + plan +
draft + Convex lanes (`routes/drafts-approvals.ts:379-441`); unknown status is a typed 400 (`:401-404`).
- `GET /needs-you` and `/needs-you/census` (`routes/drafts-approvals.ts:495-529`) → one computation in
`$R/state/lib/needs-you-read.ts:1-28` over ~13 founder-actionable lanes (`:30-55`), plus a triage
ordering (`:508-522`). `count: null` means a lane went quiet — never a zero nobody measured.
- `GET /what-needs-me` is **operator-private** and 403s without `operatorProven` (`routes/needs-me.ts:32-45`).
- **The mint/execute gate** is `evaluateWriteApply` in `routes/write-apply-gate.ts:44-55`, returning
`{staged:true} | {execute:true, approvalId} | {error,status}`. Header `:7-24`: an action grant
authorizes STAGING, never execution; the execute lane needs either a `decisionAuthority` derived
from an **operator session** (the master-key bearer explicitly does not qualify) or a single-use
`dispatchNonce` minted by the human approve road.
- **The per-caller policy vocabulary already exists and is four-valued** —
`$R/state/lib/delegated-authority.ts:41-69` `actionExecutionPolicy(callerId, actionHandle)` →
`"stage_only" | "approve_each" | "delegated" | "legacy_unset" | "no_grant"`, with `delegated`
narrowing to `approve_each` on an agent version bump (`:54-67`).
- `POST /operator/session` mints the short-lived session from the master-key bearer, and
`requireMasterKeyBearer` has **no loopback exemption** (`routes/operator-session-route.ts:1-30`).
### B.3 Hub / connectors / grants
`POST /hub/connector-action` (staged by default), `POST /hub/create-automation`
(`routes/connector-ask.ts:100-108`), `POST /hub/tool-trace` (ungated but observed,
`routes/connector-ask-hub-doors.ts:26-60`), `POST /hub/query`, `POST /connector/ask`
(`connector-ask.ts:110-140`), `GET /hub/callers`, `POST /hub/grants/{create,revoke,restore}`
(`connector-ask.ts:96-99`; refusal texts naming the door at e.g.
`routes/artifacts-import.ts:263`).
The delegated agent's bus is derived, never typed: `agentBusBaseUrl` =
`http://127.0.0.1:${resolveHeadScreenPort(env)}` (`$R/state/lib/agent-bus-address.ts:81-85`),
handed to a run as `{SNAPPY_RENDER_BASE_URL, SNAPPY_XANO_SPINE:"off"}` (`:111-118`).
### B.4 Health / deploy truth
`GET /deploy-truth` (`routes/health-and-observability.ts:11, 176-230`) compares the running process
against the last deploy stamp, names `runtime_owner`, `state_paths`, `frontend.url`, and bypasses the
30 s UI cache. `GET /healthz`, `/backend-health`, `/system-health` (`routes/system-health.ts`).
### B.5 The chat turn today
`POST /dispatch/chat` (`routes-manifest.ts:808`) is handled by
`$R/state/bin/head-screen/dispatch-chat-handler.ts` (900 lines). It opens an SSE stream
(`$R/state/lib/dispatch-turn-sse-open.ts:9-23`, `content-type: text/event-stream`,
`x-accel-buffering: no`), emits `RUN_STARTED`, then the full AG-UI vocabulary through one
`writeAgUI` journal boundary; `openRunRecord` (`$R/state/lib/dispatch-turn-open-run.ts:34-55`) seeds
a durable in-flight thread row before the model is asked anything.
**Backend selection exists but is a two-value axis, not an agent picker.**
`$R/state/lib/dispatch-config.ts:120-122` — `KNOWN_BACKENDS = ["snappy", "claude-code"]`; the
internal route resolver returns `"ai-sdk" | "claude-code"` (`:162-167`). The web carries the choice
in two window globals, `__snappyChatBackend` / `__snappyChatModel`
(`apps/snappy-os/web/src/lib/requested-dispatch.ts:11-12`, read at `:40-50`, written at `:52-78`), so
every programmatic `/dispatch/chat` caller ships the exact selection the user sees without a
round-trip.
**`agent.select` is not that.** It is a *rail selection* persisted in `UserDefaults`
(`ChatBridge.swift:336-343`), sent from `App.tsx:526`. Nothing dispatches on it.
**The "talking half" is a separate, newer door**: `POST /agent/:slug/say {text}` →
`{ok, reply, spawned_run_id}` (`routes/agent-say.ts:1-26`). Its contract is the write ORDER: the
person's row first, one harness turn, the agent's reply row, and only then a run — "the person reads
the Agent's own words before any run exists, which is the entire ruling" (`:20-25`).
`routes/agent-messages.ts:1-6` is the group room (SPEECH, never authority).
---
## C. THE MCP SURFACE
### C.1 The local governed server is **stdio-only** and is already the agents' tool road
Entry `$R/state/bin/mcp/render-server.ts` (104 files in that directory); baked twin
`render-server.mjs` (0755, `#!/usr/bin/env npx tsx`). Canonical relative path is asserted at
`$R/state/lib/agent-runtime-provability.ts:93`.
`render-server.ts:740-760` — `new StdioServerTransport()` via `serveStdio(...)`, imported from
`@modelcontextprotocol/server/stdio` (`:68`). It **dials** the daemon as a client:
`$R/state/bin/mcp/render-server-config.ts:19` — `BASE_URL = SNAPPY_RENDER_BASE_URL ||
http://127.0.0.1:${HEAD_SCREEN_PORT}`, `SERVER_NAME = "snappy-render"`.
There is a *second, different* HTTP MCP server: `$R/state/lib/voice/local-mcp-server.ts:30,103`
(`StreamableHTTPServerTransport`, name `snappy-local`) mounted at
`POST/GET/DELETE http://127.0.0.1:3147/mcp/local` (`$R/state/bin/head-screen/route-plane-rows.ts:421`,
`plane: "operator"`), stateless per request. Eight tools only: `run_on_this_mac`, `see_this_screen`,
`start_work`/`inspect_work`/`control_work`, `cli_search`/`cli_info`/`cli_run` (`:19-26`).
**It is not the governed 41-tool surface.**
### C.2 Auth and identity
`render-server-operator-credential.ts:70-74` — `operatorBearerHeader()` reads `SNAPPY_MASTER_KEY` or
the anchored `.env.cache`. `:270-283` `operatorRequestHeaders(callerId)` is the single outbound
boundary: `x-snappy-caller-id` + bearer + (optional) operator session.
Env: `SNAPPY_MASTER_KEY`, `SNAPPY_CALLER_ID` (else `mcp-local`), `SNAPPY_RUN_ID`,
`SNAPPY_RENDER_BASE_URL`, `SNAPPY_MCP_FULL_SURFACE`, `SNAPPY_MCP_OPERATOR_SESSION` (dev only),
`SNAPPY_MCP_DURABLE_WAIT_MS`, `SNAPPY_MCP_TASK_COMPAT`. Header constants at
`$R/state/lib/caller-identity.ts:9-16` (`CALLER_ID_HEADER = "x-snappy-caller-id"`).
**Identity is process-scoped**: `render-server-config.ts:25` resolves `CALLER_ID` **once at module
load**. Enforcement is at the daemon (`$R/state/lib/caller-grants.ts:449-470` `evaluateCallerRead`),
not in the MCP process; the one in-process gate is browser hands, which *removes* tools rather than
refusing calls and re-reads the grant per call
(`$R/state/bin/mcp/render-server-browser-hands.ts:133-158`). Every call is traced to
`${BASE_URL}/hub/tool-trace` with caller and run id — "that POST is GOVERNANCE, not telemetry"
(`render-server-config.ts:102-108`, `:129-176`).
### C.3 The tool list (41 curated; ~427 more under `SNAPPY_MCP_FULL_SURFACE=1`)
Assembled at `render-server-curated-manifest.ts:90-108`, registered in a loop at
`render-server.ts:616-627`.
- **Discovery (4)** — `render-server-discovery-tools.ts`: `snappy_search` (:162, find a capability
from plain English), `snappy_info` (:184, full docs for one), `snappy_list` (:199, browse by area),
`snappy_execute` (:216, universal invoker, `risk: external_write`).
- **Core (13)** — `render-server-tool-defs.ts`: `render_ui` (:139, live-data-bound OpenUI from NL),
`snappy_ask` (:636, the primary read road), `snappy_list_connectors` (:205), `snappy_query` (:240,
exact-handle raw rows over the connector mirror), `snappy_config` (:296, gated dials stage an
approval), `snappy_list_skills` (:407), `snappy_add_skill` (:720), `snappy_improve_skill` (:739),
`snappy_list_automations` (:674), `snappy_create_automation` (:695), `post-agent-invoke` (:60,
delegate to a roster report over A2A), `snappy_managed_intent`
(`render-managed-intent-tool.ts:22`), `snappy_render_proof` (`render-server-render-proof-manifest.ts:7`).
- **Hub (13)** — `render-server-hub-defs.ts:50+`: `snappy_import_file`, `snappy_mint_connector`
(destructive), `snappy_diagnose_connector`, `snappy_connector_capabilities`,
`snappy_promote_connector`, `snappy_grants` (`render-server-grants-tool.ts:27`), `snappy_approvals`,
`snappy_connector_action`, `snappy_activity`, `snappy_artifacts`, `snappy_file`, `snappy_databases`,
`snappy_connector_health`. **Scripts/runs (8)** — `render-server-script-tools.ts:412+`:
list/get/create/update/validate/run script, cancel run, retry run. **Appended (3)** —
`snappy_builder_workflow`, `snappy_credentials` (operator-only, secrets never returned),
`snappy_libretto` (`render-server-hub-defs.ts:47`).
- **Conditional browser hands (0/4/5)** — `browser_open`, `browser_snapshot`, `browser_status`,
`browser_close`, `browser_exec`; `browser_connect` is `NEVER_EXPOSED_TOOLS`
(`$R/state/lib/browser-hands/browser-hands.ts:41-46`).
### C.4 The deployed worker is a **different, sibling repo** and a different surface
`/Users/robertboulos/Projects/mcp-servers/snappy-os-mcp/` (this repo contains zero `wrangler.*`
files). `wrangler.jsonc:3-11` — name `snappy-os-mcp`, main `src/server.ts`, `nodejs_compat`,
KV `OAUTH_KV` + `TASKS_KV`, no `routes` key ⇒ `https://snappy-os-mcp.robertjboulos.workers.dev`.
Mounts (`src/routes.ts:60-110`): `/mcp`, `/admin/mcp`, `/v1/control/mcp`, `/v1/access/<id>/mcp`,
`/turn`, RFC 9728 metadata, A2A card. Deploy is the gated chain in `package.json:8`
(`gate:one-road`, `gate:tenancy`, … then `wrangler deploy`).
**It is standalone, not a proxy** — it reads Convex (`oceanic-frog-640`) and Xano. Its own registry
refuses stdio records (`src/generated/registry.shard-15.ts:216`), and its prompts tell the model the
grants door is loopback-only and unreachable from the worker (`src/prompts/craft.ts:119`).
### C.5 Can an ACP `session/new` be handed this MCP? — **yes, if the agent runs on this Mac**
The stdio shape is *already* the production one, and it maps 1:1 onto ACP's
`{name, command, args, env}`. `$R/state/lib/agent-launch-contract.ts:86-206` `snappyMcpSpec`:
```
command: "npx", args: ["tsx", <resolved render-server.ts>],
env: { SNAPPY_RENDER_BASE_URL, SNAPPY_XANO_SPINE:"off",
SNAPPY_CALLER_ID: <agent:slug@vN>, SNAPPY_RUN_ID: <run>,
SNAPPY_MCP_TASK_COMPAT:"wait-terminal", SNAPPY_MCP_FULL_SURFACE:"1",
SNAPPY_STATE_ROOT?, SNAPPY_OS_ROOT?, PATH, HOME }
```
Rendered for claude-code as `.mcp.json` by `composeMcpJson` (`:237-241`) and for codex as
`[mcp_servers.snappy-os]` in a per-run `config.toml` (`:286-321`). A product-facing variant that
prefers the bundled `node-universal` + baked `render-server.mjs` already exists at
`$R/state/lib/mcp-launcher-placement.ts:204-258`, served by
`$R/state/bin/head-screen/routes/mcp-connection-info.ts:135-145`.
**What is missing, precisely:**
1. **No ACP layer at all** anywhere in the tree.
2. **The env dict must be passed explicitly.** `agent-launch-contract.ts:347-360` records the
measurement: `StdioClientTransport.start()` spawns with
`{...getDefaultEnvironment(), ...serverParams.env}`, and `DEFAULT_INHERITED_ENV_VARS` on POSIX is
only `[HOME, LOGNAME, PATH, SHELL, TERM, USER]`. A credential dropped from the `env` block never
reaches the tool road.
3. **Caller identity is resolved once per process** (`render-server-config.ts:25`). One MCP process
cannot serve two ACP sessions. Cheapest correct answer: **one spawned `render-server` per ACP
session**, exactly the existing model, with `SNAPPY_CALLER_ID` = the agent identity and
`SNAPPY_RUN_ID` = the hosted run id.
4. **No local bearer-minting road for an off-box caller.** `/operator/session` requires the master
key already in hand, and `SNAPPY_MCP_OPERATOR_SESSION` is now build-gated OFF for agents
(`agent-launch-contract.ts:127-152`, gate `scripts/gates/agent-workspace-authority.mjs`).
5. **Loopback is structural** (`$R/state/lib/caller-identity.ts:29-48`). A remote agent cannot reach
`127.0.0.1:3147` at all.
---
## D. THE WEB CHAT
Plain React 18 + Vite 8 + `HashRouter`, no external state library — ~20 `createContext` providers
plus Convex subscriptions (`apps/snappy-os/web/package.json:65-71`, `src/app-shell.tsx:12`).
`vite.config.ts:29` `base: "./"` with a header (`:7-21`) recording that WKWebView on `file://`
blocks runtime dynamic `import()` of separate chunks, so `modulePreload.resolveDependencies`
(`:99-115`) excludes `genui-*`, `vendor-three-*`, `vendor-openui-lang-*`. Dev proxy forwards
everything vite does not own to `http://127.0.0.1:3147` (`:80-86`).
The bundle is staged into the Swift resources by `apps/snappy-os/scripts/build-web.sh:10,127-129`
(`DEST="$ROOT/Sources/SnappyOS/Resources/web"`, `rm -rf` then `cp -R dist/.`). **Swift does not load
`file://`** — `ChatWebView.swift:357,415` loads `http://127.0.0.1:3147/app/index.html`, and `:351-354`
says why: the localhost origin is what makes WebKit permit microphone capture for Realtime voice.
`SNAPPY_WEB_URL` overrides for the vite loop (`:355,366-370`); route restoration across installs at
`:320-346`.
### D.1 The typed bridge client — one vocabulary, generated into Swift
`src/bridge.ts:28-39` declares `window.bridge {send, recv, on, isHosted}` and
`window.webkit.messageHandlers.bridge.postMessage`; `:21` imports `BRIDGE_KEYS` from
`state/lib/bridge-keys.ts` — the same file the Swift enum is generated from (`bridge-keys.ts:11-18`,
`swiftIdentifierFor` at `:113-118`). `:72` `isHosted`, `:118` sends `READY` on boot, `:125-135`
synthesizes `BUNDLE_STATE` from `/deploy-truth` in a plain browser.
Call sites that matter: `App.tsx:526` (`AGENT_SELECT`), `dispatch/lang-renderer.tsx:343-347`
(`DISPATCH_ACTION {tool_call_id, action, payload}`), `components/confirm-dialog.tsx:103-107`,
`computer-use-detail-io.ts:87-97`, `lib/install-operator-fetch.ts:93-94` and
`lib/operator-authority.ts:58-61` (raw `webkit` access to remint a stale operator session). Inbound
routing `lib/use-bridge-subscriptions.ts:58`; Swift receives at `ChatWebView.swift:451-459`.
`__snappy*` globals actually in the bundle: `__snappySubmitIntent` (36), `__snappyNav` (23),
`__snappyLastCard` (22), `__snappyPendingAddConnection` (10), `__snappyDebug` (8), one each of
`__snappyChatBackend` / `__snappyChatModel`. **There is no `computeruse` global** — Computer Use rides
the `computer-use.*` bridge keys plus `${SERVER_URL}/computer-use/*`.
### D.2 There are TWO streaming roads on the glass, not one
**(a) The chat surface uses the Vercel AI SDK UI stream, not AG-UI SSE.**
`src/surfaces/chat/use-chat-pipe.ts:34-38` — `new DefaultChatTransport({ api:
`${SERVER_URL}/threads/${threadId}/ui-stream` })`, consumed by `useChat<SdkChatMessage>` (`:59`).
`:72` `running = status === "streaming" || "submitted"`; `:85-94` `ask()` **refuses mid-run** and
returns `"kept"`, so the surface queues the sentence. Main component
`src/surfaces/chat/surface.tsx` (880 lines), lazily routed from `src/app-routes.tsx:83`.
Per-turn render order (`surface.tsx:578-800`): human ask (`:598-601`) → `LiveTurnActivity` (`:632`) →
**`<ol class="cs-steps">` of `StepRow` tool rows** (`:633-651`, folded by `collapseRepeats()` `:94-119`)
→ composed GenUI surface (`:661-663`) → prose answer (`:673-675`) → inline artifact cards (`:676-687`)
→ `ConfirmableBinding` (`:688-695`) → asked-choice rows (`:704-727`) → `StagedBlock` (`:730-741`) →
`MintedBlock` (`:745-747`) → **`StagedApprovalBlock`** (`:752`) → artifact badge (`:753-777`) →
**`DraftDiffReceipt`** (`:778-785`).
**(b) The agent room reads the daemon's AG-UI tail door.**
`src/surfaces/agents/live-run.ts` is the ONE reader of a moving run and the first web consumer of the
typed AG-UI contract (`:1-32`).
- `isRunLive(work)` — `invocation_state ∈ {claimed, launched}` or `status === "running"` (`:51-55`).
- Address (`:183`) `${SERVER_URL}/agent/invocations/<runId>`; frame door once `GET .../ag-ui`
(`:189`), prose prefix `GET .../output` (`:204`), live `new EventSource(".../output/events")` with
listeners on `"output"` and `"message"` (`:222-230`) and a fold reset on `"open"` (`:234`) because
the AG-UI leg replays from the beginning. Settled runs read the sidecar exactly once (`:200-203`).
- `foldLiveRunFrame` (`:107-133`) sends AG-UI events to both `foldTypedReplyEvent` and
`foldRunTranscript`; a `{line}` payload starting `{` is JSON-parsed and re-tested (`:119-131`);
everything else is prose capped at 400 lines (`:45`). `liveRunSource()` → `"ag-ui" | "prose" | "none"`
(`:144-147`).
- The transcript vocabulary is **four entry kinds** (`src/lib/ag-ui-transcript.ts:61-89`) folded from
the 20-member event tuple (`src/lib/ag-ui-event-contract.ts:35-61`, guard `:277-285`) by
`foldRunTranscript` (`:218-301`): `said` ← `TEXT_MESSAGE_*`; `thought` ← the five `REASONING_*`;
`tool {name, args, ended, result:{text,failed}}` ← `TOOL_CALL_START/ARGS/END/RESULT`;
`diagnostic {level}` ← `HARNESS_TRACE`/`HOOK_TRACE` (trace), `BACKEND_NOTICE`/`LIVE_COMMENTARY`
(notice), `RUN_ERROR` (error, suppressed when it merely repeats the speech, `:191-198`).
`RUN_STARTED`/`RUN_METADATA` add no entry (`:222-224`); `RUN_FINISHED` sets `terminal` (`:225-226`).
- Rendering `run-stream.tsx`: `Reply` (`:93-113`, clamped past 700 words / 12 lines), `RunStream`
(`:115-186`, collapses all but the last message into `<details data-run-steps>` once settled),
`RunDetails` (`:201-263`, `data-tool` / `data-tool-state={"ended"|"running"|"failed"|"ok"}` /
`data-diagnostic` / `data-reasoning`). `toolName` (`:31-40`) de-MCPs
`mcp__snappy-os__snappy_execute` → "Snappy OS · execute"; `toolWords` (`:42-50`) picks tense.
**`:80-84` deliberately does not draw tool arguments or result bodies** — "one result ran 17,506
characters of raw payload."
- `conversation.tsx` (894) decides which run gets which: `RunEpisode` (`:598-791`) sets
`streaming = work.said === null && isRunLive(work)` (`:609-610`) and lets the stream outrank the
ledger only when frames exist (`:613`); `RunProcess` (`:240-456`) draws live "Using X" (`:263-269`),
delegation children (`:284-330`), and the **"Pick a different brain for the next ask"** remedy
(`:348-356`). `output-face.ts:34-59` marks a face `"generated"` only when
`face.for_version === artifact.current_version`, else `"fallback"` **with a stated reason**.
### D.3 Where an ACP `tool_call` update naturally lands — two existing seams
1. **Chat road.** `src/surfaces/chat/sdk-adapt.ts:97-127` `toolPartView(part)` already accepts both
`tool-<name>` and the SDK's **`dynamic-tool`** shape (`:102-105` — "a tool the client declared no
schema for is still a step that ran"), yielding `{toolCallId, toolName, input, state, outcome,
errorText}`; `:161-176` turns that into a running `Step`, `:135-159` into a ledger entry, `:178-179`
settles on `output-available | output-error`. `Step` is
`src/surfaces/chat/surface-record.ts:52-84` with `state: "waiting"|"running"|"done"|"refused"`.
`StepRow` (`src/surfaces/chat/step-row.tsx:13-136`) renders it, with a refusal block at `:103-131`
(`role="alert"`, remedy doors) and a refusal-storm tally at `:21-44`.
2. **Agent-run road.** `ToolEntry` → `run-stream.tsx:242-253`. ACP's
`status: pending|in_progress|completed|failed` maps 1:1 onto
`TOOL_CALL_START` / `TOOL_CALL_END` / `TOOL_CALL_RESULT{isError}`.
### D.4 Permission and diff surfaces that already exist
- **The closest thing to an ACP permission card today is `src/components/confirm-dialog.tsx:100-107`**
— it sends `BRIDGE_KEYS.DISPATCH_ACTION` with `{tool_call_id, action: "approve"|"reject", channel:
"confirm"}` over the Swift bridge. That is a per-tool-call yes/no decision routed natively, which is
structurally exactly `session/request_permission`. Reuse this shape rather than inventing one.
- `src/surfaces/chat/staged-approval.tsx:16-26` **deliberately draws no approve/deny** — it renders a
`"Review it"` door to Needs You, because "its whole lifecycle … is owned by the runtime's approvals
queue, and Needs You is its one room." Any ACP permission UI must decide which of these two laws it
is under: an in-turn decision (confirm-dialog) or a queued one (Needs You).
- The deciding surface is `src/surfaces/needsyou/surface.tsx:14-42` —
`onDecide(approvalId, verb: "approve"|"decline", reason, digest, daemonLifecycleId?)` behind a
build-skew `decisionLock` (`:23-34`). The portal stack of provider approvals is
`src/components/pending-approvals.tsx:1-16` (reads `/approvals/lifecycle?status=pending`, approve →
`POST /provider-approvals/:id/approve`).
- **Three distinct diff viewers already exist** — I initially under-counted this:
- `src/surfaces/parts/diff.tsx` — the canonical **word-level `was`/`becomes`** diff, `wordDiff()` at
`:35-70` (LCS over whitespace-preserving tokens); its header `:1-22` states it is the ONE drawing
and bans `+`/`−` glyphs. **This is the natural home for ACP's `{oldText, newText}`.**
- `src/genui/diff-view.tsx:15-33` — `DiffView`, an OpenUI Lang `defineComponent` taking a
**unified-diff `patch` string** with `mode: "unified"|"split"`; registered in `genuiLibrary` and
`canvasLibrary`, so a model can already emit it.
- `src/surfaces/chat/draft-diff-receipt.tsx` (111) — the chat turn's diff receipt with checkpoint
revert. Plus the line-set `src/errand-change-diff.tsx:20-35` (`{removed, added, moved}`,
three sets on purpose because added/removed cannot see a reorder, `:10-14`).
- Handoff §3.1 (`docs/HANDOFF-20260902-NIGHT.md:159-160`): "Needs You: 32 items each with a
full-width bright Approve bar; DESIGN.md §5 wants compact buttons. Untouched."
### D.5 Backend / agent selection — three pickers, three lifetimes
1. **Chat model chip** — `src/mode-chips.tsx` portals a `ProviderModelPickerChip` into the OpenUI
composer bar via `MutationObserver` (`:17-19`). The choice lands in the window globals
(`lib/requested-dispatch.ts:11-12`, coercion `:43-45`) **and** is persisted server-side by
`postAxis(axis:"chat"|"subagent", {backend?,model?,thinking?})` → `POST /dispatch-config`
(`mode-chips.tsx:440-517`; a backend flip forces `model:"auto"`, `:498-517`). Shipped every turn at
`src/lib/chat-request.ts:145`.
2. **Per-run brain, agent room** — `room.tsx:384-432`: Computer `<select>`, the
`ProviderModelPickerChip` inside `<span id={BRAIN_PICKER_HOST_ID}>`, and for managers a scope
`<select>` `"this run only" | "this run and its team"` (default `team`, `:223`). Sent as
`{runtime_hint, model_hint?, runtime_scope}` via `destinations/studio-convex-bridge.tsx:44-50` into
`api.agent_deployments.agentInvoke`, stored on the run row, **enforced at launch** (§D.6).
3. **Per-definition brain ("assemble agent")** — `settings-pane.tsx:112-143`
`applyAgentBrain({definitionId, runtime, model, reasoningEffort})` → `api.agent_brain.apply`, which
**mints a new agent version**. The browser owns no model list (`:141-153`).
"Operating face" is imported at `surfaces/agents/surface.tsx:5-10` but its mission band is
deliberately **not rendered** (`:91-103`); `readModelOptions` / `applyAgentBrain` are host seams so an
MCP App can supply native tools instead of Convex (`settings-pane.tsx:104-110`, `room.tsx:499-501`).
### D.6 The Convex twin — leases, launch gates, receipts
`convex/tables/today.ts:397-600` — ONE `runs` table carries script runs and agent invocations alike:
`invocation_state`, `invocation_prompt`, `runtime_hint`, `model_hint`, `model_effort_hint`,
`runtime_scope`, `runtime_inherited_parent_run_id` (`:401-425`), `invocation_claimed_by` (`:434`),
`invocation_reclaimed_from` (`:444`), `invocation_lease_expires_at` (`:445`), `execution_wall_ms`
(`:448`), the six lineage fields `agent_version / effective_contract_digest / authority_revision /
computer / runtime / workspace_ref` (`:449-454`), then `status, summary, did, structured_output`
(`:502`, absence load-bearing), `results_face` / `results_face_refusal` (`:508-509`), `cost_usd`,
`model_resolution`, `provider_selection`, `steps_total`. Index `by_owner_invocation` (`:583-599`) is
what the executor polls.
`convex/agent_invocations.ts`:
- **Lease arithmetic** (`:51-99`): `INVOCATION_LEASE_MS = 15 min`; `LAUNCHED_LEASE_MS = wall + 5 min
grace`, manager variant 20 + 5. `:66-84` records the 45→20 minute correction after
`agent-run-100be712` sat `launched` with no process behind it. `clampedNow()` (`:94-99`) discards a
wire `now` more than 60 s off server time.
- **`claim`** (`:342-456`): `claimed | reclaimed | already_launched | contested | not_invocable |
no_such_run`. A dead lease reclaims **and nulls all six lineage fields** (`:391-395`) so a stale
launch receipt can never be read as this attempt's.
- **`reportLaunch`** (`:491-607`), the strictest door in the system, refuses unless: state `claimed`
and holder matches (`:504-509`); digest matches `/^[0-9a-f]{64}$/` (`:510-512`); provider control
presents `provider_selection` (`:513-521`); **`runtime === run.runtime_hint`, else "a forbidden
provider fallback"** (`:522-527`); `model_resolution.runtime === runtime` (`:528-533`); model hints
proven with their `source` (`:534-559`); `provider_selection` matches `{runtime, scope, source,
inherited_parent_run_id}` (`:560-579`). `execution_wall_ms` is a **closed union of the two wall
literals** (`:473`).
- **`settle`** (`:704-736`): holder-only, and **`:724-732` a `claimed`-but-never-`launched` run may
not settle `finished`**. `applySettlement` (`:743-784`) writes
`produced: status === "finished" && outputs.length > 0`, then `attachOutputs()`.
`convex/run_receipt.ts:123-173` — a receipt is: `envelope` (`run-receipt-v1`, `counted:1`), `run`
(`:33-54`), `definition` joined from immutable `agent_versions` first (`:190-218`),
`execution_contract` (all ten lineage fields, `:69-80`), `skills: slug@version`, `decisions`,
`effects`, `outputs {artifact_id,slug,title,kind,file_url,faced,href}`, `orchestration`, bounded
`evaluations`/`activity` that answer `null` rather than guess, **`unjoined: string[]` naming every
hole**, `delegation {parent_run_id,is_child,children[],counted,complete}`, and — only while the run is
live — `control {cancel:{tool_id:"post-work-control", arguments:{verb:"cancel", target_kind:"run",
target_id, reason}}}` (`:160-171`, `:403-413`). **That `control.cancel` is the shape a Stop button
already speaks.** The list row is `convex/reads_runs_stream.ts:44-105`
(`population_contract:"runs-execution-instances-v1"`, `RUN_WINDOW = 1000`).
---
## E. SKILLS / STATE / CONSTITUTION — what constrains the design
`$R/state/CONSTITUTION.md` — five invariants; only #3 is currently backed by a check.
- **#3, the honesty invariant, is PROVEN on both write and read paths** (`:39-74`): a telemetry row
must carry `actor_session_id` and `auditor_session_id` and they must differ, enforced
synchronously in `$R/state/lib/log.ts` `assertEvalProvenance()`. **"The thing that generates output
cannot be the thing that grades it."** An auto-approving ACP host that also writes its own
"approved" receipt is on the wrong side of this line unless the approval row names a distinct
auditor. Note the precedent: `agent-invocation-executor.ts:191-203` explicitly refuses to let the
executor grade its own run.
- Invariants 1, 2, 4 and half of 5 are `UNKNOWN` — their lints were deleted (`:21-27`, `:35-38`,
`:83-88`, `:114-127`). `:207-223` states plainly that a "Proven by" line naming a deleted file is
worse than no claim.
- Retired, do not reintroduce (`:174-205`): per-skill `.openui` files, Class A cron, the frictions
ledger.
`$R/program.md` — the product contract:
- **#7 (`:23-24`): "Scope-only by default. External sends, posts, publishes, deletes, and remote
mutations require explicit `apply:true`."** This is the one rule Robert's auto-approve decision
collides with. It does *not* forbid auto-approving a **local file write** by a hosted agent; it
does forbid auto-approving an outbound connector effect. The existing lane already enforces this
server-side (`write-apply-gate.ts:7-24`), so a hosted agent going through the snappy MCP still
stages — auto-approve at the ACP layer cannot bypass it. **Say this out loud in the plan.**
- **#9 (`:27-28`)**: actor ≠ auditor, restating CONSTITUTION #3.
- **#8 (`:25-26`)**: "The app must be dogfooded through the installed Snappy OS app before a fix is
called live." An ACP feature proven only by `swift test` is not landed by this repo's own rule.
- **#1 (`:8`)**: "One path: every request enters the same Snappy AI SDK harness." A hosted ACP turn
that renders into the chat is a *second* path unless it lands on the same AG-UI vocabulary and the
same run ledger. That is the single strongest architectural constraint in this report.
`state/skills/` holds ~200 skill folders; `state/index.md` and `program.md:63-69` fix the repo shape.
Delegation workspaces are governed by `$R/state/lib/agent-workspace.ts:1-27` — every dispatched
execution starts inside an effective, DERIVED `AGENTS.md` whose sha256 is the execution-contract
digest, and **a launch that cannot present it is refused at the Convex door** (`:24-27`).
---
## F. THE SEAMS TABLE
| Seam | Existing stream-json road (file:line) | What ACP adds | What must change |
|---|---|---|---|
| **Run creation for a hosted turn** | A run is minted in Convex, polled by `runAgentInvocationPass` from `GET /bridge/agent-invocations` (`agent-invocation-executor.ts:573`), claimed (`:663`), governed workspace materialized (`:789`), launch receipt reported with agent version + AGENTS.md sha + authority revision (`:821-835`), then `launchAndSettle` (`:844-854`). `MAX_CONCURRENT = 4` (`:111`) | Nothing — ACP has no run concept. `session/new` returns only a `sessionId` | An `AgentHost` turn must mint a run row **before the first prompt** so the receipt lane, cost, steps and Activity all join. Cheapest honest road: `POST /agent/:slug/say` (`routes/agent-say.ts:1-26`) already answers `{reply, spawned_run_id}` — reuse it, or add a sibling that opens a run and returns its id to Swift. **`reportLaunch` will refuse a hosted launch that does not present a 64-hex contract digest and a `runtime` equal to the run's `runtime_hint`** (`convex/agent_invocations.ts:510-527`), and `execution_wall_ms` is a closed union of two literals (`:473`) — a hosted turn either reuses a wall or the union grows |
| **Event streaming to the chat** | **Two roads.** Agent runs: stdout → `createAgentRunAgUiWriter` (`agent-run-ag-ui.ts:567-601`) → `<workspace>/ag-ui.jsonl` (`:544-548`) → `GET /agent/invocations/:id/output/events` SSE default `message` leg (`delegation-tail.ts:130-160`) → `foldLiveRunFrame` (`live-run.ts:107-133`) → `RunTranscript` → `run-stream.tsx`. Chat: `DefaultChatTransport` over `${SERVER_URL}/threads/:id/ui-stream` (`use-chat-pipe.ts:34-38`) → `sdk-adapt.toolPartView` (`:97-127`) → `StepRow` | Push, in-process, with `messageId` boundaries and per-`toolCallId` status (`pending→in_progress→completed/failed`) that stream-json does not carry | **Do not invent `agent.text` / `agent.tool` bridge keys.** Pick one of the two existing roads and speak its vocabulary: the AG-UI sidecar (write the JSONL from Swift, or POST frames to a small daemon door) for a run-shaped turn, or the SDK `dynamic-tool` part shape (`sdk-adapt.ts:102-105`) for a chat-shaped one. A native `AgentEventProjector` emitting `ChatBridge.sendToJS("agent.update")` (plan §1) would be a **third** protocol reader, which `live-run.ts:26-31` and `program.md:8` both exist to prevent |
| **Permission → approval route** | **There is none for agent runs.** `claude -p --dangerously-skip-permissions` (`agent-launch-contract.ts:813`); codex `approval_policy = "never"` + `default_tools_approval_mode = "approve"` + `trust_level = "trusted"` (`:300`, `:311`, `:316-317`). In **chat**, the per-tool-call affordance does exist: `components/confirm-dialog.tsx:100-107` sends `dispatch.action {tool_call_id, action:"approve"\|"reject", channel:"confirm"}` over the bridge | `session/request_permission {toolCall, options[{optionId, name, kind}]}` → `{outcome:{outcome:"selected"\|"cancelled", optionId}}`. Option ids are **agent-defined strings**; codex-acp fails closed on unadvertised ids | Robert chose auto-approve. Still: (a) copy `confirm-dialog`'s `{tool_call_id, action}` shape rather than inventing one — it is already a per-tool-call native decision; (b) every auto-approval must land as a visible event — the honest home is a `DiagnosticEntry` (`ag-ui-transcript.ts:84-89`) via `BACKEND_NOTICE`, plus one Activity row like the settle already writes (`agent-invocation-executor.ts:452-483`); (c) the four ACP option kinds map **exactly** onto `actionExecutionPolicy`'s `stage_only / approve_each / delegated / no_grant` (`delegated-authority.ts:41-69`), so flipping to approve-each later is a lookup, not a redesign; (d) decide explicitly whether a permission is an **in-turn** decision (confirm-dialog) or a **queued** one — `staged-approval.tsx:16-26` states the standing law that "Needs You is its one room"; (e) program.md #7 (`program.md:23-24`) still binds — a connector write through the snappy MCP stages regardless of the ACP answer (`write-apply-gate.ts:7-24`) |
| **MCP injection of connectors** | Written to disk per run: `.mcp.json` + `--mcp-config … --strict-mcp-config` for claude (`agent-launch-contract.ts:237-241`, `:832`), `[mcp_servers.snappy-os]` in a per-run `CODEX_HOME/config.toml` for codex (`:286-321`, `:710`) | `session/new {cwd, mcpServers:[{name, command, args, env:[{name,value}]}]}` — passed in the request, no file, `env` is an **array** not an object | Reuse `snappyMcpSpec` (`:86-206`) unchanged and marshal it into ACP's array shape. Per-session `SNAPPY_CALLER_ID` + `SNAPPY_RUN_ID` because MCP identity is process-scoped (`render-server-config.ts:25`). Keep `SNAPPY_MCP_OPERATOR_SESSION` **absent** — the build gate `scripts/gates/agent-workspace-authority.mjs` fails on any spelling of it (`:127-152`) |
| **Session persistence** | Per-run directory `<stateRoot>/delegations/<runId>/workspace/` (`agent-invocation-executor.ts:169-171`) holding `AGENTS.md`, `TEAM.md`, `task.json`, `authority.json`, `skills/`, `output.md`, `ag-ui.jsonl`, and the isolation roots `.codex/`, `.home/`, `.claude-config/`, `.mcp.json`, `.pi-agent/` (`agent-launch-contract.ts:225-233`). Mode 0700/0600 (`:367-371`) | `session/load` / `session/resume` / `session/list` / `session/close` — a session outlives a turn | The plan proposes `~/Library/Application Support/Snappy OS/agents/<id>/` (`ACP-HOST-PLAN.md:40`). That is a **third** state root beside the delegation workspace and `SNAPPY_STATE_ROOT`. Put hosted sessions under the same `stateRoot()` the daemon uses (`state-root.ts`), or `agent-launch-contract.ts:183-199` repeats itself — that comment records 571 stray grant stores on this disk from exactly this mistake |
| **Login / auth surfaces** | `resolveClaudeCredential` over a slot ladder + `credentialBaseEnv`; **an absence REFUSES** with `runtime_credential_unavailable` (`agent-launch-contract.ts:771-779`). Codex auth is a symlink of the machine `auth.json` into the per-run `CODEX_HOME` (`:380-383`). `POST /codex-login` exists (`routes/codex-login.ts`), and `routes/claude-subscriptions.ts` holds the slot store | `-32000` + `authMethods`, including `{type:"terminal", id, args, env}` for a TUI login the client must run as a separate interactive process | A PTY is new surface — nothing in the Swift tree runs one today. `authMethods` of terminal type are only advertised if the client sets `clientCapabilities.auth.terminal`; do **not** set it in P1, and route a `-32000` to the existing `/codex-login` / claude-slot roads instead |
| **Cost / usage display** | `cost_usd: stream.result()?.cost_usd ?? null` at settle (`agent-invocation-executor.ts:438`), harvested from claude's terminal `result` frame `total_cost_usd` (`agent-run-ag-ui.ts:327-335`). **Codex publishes no cost** — `cost_usd: null` deliberately (`:387-394`). A null means nobody measured, never zero | `usage_update {used, size, cost?}` (cumulative) and prompt-response `_meta.quota` — a live number mid-turn, which stream-json never gives | Map `usage_update` to a new AG-UI event or to `RUN_METADATA`; do not stuff it into `BACKEND_NOTICE`. Preserve the null-vs-zero law. This finally gives codex a cost |
| **Cancel semantics** | `cancelDelegation(root, runId, reason)` (`agent-invocation-executor.ts:869-893`): SIGTERM the held child then SIGKILL after 10 s, exit path settles `cancelled_by_control`; no local child ⇒ claim the row and settle. The wall is SIGTERM + SIGKILL after 5 s (`:498-503`). The **UI-facing shape already exists**: `run_receipt.ts:160-171, 403-413` serves `control.cancel = {tool_id:"post-work-control", arguments:{verb:"cancel", target_kind:"run", target_id, reason}}` only while a run is live | `session/cancel` is a **notification**, cooperative; the prompt MUST return `stopReason:"cancelled"`, and the client MUST answer every pending `request_permission` with the cancelled outcome | Keep `cancelDelegation` as the outer authority (reachable from the governed work-control verb, and already spelled for the glass by `run_receipt.control.cancel`) and add an inner cooperative step: `session/cancel` → wait ≤5 s → tree kill via the existing `collectDescendants` (`HeadScreenSubprocess.swift:635-667`). **Never SIGTERM mid-tool-call** (`ACP-HOST-PLAN.md:67`) |
| **Orphan / process supervision** | Three named death modes in `$R/state/lib/agent-invocation-recovery.ts:7-24`: the tsx pipe leak (5,316 dead pipes at 96 % volume), the silent ENOSPC that launches a toolless agent, and the dead launcher (an install killed the daemon holding a run). Preflight probes a unix-socket `listen` before claiming anything (`agent-invocation-executor.ts:627-648`); a reclaimed row with a pre-existing workspace always settles rather than relaunching (`:675-682`) | Nothing — ACP has no supervision story | An `AgentHost` inherits all three. It also **removes** one: an in-process ACP client does not spawn `npx tsx` per MCP call the way… actually it still does (the MCP server is spawned per session), so the pipe reaper stays load-bearing. Reuse `decideHealthProbeAction`'s shape: a pure function over `{childAlive, sessionOpen, lastFrameAtMs}` |
| **Deploy-truth implications** | `GET /deploy-truth` compares the running process to the last deploy stamp and names `runtime_owner` + `state_paths` (`routes/health-and-observability.ts:176-230`). `resolveRenderServerPath` exists because every install mints a new runtime dir and reaps the old — **41 of 41 workspaces named a file that no longer existed** (`agent-invocation-executor.ts:296-311`) | Nothing | An `AgentHost` living in the Swift binary changes the truth question from "which runtime dir" to "which app binary". `/deploy-truth` must gain the host's own identity, or "is the ACP host live" becomes unanswerable — and this repo's whole method (`docs/HANDOFF-20260902-NIGHT.md:88-91`) is `curl /deploy-truth`. Also: an ACP adapter resolved by absolute path has the same reaping problem as `render-server.ts` did |
---
## G. ONE HOSTED TURN, END TO END, UNDER TODAY'S MODEL
The ask: *"write `notes.md` in the workspace."* Runtime claude-code. What actually happens now:
1. **Claim + workspace.** `runAgentInvocationPass` polls, claims, materializes
`<stateRoot>/delegations/agent-run-XXXX/workspace/` with `AGENTS.md` + `TEAM.md` + `authority.json`
+ `skills/`, and emits claim-time receipts (`agent-invocation-executor.ts:723-799`).
2. **Tool road.** `materializeToolRoad` writes `.mcp.json` (0600) naming
`npx tsx <render-server.ts>` with `SNAPPY_CALLER_ID=agent:<slug>@vN`, `SNAPPY_RUN_ID=<run>`
(`agent-launch-contract.ts:333-385`). `toolRoadIsOpen` re-stats it right before spawn
(`agent-invocation-executor.ts:330`).
3. **Launch receipt.** `agent_invocation_launch` to Convex with model resolution, agent version,
AGENTS.md sha256, authority revision, computer, runtime, workspace ref, wall
(`:821-835`). A launch without lineage is refused at the door.
4. **Spawn.** `spawn(claudeBin, ["-p","--dangerously-skip-permissions","--disallowedTools",
"Agent,ListAgents,SendMessage,Task","--output-format","stream-json","--verbose",
"--mcp-config",<path>,"--strict-mcp-config","--",prompt], {cwd: workspacePath, env, stdio:
["ignore","pipe","pipe"]})` (`:384-388`, argv at `agent-launch-contract.ts:812-834`). 15-minute
wall armed (`:498-503`, `DELEGATION_WALL_MS` at `:121`).
5. **The AG-UI sequence** written to `ag-ui.jsonl` (`agent-run-ag-ui.ts`):
```
RUN_STARTED {threadId: <session_id>, runId} ← system/init :245
RUN_METADATA {threadId, runId, harnessMode:"delegation"} :246
HARNESS_TRACE {backend:"claude-code", model, internalRoute:"agent-invocation-executor"} :247-252
TEXT_MESSAGE_START {messageId, role:"assistant"} ← assistant/text :275
TEXT_MESSAGE_CONTENT {messageId, delta:"I'll write that file."} :276
TEXT_MESSAGE_END {messageId} :277
TOOL_CALL_START {toolCallId:"toolu_01…", toolCallName:"Write"} ← tool_use :293
TOOL_CALL_ARGS {toolCallId, delta:'{"file_path":"…/notes.md","content":"…"}'} :294
TOOL_CALL_END {toolCallId} :295
TOOL_CALL_RESULT {toolCallId, toolName:"Write", content:"…", isError:false} ← user/tool_result :306-312
TEXT_MESSAGE_START/CONTENT/END {messageId2, "Done — notes.md written."} :275-277
RUN_FINISHED {threadId, runId, outcome:"success"} ← result frame :346
```
There is **no permission frame anywhere in that list**, because the flag turned it off.
6. **Run steps.** `createAgentStepRecorder` (`agent-run-steps.ts:47-96`) folds
`TOOL_CALL_START` → open, `TOOL_CALL_RESULT` → one persisted `RunStepRecord`:
`{run_id, index:1, step_id:"toolu_01…", tier:"capability", binding_digest:"",
description:"Used Write", effect:"local-write" (LOCAL_WRITE_TOOLS matches `Write`, `:26-30`),
status:"ok", started_at, finished_at, duration_ms, output_digest: sha256(first 500 chars),
output_bytes, cloned_from_run_id:null, result:{step_id, tier:"capability", ok:true, output:<preview>}}`
→ `persistRunStep` (`script-run-steps.ts:56-90`), which also bridges to the store's `run_steps`
table (`:1-25`).
7. **Prose.** The same translation hands back reconstructed prose, appended live to `output.md`
(`agent-invocation-executor.ts:519-525`), rewritten wholesale at settle (`:406`).
8. **Settle.** `settlementForExit` (`agent-run-answer.ts:228-273`) turns exit 0 + text into
`finished`; `agent_invocation_settle` carries `summary`, `did` (the whole final answer, never a
beheaded tail), `cost_usd` from the result frame, `steps_total: 1`, `model:
describeLaunchRecord(...)`, and `outputs: [<deliverable with its composed OpenUI face>]`
(`agent-invocation-executor.ts:423-451`; face at `$R/state/lib/agent-output-face.ts:18-30`).
9. **Receipt / Activity.** One `activity_event` row: `kind:"run"`, `lifecycle_state:"succeeded"`,
`effect:"produces-content"`, `href:"#/library/activity?run_id=…"`, `latency_ms`
(`:452-483`), a local `agent-delegations` log line (`:484-489`), and a mechanical outcome row
written by a **different** function than the one that produced the work (`:191-203`).
10. **Glass.** The room's `live-run` hook attaches to `.../output/events`, resets on `open`,
folds every frame, and `run-stream.tsx` draws: one said bubble, one tool row reading "Wrote a
file" / "Used Write", one said bubble, then the settled face.
**Under ACP the same turn gains** a `tool_call {kind:"edit", status:"pending"}` before the write, a
`session/request_permission` the host answers, a `tool_call_update {status:"completed",
content:[{type:"diff", path, oldText, newText}]}`, a `usage_update`, and `{stopReason:"end_turn"}`.
Steps 1–3 and 6–9 are unchanged work that must still happen — they are not ACP's job, and skipping
them is how a hosted turn becomes invisible to Today, Outputs, Activity and the receipt rail.
---
## H. RISKS AND ODDITIES THAT BITE AN AGENT HOST
1. **111 git worktrees.** `git worktree list | wc -l` → 111, including 7 locked
`/private/tmp/snappy-fable-audit-*` and ~90 under `~/.codex/worktrees/`. `docs/TRAPS.md:218-226`:
never `git stash` with worktrees (one shared `refs/stash`), bootstrap before any test, and
positional vitest args match worktree copies too. A `swift test` run from the wrong tree proves
nothing.
2. **Two spellings of the repo root.** This session's cwd is
`/Users/robertboulos/projects/snappy-os-app`; `git worktree list` and the running daemon both say
`/Users/robertboulos/Projects/snappy-os-app` (capital P). macOS's case-insensitive filesystem
hides it, but `SNAPPY_OS_ROOT` string comparisons and `detectRuntimeOwner`
(`routes/health-and-observability.ts:178-185`) do not.
3. **The web bundle is a tracked source file, and the fast swap dirties it.** 77 files under
`apps/snappy-os/Sources/SnappyOS/Resources/web/` are in git; right now `git status` shows
`build-stamp.txt` and `index.html` modified. `docs/HANDOFF-20260902-NIGHT.md:88-89`: a full
`app:install` "refuses on ANY dirty tracked file". So the fast web swap and the full install are
mutually exclusive until you commit or check out.
4. **`node-universal` is 267 MB and gitignored** (`apps/snappy-os/.gitignore:20`). A fresh clone
cannot build a working app; the Swift shell then falls back to `/opt/homebrew/bin/node`
(`HeadScreenSubprocess.swift:19-28`). A hosted ACP adapter that assumes the bundled node is
present will fail on exactly the machines that already fail.
5. **The runtime directory is remade and reaped on every install.** The live daemon right now is
`/Applications/SnappyOS.app/…/runtime/node-universal
/Users/robertboulos/Projects/snappy-os-app/apps/snappy-os/.build/runtimes/<hash>/state/bin/head-screen/server.mjs`.
This is precisely the failure `resolveRenderServerPath` was written for — 41 of 41 workspaces
named a deleted file (`agent-invocation-executor.ts:296-311`). **Any absolute path to an ACP
adapter must be resolved, not pinned.**
6. **Three identities of the daemon.** launchd `com.snappy.head-screen-app`
(`HeadScreenSubprocess.swift:18`), an app-spawned child (`:533-596`), and a dev
`npm run app:preview` on the same port — `docs/TRAPS.md:96-101`: the preview daemon claims :3147
with the repo-tree state root and every later `app:install` then fails the durable-state gate and
rolls back. One full install already burned on this.
7. **The dead name is still on disk.** `apps/snappy-os/dist/` holds both `SnappyChat.app` and
`SnappyOS.app`; `docs/TRAPS.md:171-172` — "SnappyChat is a dead name; quitting it silently does
nothing." The icon resource is still `SnappyChat.icns`, and a UserDefaults suite `ai.snappy.chat`
is still read (`HeadScreenSubprocess.swift:100-113`).
8. **A full install regenerates the WebKit origin salt**, orphaning the signed-in Convex session
(`docs/TRAPS.md:102-110`) — which is why `AuthSessionKeeper` exists, and why the login keychain
was abandoned after it cost a rolled-back install and a night of password dialogs (`:111-126`).
9. **codex's seatbelt sandbox denies loopback network to `command_execution`, but not to MCP.**
`$R/state/lib/agent-bus-address.ts:120-141`: run `agent-run-1ca12b01` reported "the A2A endpoint
was unreachable on port 3147" while the door answered 200 the whole time — a shell `curl` to
loopback cannot connect, while the MCP server spawned outside the sandbox could. Any hosted
codex-acp session inherits this asymmetry.
10. **The MCP server's caller identity is frozen at module load** (`render-server-config.ts:25`), so
a long-lived shared MCP process across ACP sessions would attribute every governed read to one
caller. One process per session, or a new per-request seam.
11. **`SNAPPY_MCP_OPERATOR_SESSION` is a landmine with a build gate on it.** It once made every
delegated agent *be Robert* — `/me` answered `person:local, authority ["read","stage","execute",
"approve"]` — and falsified three separate safety comments at once
(`agent-launch-contract.ts:127-182`). Never set it from an ACP host.
12. **Per-leg timing does not exist.** `docs/HANDOFF-20260902-NIGHT.md:151-155`: one sentence from an
agent is **42.6 s** wall, `runs` has no `claimed_at`/`launched_at`, AG-UI frames carry no
timestamps, and the executor logs only the settle. An ACP host will be judged on latency and the
instrument is missing. Adding a timestamp to each AG-UI frame is the cheap fix and would benefit
both roads.
13. **0 of 41 production agents declare an `output_contract`** (`:196-198`), so typed GenUI outcomes
almost never render. An ACP host does not change this.
14. **Three pre-existing red tests** on clean HEAD (`docs/HANDOFF-20260902-NIGHT.md:68-72`) — do not
read them as your regression, and do not `.skip()` them.
15. **`grep` in this session's shell is a function that returns silence for files it calls binary**
(`docs/TRAPS.md:11-23`). Use `/usr/bin/grep` for any search whose empty result you intend to
report. Two files in `Sources/SnappyOS/Resources/` are exactly that shape.
16. **ZERO PROVIDER FAILOVER is enforced in two places, and a hosted turn is subject to both.**
`pickRuntime` returns `null` rather than substituting a provider (`agent-invocation-executor.ts:242-254`:
"A provider is authority and billing identity, not a preference"), and `reportLaunch` refuses a
`runtime` that differs from `run.runtime_hint` as "a forbidden provider fallback"
(`convex/agent_invocations.ts:522-527`). An `AgentHost` that quietly falls back from `claude` to
`codex` when a login is missing will be refused at the door — correctly.
17. **The chat and the agent room are on different transports.** Chat is the Vercel AI SDK UI stream
(`use-chat-pipe.ts:34-38`, `/threads/:id/ui-stream`); the agent room is the AG-UI SSE tail
(`live-run.ts:183-234`). A hosted ACP turn must pick one; landing frames in both is a third road.
Also: `ask()` **refuses mid-run** and returns `"kept"` (`use-chat-pipe.ts:85-94`), so an ACP
session that expects to accept a prompt while a turn is open has no chat surface today.
18. **A `claimed`-but-never-`launched` run may not settle `finished`** (`convex/agent_invocations.ts:724-732`).
A hosted turn that spawns, talks, and finishes without ever writing a launch receipt cannot record
success — it can only record failure. This is the single sharpest reason the run-creation seam
(§F row 1) has to be built before P1's "text streams into the web chat" milestone is called done.
---
## I. THE THREE PLACES THE PLAN AND THE CODEBASE DISAGREE
Stated plainly, because they are cheap to fix now and expensive later.
1. **`ACP-HOST-PLAN.md:29` routes hosted-turn events through
`ChatBridge.sendToJS("agent.update", …)` into the web chat.** That is a second stream protocol
beside AG-UI, which `live-run.ts:26-31` and `ag-ui-event-contract.ts:1-27` were both written to
prevent, and which `program.md:8` ("one path") forbids. Emit the existing AG-UI vocabulary —
the web fold, the transcript, the tool row and the step recorder all come free, and the only
genuinely new frames are permission, plan, diff, terminal and usage.
2. **`ACP-HOST-PLAN.md:34` says "the Node runtime is not in the loop for hosted turns; it only
receives run receipts."** But the run ledger, the approval mint, the grant store, the Activity
stream, the deliverable face and `steps_total` are all Node-side, and the Convex launch door
*refuses* a launch that cannot present its lineage (`agent-workspace.ts:24-27`). A hosted turn
that skips them is invisible to Today, Outputs, Needs You and the receipt rail. Swift should own
the **process and the protocol**; the run's identity and governance stay where they are.
3. **`ACP-HOST-PLAN.md:40` puts sessions in
`~/Library/Application Support/Snappy OS/agents/<id>/`.** That is a third state root beside
`SNAPPY_STATE_ROOT` and the delegation workspace. `agent-launch-contract.ts:183-199` records what
a divergent state root already cost: 571 stray grant stores on this disk, 24 of them minted in
delegation workspaces in a single day, because a child resolved its own root and
`loadCallerStore()` seeds on absence rather than refusing.
## J. WHAT THIS REPORT COULD NOT ESTABLISH
- Whether `codex-acp` inherits the seatbelt sandbox's loopback denial (§H.9) — needs one live probe.
- Whether `swift-acp` 0.1.0 builds under this package's `swift-tools-version:6.0` strict concurrency
with `.unsafeFlags` present (`Package.swift:80-83`); no resolve was attempted (read-only pass).
- Whether `POST /threads/:id/ui-stream` (the chat transport) can carry ACP-shaped tool parts, or
whether a hosted turn must go down the agent-run road instead. The client side already tolerates
`dynamic-tool` (`sdk-adapt.ts:102-105`); the server side of that door was not read.
- Whether `api.agent_brain.apply` (per-definition brain, `settings-pane.tsx:112-143`) would accept an
`acp` runtime word, or whether `EditableAgentRuntime` and the store's model-policy vocabulary
(`convex/tables/agent_model_policy.ts`) need a new member first.
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "probe", platforms: [.macOS(.v13)],
dependencies: [.package(url: "https://github.com/wiedymi/swift-acp", exact: "0.1.0")],
targets: [.executableTarget(name: "probe", dependencies: [.product(name: "ACP", package: "swift-acp")])]
)
// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "probe", platforms: [.macOS(.v13)], dependencies: [.package(url: "https://github.com/wiedymi/swift-acp", exact: "0.1.0")], targets: [.executableTarget(name: "probe", dependencies: [.product(name: "ACP", package: "swift-acp")])] )
// Verified 2026-09-02 on macOS (Swift 6.1.2 CLT) against @agentclientprotocol/claude-agent-acp 0.73.0:
// swift build -c release && ACP_ADAPTER=<…/claude-agent-acp/dist/index.js> .build/release/probe <git dir> ["prompt"]
// run 1: "hello from swift", end_turn, 8.3 s, updates: agent_message_chunk, usage_update, available_commands_update
// run 2: Write tool -> session/request_permission answered allow_always in Swift -> file on disk -> end_turn, 10.4 s
// codex: ACP_ADAPTER=<…/codex-acp/dist/index.js> CODEX_HOME=<isolated home with wire_api="responses"> CODEX_API_KEY=<OpenRouter key>
// plain turn end_turn 5.8 s; Write turn end_turn 5.7 s — no permission request: Codex's own approval_policy/sandbox decided it.
// This is the line-for-line reference for SnappyOS.app's AgentHost: same env hygiene as api.ts spawnSpec.
import ACP
import Foundation
final class AutoApprove: ClientDelegate, @unchecked Sendable {
func handlePermissionRequest(request: RequestPermissionRequest) async throws -> RequestPermissionResponse {
let pick = request.options.first { $0.kind == "allow_always" } ?? request.options.first { $0.kind == "allow_once" } ?? request.options[0]
print("[perm] \(request.toolCall.title ?? "?") -> \(pick.kind)")
return RequestPermissionResponse(outcome: PermissionOutcome(optionId: pick.optionId))
}
func handleFileReadRequest(_ path: String, sessionId: String, line: Int?, limit: Int?) async throws -> ReadTextFileResponse {
ReadTextFileResponse(content: try String(contentsOfFile: path, encoding: .utf8))
}
func handleFileWriteRequest(_ path: String, content: String, sessionId: String) async throws -> WriteTextFileResponse {
try content.write(toFile: path, atomically: true, encoding: .utf8); return WriteTextFileResponse()
}
struct Unsupported: Error {}
func handleTerminalCreate(command: String, sessionId: String, args: [String]?, cwd: String?, env: [EnvVariable]?, outputByteLimit: Int?) async throws -> CreateTerminalResponse { throw Unsupported() }
func handleTerminalOutput(terminalId: TerminalId, sessionId: String) async throws -> TerminalOutputResponse { throw Unsupported() }
func handleTerminalWaitForExit(terminalId: TerminalId, sessionId: String) async throws -> WaitForExitResponse { throw Unsupported() }
func handleTerminalKill(terminalId: TerminalId, sessionId: String) async throws -> KillTerminalResponse { throw Unsupported() }
func handleTerminalRelease(terminalId: TerminalId, sessionId: String) async throws -> ReleaseTerminalResponse { throw Unsupported() }
}
func run() async throws {
let args = CommandLine.arguments
let cwd = args.count > 1 ? args[1] : FileManager.default.currentDirectoryPath
let text = args.count > 2 ? args[2] : "Reply with exactly the three words: hello from swift"
let adapter = ProcessInfo.processInfo.environment["ACP_ADAPTER"] ?? ""
var env = ProcessInfo.processInfo.environment
for k in ["CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"] { env.removeValue(forKey: k) }
env["NODE_NO_WARNINGS"] = "1"; env["NO_COLOR"] = "1"; env["DISABLE_AUTOUPDATER"] = "1"
env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"; env["CLAUDE_CODE_DISABLE_TERMINAL_TITLE"] = "1"
env["CLAUDE_CODE_EXECUTABLE"] = "/Users/robertboulos/.local/bin/claude"
let client = Client()
let delegate = AutoApprove()
await client.setDelegate(delegate)
let t0 = Date()
try await client.launch(agentPath: "/opt/homebrew/bin/node", arguments: [adapter], workingDirectory: cwd, environment: env)
var updates = 0; var textOut = ""; var kinds: [String: Int] = [:]
let notes = await client.notifications
let pump = Task {
for await n in notes {
guard n.method == "session/update", let p = n.params else { continue }
updates += 1
if let data = try? JSONEncoder().encode(p), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let u = obj["update"] as? [String: Any], let kind = u["sessionUpdate"] as? String {
kinds[kind, default: 0] += 1
if kind == "agent_message_chunk", let c = u["content"] as? [String: Any], let t = c["text"] as? String { textOut += t }
}
}
}
let initR = try await client.initialize(
capabilities: ClientCapabilities(fs: FileSystemCapabilities(readTextFile: true, writeTextFile: true), terminal: false),
clientInfo: ClientInfo(name: "snappy-probe", title: "Snappy ACP probe", version: "0"), timeout: 60)
print("[init] agent=\(initR.agentInfo?.name ?? "?") \(initR.agentInfo?.version ?? "") authMethods=\(initR.authMethods?.map { $0.id } ?? [])")
let sess = try await client.newSession(workingDirectory: cwd, timeout: 60)
print("[session] \(sess.sessionId)")
let resp = try await client.sendPrompt(sessionId: sess.sessionId, content: [.text(TextContent(text: text))])
try? await Task.sleep(nanoseconds: 300_000_000)
pump.cancel()
let ms = Int(Date().timeIntervalSince(t0) * 1000)
print("[turn] stop=\(resp.stopReason.rawValue) updates=\(updates) kinds=\(kinds) \(ms) ms")
print("[agent said] \(textOut.trimmingCharacters(in: .whitespacesAndNewlines))")
await client.terminate()
}
let watchdog = Task { try? await Task.sleep(nanoseconds: 150_000_000_000); if Task.isCancelled { return }; FileHandle.standardError.write(Data("[probe] timeout\n".utf8)); exit(2) }
let sem = DispatchSemaphore(value: 0)
Task { do { try await run() } catch { print("[probe] error: \(error)") }; watchdog.cancel(); sem.signal() }
sem.wait()
exit(0)
// Verified 2026-09-02 on macOS (Swift 6.1.2 CLT) against @agentclientprotocol/claude-agent-acp 0.73.0:
// swift build -c release && ACP_ADAPTER=<…/claude-agent-acp/dist/index.js> .build/release/probe <git dir> ["prompt"]
// run 1: "hello from swift", end_turn, 8.3 s, updates: agent_message_chunk, usage_update, available_commands_update
// run 2: Write tool -> session/request_permission answered allow_always in Swift -> file on disk -> end_turn, 10.4 s
// codex: ACP_ADAPTER=<…/codex-acp/dist/index.js> CODEX_HOME=<isolated home with wire_api="responses"> CODEX_API_KEY=<OpenRouter key>
// plain turn end_turn 5.8 s; Write turn end_turn 5.7 s — no permission request: Codex's own approval_policy/sandbox decided it.
// This is the line-for-line reference for SnappyOS.app's AgentHost: same env hygiene as api.ts spawnSpec.
import ACP
import Foundation
final class AutoApprove: ClientDelegate, @unchecked Sendable {
func handlePermissionRequest(request: RequestPermissionRequest) async throws -> RequestPermissionResponse {
let pick = request.options.first { $0.kind == "allow_always" } ?? request.options.first { $0.kind == "allow_once" } ?? request.options[0]
print("[perm] \(request.toolCall.title ?? "?") -> \(pick.kind)")
return RequestPermissionResponse(outcome: PermissionOutcome(optionId: pick.optionId))
}
func handleFileReadRequest(_ path: String, sessionId: String, line: Int?, limit: Int?) async throws -> ReadTextFileResponse {
ReadTextFileResponse(content: try String(contentsOfFile: path, encoding: .utf8))
}
func handleFileWriteRequest(_ path: String, content: String, sessionId: String) async throws -> WriteTextFileResponse {
try content.write(toFile: path, atomically: true, encoding: .utf8); return WriteTextFileResponse()
}
struct Unsupported: Error {}
func handleTerminalCreate(command: String, sessionId: String, args: [String]?, cwd: String?, env: [EnvVariable]?, outputByteLimit: Int?) async throws -> CreateTerminalResponse { throw Unsupported() }
func handleTerminalOutput(terminalId: TerminalId, sessionId: String) async throws -> TerminalOutputResponse { throw Unsupported() }
func handleTerminalWaitForExit(terminalId: TerminalId, sessionId: String) async throws -> WaitForExitResponse { throw Unsupported() }
func handleTerminalKill(terminalId: TerminalId, sessionId: String) async throws -> KillTerminalResponse { throw Unsupported() }
func handleTerminalRelease(terminalId: TerminalId, sessionId: String) async throws -> ReleaseTerminalResponse { throw Unsupported() }
}
func run() async throws {
let args = CommandLine.arguments
let cwd = args.count > 1 ? args[1] : FileManager.default.currentDirectoryPath
let text = args.count > 2 ? args[2] : "Reply with exactly the three words: hello from swift"
let adapter = ProcessInfo.processInfo.environment["ACP_ADAPTER"] ?? ""
var env = ProcessInfo.processInfo.environment
for k in ["CLAUDECODE", "CLAUDE_CODE_CHILD_SESSION", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"] { env.removeValue(forKey: k) }
env["NODE_NO_WARNINGS"] = "1"; env["NO_COLOR"] = "1"; env["DISABLE_AUTOUPDATER"] = "1"
env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"; env["CLAUDE_CODE_DISABLE_TERMINAL_TITLE"] = "1"
env["CLAUDE_CODE_EXECUTABLE"] = "/Users/robertboulos/.local/bin/claude"
let client = Client()
let delegate = AutoApprove()
await client.setDelegate(delegate)
let t0 = Date()
try await client.launch(agentPath: "/opt/homebrew/bin/node", arguments: [adapter], workingDirectory: cwd, environment: env)
var updates = 0; var textOut = ""; var kinds: [String: Int] = [:]
let notes = await client.notifications
let pump = Task {
for await n in notes {
guard n.method == "session/update", let p = n.params else { continue }
updates += 1
if let data = try? JSONEncoder().encode(p), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let u = obj["update"] as? [String: Any], let kind = u["sessionUpdate"] as? String {
kinds[kind, default: 0] += 1
if kind == "agent_message_chunk", let c = u["content"] as? [String: Any], let t = c["text"] as? String { textOut += t }
}
}
}
let initR = try await client.initialize(
capabilities: ClientCapabilities(fs: FileSystemCapabilities(readTextFile: true, writeTextFile: true), terminal: false),
clientInfo: ClientInfo(name: "snappy-probe", title: "Snappy ACP probe", version: "0"), timeout: 60)
print("[init] agent=\(initR.agentInfo?.name ?? "?") \(initR.agentInfo?.version ?? "") authMethods=\(initR.authMethods?.map { $0.id } ?? [])")
let sess = try await client.newSession(workingDirectory: cwd, timeout: 60)
print("[session] \(sess.sessionId)")
let resp = try await client.sendPrompt(sessionId: sess.sessionId, content: [.text(TextContent(text: text))])
try? await Task.sleep(nanoseconds: 300_000_000)
pump.cancel()
let ms = Int(Date().timeIntervalSince(t0) * 1000)
print("[turn] stop=\(resp.stopReason.rawValue) updates=\(updates) kinds=\(kinds) \(ms) ms")
print("[agent said] \(textOut.trimmingCharacters(in: .whitespacesAndNewlines))")
await client.terminate()
}
let watchdog = Task { try? await Task.sleep(nanoseconds: 150_000_000_000); if Task.isCancelled { return }; FileHandle.standardError.write(Data("[probe] timeout\n".utf8)); exit(2) }
let sem = DispatchSemaphore(value: 0)
Task { do { try await run() } catch { print("[probe] error: \(error)") }; watchdog.cancel(); sem.signal() }
sem.wait()
exit(0)
2026-09-02. Design input for a Swift-native ACP host in SnappyOS.app spawning
@agentclientprotocol/claude-agent-acp, @agentclientprotocol/codex-acp and gemini --acp over stdio via
wiedymi/swift-acp. Nothing here is invented; §M lists where Zed and the spec disagree.
Tags: [src] Zed working copy · [docs] official docs (agentclientprotocol.com, or Zed's own docs/src in the
same checkout) · [issue] tracker. Goes deeper than
~/.claude/skills/snappy-agent-host/references/extract-agent-host.md §D/§B, which is the starting point. Companion
file (same commit, full detail on plan/usage/modes/commands):
~/.claude/skills/snappy-agent-host/references/zed-plan-usage-modes-commands.md.
[src] /Users/robertboulos/projects/cloned-repos/zed @ 8514ce3ba1ee7a6786e35f6566c7cfeb3c42d3b7
(2 Sep 2026 15:41 UTC). Shallow + sparse: only `crates/{acp_thread,acp_tools,agent,agent_servers,
agent_settings,agent_ui,node_runtime,project,settings_content,terminal,util} and docs/ exist; assets/` and
crates/ui are tracked but not checked out (read via git show).
| File | LoC | Owns |
|---|---|---|
crates/acp_thread/src/acp_thread.rs |
10200 | thread model, merge rules, permissions, cancel, fs, terminals, ~120 tests |
crates/acp_thread/src/connection.rs |
1138 | AgentConnection trait = the whole client capability surface; PermissionOptions |
crates/acp_thread/src/{terminal,diff,mention}.rs |
761/423/1773 | terminal entity + truncation; multibuffer diffs; @-mentions |
crates/agent_servers/src/acp.rs |
5138 | the wire client: spawn, transport, initialize, session methods, all agent→client handlers |
crates/agent_servers/src/{agent_servers,custom,e2e_tests}.rs |
135/431/500 | registry vs custom agents; live e2e suite |
crates/agent_ui/src/conversation_view{,/thread_view}.rs |
11227/12974 | permission cards, tool cards, diffs, terminals, plan, usage |
crates/acp_tools/src/acp_tools.rs |
842 | dev::OpenAcpLogs, the wire log viewer |
crates/project/src/{agent_server_store,agent_registry_store,environment}.rs |
— | command resolution, registry, shell env |
crates/util/src/{process,shell_builder,shell_env}.rs |
— | setsid/killpg, shell wrapping, env harvest |
crates/agent/src/thread.rs |
8885 | Zed's own agent — matters only because it owns "always allow" persistence |
docs/src/ai/{external-agents,tool-permissions,mcp}.md |
— | Zed's user-facing contract |
Protocol version, resolved. Cargo.toml:521 pins `agent-client-protocol = { version = "=2.0.0",
features = ["unstable"] }, but the wire version is **1**: MINIMUM_SUPPORTED_VERSION = ProtocolVersion::V1`
(acp.rs:663), InitializeRequest::new(ProtocolVersion::V1) (:993), reject if `response.protocol_version <
MINIMUM_SUPPORTED_VERSION (:1023), schema imported as agent_client_protocol::schema::{MaybeUndefined, v1 as acp}`
(acp_thread.rs:7). Crates.io confirms `unstable = [unstable_auth_methods, unstable_elicitation,
unstable_end_turn_token_usage, unstable_mcp_over_acp, unstable_session_fork] and that **unstable_protocol_v2` is a
separate feature not in that umbrella [docs]. Crate 2.0.0 ≠ protocol 2. Target "protocolVersion": 1.**
Spec source: https://agentclientprotocol.com/llms-full.txt (1.44 MB, the entire site in one fetch — use it, not
per-page requests) plus schema/v1/schema.json (170 $defs). schema/v2/schema.json exists; v2 is draft, removes
fs/*, all terminal/* and session modes, makes updates upserts, and renames authenticate → auth/login
[docs] /protocol/v2/migration. Not needed here.
session/update into#One flat Vec<AgentThreadEntry> (acp_thread.rs:2094), six variants (:394-401):
UserMessage | AssistantMessage | ToolCall | Elicitation(id) | CompletedPlan(Vec<PlanEntry>) | ContextCompaction. The
live plan, token usage, modes, config options and available commands are thread-level fields, not entries
(:2095-2118); only a finished plan is snapshotted into the list (snapshot_completed_plan, :3602).
handle_session_update (:2549-2655) is the whole dispatch:
sessionUpdate |
Zed's fold | Cite |
|---|---|---|
user_message_chunk |
append to last UserMessage if ids mergeable; echo-suppressed (B.2) |
2555-2586 |
agent_message_chunk |
merge into last AssistantMessage's last Message chunk |
2587-2596 |
agent_thought_chunk |
same, into a Thought chunk; Message↔Thought flip always starts a new chunk |
2597-2604 |
tool_call |
upsert — existing id updates in place, new id pushes | 2605-2607, 3190 |
tool_call_update |
field update; unknown id → synthesised Failed entry, no error |
2608-2610, 3115 |
plan |
full replace of plan.entries |
2611-2613, 3577 |
session_info_update |
title only, only when MaybeUndefined::Value; updatedAt ignored |
2614-2624 |
available_commands_update |
replace list, emit event | 2625-2632 |
current_mode_update |
emit ModeUpdated; the connection, not the thread, stores it |
2633-2636 |
config_option_update |
emit ConfigOptionsUpdated |
2637-2640 |
usage_update |
max_tokens = size, used_tokens = used; cost only overwritten when present |
2641-2652 |
| anything else | _ => {} — silent no-op |
2653 |
Spec confirms 11 variants is the closed v1 set [docs] schema/v1. A Swift Codable enum needs a default case or it
breaks the day an agent ships a new kind.
B.1 Chunk merging. One function decides everything:
rustfn can_merge_message_chunks(existing: Option<&MessageId>, incoming: Option<&MessageId>) -> bool {
match (existing, incoming) { (Some(a), Some(b)) => a == b, _ => true } // :383-391
}
messageId is advisory — it only ever splits, and an absent id on either side merges. v1 makes it optional (v2
requires it) [docs]. Assistant path (:2770-2860): text with an active streaming target goes to the smooth-stream
buffer and returns early (:2779-2790); otherwise merge into the last chunk if indented matches, the
Message/Thought variant matches and ids are mergeable, adopting the incoming id when the existing one is None
(:2810-2826); a variant or id mismatch pushes a new chunk in the same entry (:2827-2840); a
non-AssistantMessage last entry pushes a new entry (:2841-2858).
B.2 Echo suppression. Zed pushes the user prompt optimistically before calling session/prompt. An
echoed user_message_chunk is dropped iff the last entry is an optimistic user message whose chunks already
contains(&content) and ids are mergeable — and the echo's messageId is stolen while dropping it (:2559-2586).
The user path adds one extra guard: never merge an optimistic-without-id into a non-optimistic-with-id (:2721-2724).
Tests: …ignore_echoed_user_message_chunks… (5937), …does_not_merge_into_optimistic_prompt (5624).
B.3 Smooth streaming. Text is not appended immediately; StreamingTextBuffer reveals it on a 16 ms timer
over a 200 ms target (:2126-2143, TASK_UPDATE_MS = 16, REVEAL_TARGET = 200.0), flushed at every entry boundary,
on cancel, on error and at turn end (:2944, 3903, 3794, 3884). This is why Zed types rather than stutters.
B.4 ContentBlock (:1346-1363) → Empty | Markdown | EmbeddedResource | ResourceLink | Image. text
and audio collapse to markdown/empty; image is base64-decoded eagerly (:1487); an EmbeddedResource whose
resource is a blob with an image mime type is also decoded as an image (:1493), otherwise it becomes a fenced
block whose language comes from the mime type (text_resource_render_mode, :1757). Spec baseline: agents MUST
support text and resource_link in prompts; image/audio/resource are gated on promptCapabilities [docs]
/protocol/v1/content.
C.1 The struct (acp_thread.rs:860-880). Fifteen fields, of which five come from _meta, not the
spec: tool_name, subagent_session_info, sandbox_authorization_details, sandbox_fallback_authorization_details,
sandbox_not_applied (helpers :69-292). Plus raw_input_markdown (pre-rendered) and resolved_locations (paths
resolved to open buffers + anchors). This is the practical answer to "what is _meta for": everything the spec has no
field for. Spec: "Implementations MUST NOT add any custom fields at the root of a type that's part of the
specification" [docs] /v1/extensibility.
C.2 tool_call is an upsert. upsert_tool_call_inner (:3200-3252) updates in place when
index_for_tool_call finds the id, else pushes. Lookup is a reverse linear scan ("typically the last one, or very
close to the end", :3277-3278) — no hash map.
C.3 tool_call_update merge, field by field (update_fields, :951-1114):
| Field | Rule | Cite |
|---|---|---|
kind |
replace | 1119 |
status |
update_acp_status — not a plain assign (C.5) |
1123 |
title |
replace, after kind-dependent rewriting (C.6); also pushes into embedded terminals | 1146-1163 |
content |
positional replace-with-reuse, then truncate (C.4) | 1165-1189 |
locations |
wholesale replace, then async re-resolve every path to a buffer | 1191, 3157 |
rawInput |
replace + re-render markdown | 1195 |
rawOutput |
replace and, only if content.is_empty(), push a rendered markdown block into content |
1200-1210 |
_meta |
each recognised key merged in, never cleared — absent key keeps the old value | 1127-1144 |
Matches the schema exactly: ToolCallUpdate.content is described as "Replace the content collection." and
locations as "Replace the locations collection." [docs] schema/v1. There is no append primitive in v1 —
tool_call_content_chunk is a v2 addition [docs].
C.4 Why the content merge is not a naive replace:
rustlet mut new_content_len = content.len();
for (old, new) in self.content.iter_mut().zip(content.by_ref()) {
if !old.update_from_acp(new, …)? { new_content_len -= 1; } // in-place, keeps the UI entity
}
for new in content { if let Some(n) = ToolCallContent::from_acp(new, …)? { self.content.push(n) }
else { new_content_len -= 1 } }
self.content.truncate(new_content_len); // :1165-1189
Semantically a replace; implemented index-wise so a live Markdown, a Diff's multibuffer or a Terminal survives
instead of being recreated — the comment says "reused across snapshots instead of being recreated (which flickers)"
(:1856-1858). new_content_len decrements for every item that fails to materialise (unknown ToolCallContent
variant → Ok(None), :1845), so unknown kinds drop without shifting.
The non-obvious part — tool-call text is a SNAPSHOT, not a delta:
rustmatch new_content.strip_prefix(¤t) {
Some("") => {} // identical
Some(suffix) => markdown.append(suffix, cx), // grew: append the tail only
None => markdown.reset(new_content.into(), cx), // changed: replace whole
} // :1468-1485
An agent streaming tool output over v1 resends the entire accumulated string each update. That is the exact
opposite of agent_message_chunk, where each chunk is a delta. Getting these backwards is the easiest way to
double or truncate text in a new client.
C.5 Status machine. Zed's enum is a superset of the wire's (:1263-1285): the spec's v1 set is
pending | in_progress | completed | failed (cancelled is v2-only [docs]), Zed adds three client-local
states — WaitingForConfirmation { current_status, options, respond_tx, kind }, Rejected, Canceled — which never
go on the wire (as_acp_status() maps the last two to None, :1305-1312).
rustfn update_acp_status(&mut self, status: acp::ToolCallStatus) {
if let WaitingForConfirmation { current_status, .. } = &mut self.status
&& matches!(status, Pending | InProgress) { *current_status = status; } // card stays up
else { self.status = status.into(); } // terminal status wins
} // :1088-1098
fn status_after_permission_grant(s) -> ToolCallStatus {
match s.into() { Pending => InProgress, other => other } // :1314-1317
}
So a pending/in_progress update while a permission card is open does not dismiss it; a completed/failed
update does — and because the old status (holding respond_tx) is dropped, the agent's pending
session/request_permission resolves to Cancelled. From<acp::ToolCallStatus> has a catch-all _ => Pending
(:1287-1297): the crate's v1 enums are open. The spec defines no legal transition table in either version; build
permissively.
C.6 Titles are kind-dependent (:888-896, re-applied on every update :1147-1162): Execute verbatim
as plain text (shell commands must never render as markdown); Edit markdown-escaped (file paths); anything else
collapsed to its first line + "…". ToolKind has ten values in the schema — `read | edit | delete | move |
search | execute | think | fetch | switch_mode | other — and **switch_mode` is missing from the docs prose list**
[docs]; Claude Code's exit-plan-mode uses it.
C.7 Unknown toolCallId fabricates a visible failure (:3124-3151): kind Fetch, status Failed,
label and content both "Tool call not found", pushed as a new entry — and Ok(()) returned, so the agent sees no
error. Pinned by test_tool_call_not_found_creates_failed_entry (8961). A quieter client would drop the update and
the user would never learn the agent is confused.
C.8 ToolCallUpdate in acp_thread is an enum of three (:1905-1910): UpdateFields (the wire one),
UpdateDiff, UpdateTerminal. The latter two attach a live entity without JSON round-trip and both do a hard
content.clear(); content.push(…) (:3170-3178). An external agent can only produce UpdateFields.
D.1 Wire contract. session/request_permission params: `{sessionId, toolCall: ToolCallUpdate,
options: PermissionOption[]}, all required. **v1 has no title/description/subject`** (v2 adds them), so the
prompt must be rendered from the embedded ToolCallUpdate. PermissionOption requires {optionId, name, kind};
kinds are allow_once | allow_always | reject_once | reject_always; the response is
{outcome:{outcome:"selected",optionId}} or {outcome:{outcome:"cancelled"}} [docs] /v1/tool-calls. Two MUSTs:
on turn cancellation the client MUST answer cancelled, and it MUST answer all pending permission requests
that way [docs] /v1/tool-calls, /v1/prompt-turn.
D.2 Three outcomes, not two (acp_thread.rs:1230-1246): Cancelled, InterruptedByFollowUp,
Selected(…). The middle one serialises to cancelled but is produced by run_turn when the user types a follow-up
while a card is open (:3742) — so the UI can say "superseded" rather than "you cancelled". Cheap now, impossible to
retrofit. SelectedPermissionOutcome carries {option_id, option_kind, params} but
only option_id is sent (:1224-1228); the rest is local bookkeeping.
D.3 AuthorizationKind (:1248-1261) splits PermissionGrant (allow ⇒ InProgress, reject ⇒ Rejected)
from ActionChoice (always ⇒ InProgress; the caller interprets option_id, e.g. "Save vs Discard before editing a
dirty buffer"). External agents are always PermissionGrant (agent_servers/acp.rs:4602).
D.4 Resolution (authorize_tool_call, :3433-3478): reject kinds ⇒ Rejected, allow kinds ⇒
status_after_permission_grant(current_status), and — twice — _ => treats an unrecognised kind as allow
(:3452, 3459), because the enum is open and a future allow_for_session must not read as a reject. Status flips
before the response is sent, and .ok() swallows a dropped receiver. The mirror, cancel_tool_call_authorization
(:3420-3431), used when the agent cancels its own request, is a **no-op unless the status is still
WaitingForConfirmation** — a late cancel cannot clobber a decision already made.
D.5 Zed persists nothing for external agents. PermissionOptions has three variants
(connection.rs:579-586), and external agents always get Flat(args.options) (acp.rs:4601); the
Dropdown/DropdownWithPatterns variants are built only in crates/agent/src/thread.rs (`984, 1000, 1055,
1165). ConversationView::authorize_tool_call (conversation_view.rs:467-497`) emits telemetry, calls the thread,
notifies — no settings write; grep always_allow crates/agent_ui/ finds only test strings. Persistence lives in
Thread::persist_permission_outcome (agent/src/thread.rs:6520-6560), which string-matches option-id prefixes
always_allow: / always_deny: / always_allow_mcp: / always_deny_mcp: and writes agent.tool_permissions.*
(:6588-6628); "allow"/"deny" are once-only.
Zed's rule model is still worth mining (docs/src/ai/tool-permissions.md): precedence is *built-in security rules →
always_deny → always_confirm → always_allow → per-tool default → global default* (:120-127); the only
non-overridable rules are recursive deletes of /, ~, $HOME, ., .. in the terminal tool, matched
case-insensitively against the raw command and each parsed sub-command of a chain (:176-190); the menu offers
"Allow once / Deny once", "Always for <tool>", and "Always for <pattern>" only "when a safe pattern can be
extracted", MCP tools getting tool-level only (:191-203).
D.6 The card is inline, force-expanded, and never modal. Appended to the tool-call body
(thread_view.rs:8671; terminal variant :8002-8015), with is_open |= needs_confirmation and `is_collapsible =
has_content && !needs_confirmation (:8211-8216) — so it cannot be collapsed away. Flat rendering (:9801-9911`)
is a vertical stack in the agent's exact order, unfiltered:
div().p_1().border_t_1().w_full().v_flex().gap_0p5().children(options.iter()…) (:9814-9820), label = option.name
verbatim.
| kind | icon | colour | action | cite |
|---|---|---|---|---|
AllowOnce |
Check |
Success | agent::AllowOnce |
9840-9845 |
AllowAlways |
CheckDouble |
Success | agent::AllowAlways (none for id allow_thread) |
9846-9857 |
RejectOnce |
Close |
Error | agent::RejectOnce |
9858-9863 |
RejectAlways and any unknown kind |
Close |
Error | none | 9864-9869 |
id SANDBOX_FALLBACK_RETRY_OPTION_ID |
RotateCcw |
Muted | none | 9828-9838 |
No per-kind button style — no filled danger button, no primary; icon and icon colour carry it all.
Nothing is focused or auto-focused: the focus_handle is the ThreadView's and is used only to resolve
keybinding badges (:9893), so there is no "Return accepts the default". A badge is drawn only on the
first pending call and only once per kind (seen_kinds ArrayVec, :9885-9895); is_first = head of a
global FIFO queue (:8075-8088).
Keymap (context "AcpThread", assets/keymaps/, read via git show): macOS cmd-y AllowOnce, cmd-alt-y
AllowAlways, cmd-alt-z RejectOnce, cmd-alt-a OpenPermissionDropdown; Linux shift-alt-a / shift-alt-q /
shift-alt-x / ctrl-alt-a. Actions at agent_ui.rs:268-298; handlers thread_view.rs:2504-2540. For a Flat list
the keyboard path resolves via first_option_of_kind(AllowOnce | RejectOnce) (conversation_view.rs:518-560) — **the
shortcut picks the first option of that kind whatever its optionId**. permission_option_for_action (:505-521)
additionally prefers an option whose id equals SandboxPermission::AllowAlways.as_id() ("allow_always",
acp_thread.rs:135-162) for the AllowAlways action.
Escape does not dismiss the card — it cancels the turn. menu::Cancel on the AcpThread element →
cancel_generation → AcpThread::cancel (thread_view.rs:12152-12159, 1972-1979). Correct, because there is no
third option: the agent is blocked until answered.
A floating duplicate appears when the card scrolls off-screen.
render_main_agent_awaiting_permission (:3554-3632) returns None while the entry is visible and otherwise
re-renders the same buttons above the editor in ToolCallLayout::Floating (max_h_40().overflow_y_scroll(),
:8660-8668) with a sand spinner, header "Awaiting Confirmation" / "…(N)", and a "Scroll" button that jumps
to the entry (:3592-3631); subagents get "Subagents Awaiting Permission:" (:3456-3520). This is the fix for the
worst failure mode of an inline permission UI — the user not knowing the agent is blocked.
A homograph gate worth stealing. Allow buttons are disabled while a surprising-Unicode warning is
unacknowledged: disabled = allow_disabled && is_allow where `allow_disabled =
sandbox_confusables_block_allow(tool_call, cx) (:9871-9880, 8005, impl :9034-9048, banner :9053+`,
agent_ui/src/unicode_confusables.rs). Deny and Retry stay enabled, and the **keyboard shortcuts check the same
gate** (:2520-2536) so they cannot bypass the banner.
Caveat:
Flatis not exclusively the external shape — Zed's own agent emits it for sandbox prompts, whichis why the flat renderer carries native-only id special cases. A third-party agent's ids never match, so
every option gets the plain kind-based treatment.
D.7 Tool-call, diff and terminal cards.
ToolKind → icon (thread_view.rs:10010-10021): Read|Search → ToolSearch, Edit → ToolPencil, `Delete →
ToolDeleteFile, Move|SwitchMode → ArrowRightLeft, Execute → ToolTerminal, Think → ToolThink, Fetch → ToolWeb`,
Other | _ → ToolHammer. Three overrides run first (:9971-10009): an Edit with exactly one location uses the
language file icon; that case when failed and the diff was revealed adds a warning triangle tooltipped
"Interrupted Edit"; a subagent call uses the agent icon.
Status → visual is deliberately minimal: no per-call spinner, no per-call checkmark — the thread-level spinner
carries running state (:7341-7385, SpinnerVariant::Sand + "Awaiting Confirmation" when a permission is pending,
Dots otherwise). The only glyph is failure — Close/Error, or XCircle/Muted + "Interrupted Edit" for a cancelled
edit with no revealed diff (:8531-8554) — plus a dashed card border (:7965, 10393). A Rejected call renders
no body (:8432); a Canceled call with no visible content
is hidden entirely (:6396-6405). Subagent cards do get icons (:10633-10662).
Collapse state is a HashSet<ToolCallId> (entry_view_state.rs:50, 76-91) — **default collapsed, and a failed or
running call does not auto-expand**. Two settings-gated auto-expands on view events (thread_view.rs:1268-1286):
NewDiff if expand_edit_card, NewTerminal if expand_terminal_card; TerminalMovedToBackground auto-collapses.
The chevron is hover-only (:8502-8530). There is no line limit or truncation of tool output in the UI — the only
caps are a 200-char title elide, an 8-entry subagent preview, max_h_64 on thinking, max_w_96/max_h_96 on images,
max_h_40 when floating.
rawOutput is never rendered (zero hits in crates/agent_ui/src/; it exists only for the refusal gate,
acp_thread.rs:3842). rawInput is shown twice, gated by `should_show_raw_input = !is_terminal_tool && !is_edit
&& !has_image_content (:8205`): behind a second disclosure "View Raw Input"/"Raw Input:" while awaiting confirmation
(:8268-8335), and inside expanded output as "Raw Input:" then "Output:" (:8351-8371). Rendering is
markdown_for_raw_output (acp_thread.rs:4721-4762): scalars bare, objects/arrays as a pretty-printed `json fence.
Diff cards are a real read-only Editor over a MultiBuffer with full Tree-sitter highlighting
(entry_view_state.rs:655-700: EditorMode::Full{SizeByContent}, no gutter/diagnostics/minimap, read-only,
set_expand_all_diff_hunks, HiddenUnstagedDiffHunkRenderer, text at TextSize::Small). Model side
(acp_thread/src/diff.rs:18-83): Diff::finalized builds a MultiBuffer::without_headers, loads the language for
the path, and installs only the hunk ranges as excerpts with excerpt_context_lines of context;
Diff::needs_update compares base text and rope contents (:192-210) so an unchanged diff is not rebuilt — which is
what update_from_acp consults before replacing a diff entity (acp_thread.rs:1866-1877). The inline card has **no
keep/reject buttons** and appears only once has_revealed_range is true (thread_view.rs:10377-10385); review lives
in AgentDiff — per-hunk Reject/Keep with keybinding badges (agent_diff.rs:812-858), toolbar Reject All/Keep All
(:1138-1168), actions agent::{Keep, Reject, KeepAll, RejectAll, UndoLastReject} (agent_ui.rs:259-268), macOS
cmd-y/cmd-alt-z/shift-alt-y/shift-alt-z. Stats render as green + n / red ‒ n per file and as a whole-turn
rollup (thread_view.rs:3310, 4153).
Terminal cards. TerminalToolHeader (agent_ui/src/ui/terminal_tool_header.rs) shows: working dir with
.truncate_start() so long paths elide from the left (:145-153); hover-only disclosure; elapsed time
only past 10 s (ELAPSED_DISPLAY_THRESHOLD, :7, gate :108-110); a rotating LoadCircle plus a red
Stop button tooltipped "Stop This Command" with the note *"Also possible by placing your cursor inside the
terminal and using regular terminal bindings"* (:172-197); a truncation Info icon (:198-207); exit code as
Close/Error, "Exited with code {code}" (:208-219); a sandbox LockOff warning (:220-239). The command arrives
via .command_slot, not owned by the header. Body (thread_view.rs:7820-8016): the **live terminal renders only when
expanded* (:7971), in h_72() when scrollable; the card is *not shown as running while awaiting confirmation
(:7911); Stop calls terminal.stop_by_user(cx) then, if cancel_generation_on_terminal_stop, cancels the whole
turn (:7924-7934). Truncation copy, verbatim (:7889-7906) — note "the first", Zed's head-retention leaking
into the UI (§E.3, §M1):
"Output exceeded terminal max lines and was truncated, the model received the first {size}."
"Output is {orig} long, and to avoid unexpected token usage, only {sent} was sent back to the agent."
D.8 Plan, usage, modes, config options, slash commands — full detail in the companion file; the
load-bearing points only:
Plan. update_plan (acp_thread.rs:3577-3600) is a positional replace with entity recycling, the same
trick as C.4; Plan::stats() counts InProgress toward pending (:1969-1995). Three further mutations:
clear_completed_plan_entries at the start of each turn (:3609, from run_turn :3739); at the end of a
non-cancelled turn a non-empty plan with pending == 0 is mem::taken into the transcript as CompletedPlan
(:3602); clear_plan() for the dismiss button. Spec: "The Agent MUST send a complete list … **The Client MUST
replace the current plan completely" [docs]** /v1/agent-plan — and entries have
no id, so full replace is the only correct implementation. UI: a sticky activity bar above the editor,
collapsed by default (thread_view.rs:603, 1018, 3084-3195); icons TodoProgress/Accent with a 2 s
rotate, TodoComplete/Success, TodoPending/Muted (:3830-3852); strikethrough only for Completed;
priority is stored and never rendered.
Usage. usage_update is an absolute snapshot for size/used, and cost is sticky — replaced only
when present (acp_thread.rs:2639-2651). TOKEN_USAGE_WARNING_THRESHOLD = 0.8 drives
TokenUsageRatio::{Normal,Warning,Exceeded} (:2028-2052), but the ring itself breaks at 0.85 with no red state
(thread_view.rs:4696-4702); 16 px ring, 2 px stroke, in the editor toolbar (:4682-4866). Percentage and cost are
tooltip-only; cost is "{amount:.2} {currency}", 4 decimals under $0.01 (:4687-4694) — a trailing ISO code, not
a symbol. The token-limit callout is explicitly suppressed for external agents (:11826).
Modes vs config options are mutually exclusive (conversation_view.rs:1313-1350): if the connection reports
session_config_options, the mode and model selectors are set to None. Spec agrees — "Clients
SHOULD use them instead of the modes field. Modes will be removed in a future version" [docs]
/v1/session-config-options. Neither view caches state: the update handler only calls cx.notify() and the render
re-reads the connection (:1868-1871). Both change paths are two-step (persist default, then send) and
errors are only log::error!d — never surfaced, never rolled back (mode_selector.rs:65-84,
config_options.rs:776-807). select is a picker (searchable at ≥5), boolean a Switch, and **any other type
renders an empty div** (config_options.rs:445-608).
Slash commands. available_commands_update replaces the list wholesale (acp_thread.rs:2624-2631); invocation
is the spec's — the text rides a normal session/prompt [docs] /v1/slash-commands. Zed's trigger is **not
leading-/ only**: it scans right-to-left for a / at column 0 or preceded by whitespace, so "Lorem /help"
completes (completion_provider.rs:2022-2071); a command with input: Some requires an argument and merely inserts
text, one without submits immediately (:1595-1610); category grouping comes from a _meta key
("native"/"mcp", acp_thread.rs:85-121) that external agents never send; bare /login//logout are
intercepted client-side (thread_view.rs:1505-1537).
D.9 OS notifications. agent_ui/src/ui/agent_notification.rs is a borderless GPUI popup, not an
OS-native notification: 450×72, top-right of the target display, WindowKind::PopUp, focus: false, transparent
(:32-67), with View and Dismiss buttons. Five triggers, all in handle_acp_thread_event
(conversation_view.rs): ToolAuthorizationRequested → "Waiting for tool confirmation" (:1665);
ElicitationRequested → "Waiting for input" (:1669); turn finished → "Finished running tools" or "New message"
(:1729); Refusal (:1742); Error → "Agent stopped due to an error" (:1757). Gating (:2907-2990): **at most
one at a time* (:2914); suppressed entirely when the window is active *and the panel shows this conversation
(:2879-2891); notify_when_agent_waiting selects PrimaryScreen/AllScreens/Never with request_attention() on
the non-Never paths; turn-finished is suppressed when a queued message was just auto-sent (:1725). The
suppress-when-visible rule is what decides whether the app feels attentive or naggy.
E.1 Zed says yes to everything (client_capabilities_for_agent, acp.rs:767-795):
fs{read_text_file:true, write_text_file:true}, terminal(true), auth{terminal:true},
session.config_options.boolean, elicitation{form, url}, plus _meta keys "terminal_output": true and
"terminal-auth": true (and "parameterizedModelPicker" for Cursor only, :773-775). Contrast fazm, which declines
fs entirely. terminal(true) means Zed owns the PTY for every command the agent runs. The nine agent→client
handlers it registers (acp.rs:707-750): session/request_permission, fs/{write,read}_text_file,
terminal/{create,kill,release,output,wait_for_exit}, elicitation/create, plus notifications session/update and
elicitation/complete. Spec: session/request_permission is the
only baseline client method; everything else is capability-gated [docs].
E.2 terminal/create does not exec the command. handle_create_terminal (acp.rs:4972-5030) →
create_terminal_entity (terminal.rs:615-666): resolve the directory environment for cwd; then
rustenv.insert("PAGER".into(), "".into());
// Override user core.pager (e.g. delta) which Git prefers over PAGER
env.insert("GIT_PAGER".into(), "cat".into()); // terminal.rs:670-675
then apply the request's env array on top (agent wins, :622); pick the shell
(get_default_system_shell_preferring_bash(), :627-634); and wrap: `ShellBuilder::new(&shell,
is_windows).redirect_stdin_to_dev_null().build(Some(command), &args) (:636-638`), which quotes each arg, joins them,
and prefixes exec </dev/null\n for POSIX shells — the comment explains the newline: *"so that it is already active
if the command contains a syntax error. Otherwise, with -i, dash will fall back to an interactive shell"*
(shell_builder.rs:100-109). Finally a real PTY task (:640-652). So {command:"ls", args:["-la"]} becomes
/bin/bash -c 'exec </dev/null\nls -la'. A Swift host doing Process() on the raw command will behave differently
for shell syntax and hang on the first pager. AcpThread::create_terminal (acp_thread.rs:4387-4535) is the
richer path used by Zed's own agent: same steps plus optional Seatbelt sandbox wrapping (terminal.rs:363) and a
headless branch that drops the PTY because *"Headless hosts have no controlling TTY, so PTY setup fails with
ENOTTY"* (:4417-4420).
E.3 Truncation — Zed contradicts the spec.
rustif let Some(limit) = self.output_byte_limit && content.len() > limit {
let mut end_ix = limit.min(content.len());
while !content.is_char_boundary(end_ix) { end_ix -= 1; }
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix); // don't truncate mid-line
content.truncate(end_ix); // keeps [0..end_ix] = the HEAD
} // terminal.rs:560-579
Spec: *"Once exceeded, earlier output is truncated… the Client truncates from the beginning of the output… The
Client MUST ensure truncation happens at a character boundary"* [docs] /v1/terminals. Zed honours the
character-boundary MUST (and snaps to a line boundary too) but retains the wrong end. For a build log that is the
difference between seeing the compiler banner and seeing the errors. §M1. current_output (:540-558) sets
truncated = original_content_len > content.len().
E.4 Exit, kill, release. _output_task (:471-509) awaits the PTY, snapshots
TerminalOutput { ended_at, exit_status, content, original_content_len, content_line_count }, releases PTY resources
and tears the sandbox down on a background thread. It is a Shared<Task<…>>, so
multiple terminal/wait_for_exit calls are safe and all resolve identically (:518-520). stop_by_user
sets an AtomicBool before killing so awaiting code can distinguish a user stop from a natural exit (:530-538) —
the wire has no field for this. kill_terminal keeps the entry in the map; release_terminal removes and kills
(acp_thread.rs:4543-4571), matching *"After release the terminal ID becomes invalid for all other terminal/*
methods" — while the entity survives inside any ToolCallContent::Terminal that referenced it, satisfying "the
client SHOULD continue to display its output after release"* [docs]. Shape trap: terminal/output nests
{exitStatus:{exitCode,signal}}; terminal/wait_for_exit returns {exitCode, signal} flat [docs] schema/v1.
E.5 Out-of-order events. ToolCallContent::from_acp errors if the terminal id is unknown
(acp_thread.rs:1841-1845), so AcpThread keeps pending_terminal_output and pending_terminal_exit maps
(:2109-2110) drained in the Created arm (:4658-4676). Three tests pin it (§K).
E.6 fs/read_text_file (:4212-4288): 1-based → 0-based (line.saturating_sub(1), :4220, matching
the spec's "Line numbers are 1-based"), limit defaults to u32::MAX. Reads through project.open_buffer — i.e.
the unsaved buffer, which is the method's stated purpose: *"These methods enable Agents to access unsaved editor
state"* [docs] /v1/file-system. Every read is logged to the ActionLog and its snapshot cached in
shared_buffers (:4256-4263) so a later write diffs against what the agent saw. Past-EOF is an explicit
invalid_params with the real extent (:4266-4272); a missing path is resource_not_found = -32002
(:4234-4236). The "agent location" cursor moves so the user follows along — but only for the root session
(parent_session_id.is_none(), :4225), so a subagent cannot yank the viewport.
E.7 fs/write_text_file is not a file write (:4292-4385): open the buffer, take the cached snapshot,
compute text_diff(old, new) on a background thread, map to anchor ranges, apply as one transaction tagged
BufferEditSource::Agent, log buffer_edited, run format-on-save if configured, then save_buffer. Applying a
diff is what preserves cursor, folds, selection and undo history; try content.write(to:) throws all of that away.
Spec: path "MUST be absolute. The Client MUST create the file if it doesn't exist."
F.1 Never exec the binary directly.
rustlet builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive();
let mut child = builder.build_std_command(Some(path.clone()), &args);
child.envs(env.clone());
if let Some(cwd) = /* local projects only */ { child.current_dir(cwd); }
let mut child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?; // acp.rs:849-860
Shell::System = $SHELL or /bin/sh; .non_interactive() drops -i, giving `["$SHELL","-c","<quoted path>
<quoted args>"] (shell_builder.rs:36-39, shell.rs:359-380`). Consequences: nvm/mise/asdf/homebrew shims resolve;
a missing binary is exit 127 from the shell, arriving as LoadError::Exited{status, stderr} rather than a spawn
error (acp.rs:242-247, 957-974; regression test startup_returns_error_when_agent_exits_before_initialization,
:3769). cwd is the first ordered project path, set only for local projects (:852-860) — Zed sets it on both
the process and per session, where fazm sets it only per session.
F.2 Command resolution. Seam = ExternalAgentServer (agent_server_store.rs:117-143) with four impls,
all producing AgentServerCommand { path, args, env } (:34-41, serde renames path → "command"). The registry
file is registry.json, not agent.json: https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json,
1 h throttle, 30 s fetch timeout (agent_registry_store.rs:20-25), cached at
external_agents_dir()/registry/registry.json; entries carry `distribution = { binary?: {"<os>-<arch>":
{archive,cmd,args,sha256?,env}}, npx?: {package,args,env} } (:628-677`), binary preferred only when the platform is
present (:439-450).
Zed never runs npx — it npm installs into a per-agent dir with managed Node, reads package.json's
bin, and execs node <resolved entry> (agent_server_store.rs:1387-1421); the version pin is a ceiling range
pkg@0.0.0 - <version>, not <=, because Windows npm/PowerShell strips the quotes (:1436-1468). Managed Node:
const VERSION: &str = "v24.11.0", bin/node / bin/npm (node_runtime.rs:605-616), installed to
data_dir()/node/node-{version}-{darwin|linux|win}-{x64|arm64} from nodejs.org, health-checked by `node <npm>
--version with private cache and blank npmrcs, wiping and re-downloading on failure (:621-725). A registry cmd ==
"node" is **rewritten to managed Node**; anything else must be a ./`-relative path inside the extracted archive,
with .. rejected (:1284-1302) — that rewrite is what breaks packages whose bin is a native Mach-O [issue]
zed#62716, which is exactly @zed-industries/codex-acp's shape. Custom agents get shellexpand::tilde and **no
existence check** (:1476-1497, 1637).
F.3 Env layering (later wins). npx: `project shell env → npm_command_env → distribution.npx.env →
extra_env → settings agent_servers.<id>.env (:1380-1421`); archive the same with
distribution.binary.<platform>.env; custom puts extra_env last (:1485-1493). The project shell env is
harvested by running a login + interactive shell in the worktree and re-execing Zed with --printenv to a private
fd (environment.rs:173-250, shell_env.rs:107-178, itself setsid-detached at :154). Proxy: HTTP(S)_PROXY plus
a NO_PROXY fallback of "localhost,127.0.0.1" explicitly so local MCP servers aren't proxied
(agent_servers.rs:113-135).
Per-agent injections, complete (custom.rs:229-253, ids at :17-20):
| id | injection |
|---|---|
claude-acp |
ANTHROPIC_API_KEY="" — blanked, not removed, so the CLI falls back to its own subscription auth |
codex-acp |
forwards CODEX_API_KEY and OPEN_AI_API_KEY from Zed's own env if set |
gemini |
SURFACE=zed, plus GEMINI_API_KEY from GEMINI_API_KEY → GOOGLE_AI_API_KEY → keychain (:288-303) |
| any | NO_BROWSER=1 when the client lacks WSL interop (:226-228) |
Nothing is ever unset — child.envs(env) merges over Zed's inherited environment; no env_clear().
(fazm by contrast deletes CLAUDECODE so the nested-session guard doesn't break --resume.)
F.4 Supervision. setsid() in pre_exec (util.rs:405-419); kill is
killpg(pid, SIGKILL) (process.rs:114-121) driven from impl Drop for AcpConnection (acp.rs:1528-1534). **On
Windows only**, a job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE kills the tree even on a crash
(process.rs:56-102); on macOS there is no equivalent — no kill_on_drop, no SIGTERM grace, no reaper. That is
zed#61303 (55 orphaned processes / 3.1 GB) [issue]. stderr is line-read for the process lifetime, logged as `agent
stderr: {line}, and stored in a 2000-entry ring (acp.rs:913-928, 48, 201-218); trailing_stderr()` extracts only
the contiguous trailing block so a crash callout shows the last error (:220-239).
There is no automatic respawn — recovery is a "Retry" button and a "Reload Agent" menu item through
AgentConnectionStore::restart_connection (conversation_view.rs:983-990, 2748-2758;
agent_connection_store.rs:127-141). Process exit is raced against startup twice, with a 250 ms grace after an
initialize error so the richer exit error wins (acp.rs:1002-1021); afterwards _wait_task fans
LoadError::Exited to every live session (:1027-1034, 1510-1526).
F.5 Transport, and the stack-size trap. Newline-delimited JSON-RPC via
agent_client_protocol::Lines, tee'd both ways into the debug log (acp.rs:888-911). Framing, request ids and
$/cancel_request live inside the crate — the literal string does not appear in Zed's tree. The IO future runs on a
dedicated thread:
"…in unoptimized builds its dispatch chain needs ~0.5 MiB of stack per inbound message, which overflows the
fixed 512 KiB stacks of the GCD workers that poll background tasks on macOS, crashing dev builds as soon as
an agent sends its first message." (
acp.rs:930-949, restated:665-677)
Swift inherits the same 512 KiB non-main-thread default — run the reader on a Thread with an explicit stackSize. A
second bridge pushes handler work onto an mpsc drained by a foreground task (:284-394, 976-989); the Swift analogue
is a MainActor handler queue fed from the reader thread.
Malformed input is never fatal: non-JSON lines dropped (:94-96), unparsable ids warn and skip
(:122-138), undeserialisable error objects degrade to internal_error (:140-150), read errors log and continue
(:893-895), JSON-RPC batch arrays are split per entry (:99-104), unknown-session notifications warn and drop
(:4824-4830), unknown-session requests answer "unknown session: {id}" (:4551-4560), and every outgoing error is
logged with its method first (:4562-4571).
All three creation requests share a shape (acp.rs:1446-1470): cwd = first ordered project path,
additionalDirectories = the rest but only when sessionCapabilities.additionalDirectories is advertised
(otherwise dropped, :1473-1492, 1722-1727), and mcpServers. An empty path list errors `"Working directory cannot
be empty". Spec: session/new **requires** mcpServers (send []); session/resume` makes it optional; every
additional dir must be absolute and must be re-sent on every load/resume — "omitting the field or providing an empty
array does not restore stored roots implicitly"
[docs] /v1/session-setup.
load vs resume differ in who replays. Spec: on session/load the agent "MUST replay the entire
conversation … in the form of session/update notifications" and respond only "when all the conversation entries
have been streamed"; on session/resume it "MUST NOT replay the conversation history … before responding"
[docs]. That replay is why Zed keeps sessions and pending_sessions side by side (`acp.rs:401-402,
1166-1301) — updates arrive *before* the response — and reference-counts the lifecycle so a session/close` racing an
in-flight load fails it with "session was closed before load completed" (:1817-1881). Replay and live streaming
share one handler (handle_session_notification, :4816+), so the UI cannot tell them apart. A client that registers
the session on the response loses the entire transcript.
Selection is a three-way branch (conversation_view.rs:1131-1165): supports_load_session() → load_session; else
supports_resume_session() → resume_session with resumed_without_history = true; else error `"Loading or resuming
sessions is not supported by this agent."` The resumed case renders a callout — *"Resumed Session … This agent does
not support viewing previous messages. However, your session will still continue from where you last left off."*
(thread_view.rs:11486-11496, 12137).
Capability gates all read off InitializeResponse.agentCapabilities (acp.rs:1102): loadSession (:1711),
sessionCapabilities.resume (:1715), .additionalDirectories (:1722), .close (:1813), .list/.delete
(:1045-1065), auth.logout (:1934). session/fork is a draft RFD, not in the v1 schema, and one of the five
features behind the crate's unstable flag [docs] /rfds/session-fork —
Zed does not implement it (no fork_session on AgentConnection, connection.rs:91-260). fazm does, at
its bridge layer.
Post-creation Zed fires up to two more requests: session/set_mode for the configured default (optimistic, rolled
back on failure; an unknown mode only warns with the available list, :1624-1672) and session/set_config_option per
default, validated against the advertised options (:1303-1436).
Host-side persistence: a draft_prompt: Option<Vec<ContentBlock>> and ui_scroll_position
(acp_thread.rs:2114-2118); git checkpoints per user message (:294-308, 3672-3685, 4062-4179) with
restore_checkpoint/rewind (:3983-4060) — Zed's answer to what every other host does with worktrees; title +
provisional_title (:2091-2092). Import enumerates an agent's own session/list and adds missing ones as
archived metadata rows only — history loads lazily on open (thread_import.rs:352-387, 855); sessions without a
working directory are skipped and re-import is idempotent [docs] docs/src/ai/external-agents.md:181-190.
mcp_servers_for_project (acp.rs:4397-4443) is the whole of it — and the identical list goes to session/new,
session/load and session/resume (:1612, 1743, 1787).
| Fact | Detail |
|---|---|
| Source | Only Zed's configured context servers. name = the context-server id. |
| stdio | {name, command, args, env:[{name,value}]} — acp::EnvVariable, an array of objects; schema requires all four fields (:4415-4424) |
| http | {type:"http", name, url, headers:[{name,value}]}; timeout and oauth silently discarded (:4429-4437) |
| sse | never emitted (exists in schema, deprecated by MCP) |
| Remote projects | stdio skipped unless the config sets remote: true (:4415) |
| Zed's own tools | not injected — externals get Zed's fs and terminal through ACP client methods, not MCP |
Spec: "All Agents MUST support the stdio transport"; http/sse are capability-gated; "Agents SHOULD connect to
all MCP servers specified by the Client" [docs] /v1/session-setup. Zed's doc hedges: "check
both Zed's MCP server configuration and the agent's native MCP configuration"
(docs/src/ai/external-agents.md:194-198).
This is the injection point for SnappyOS. One
mcpServersentry — stdio, or http against
localhost:3147— gives Claude, Codex and Gemini the Snappy toolset with zero per-agent work, and rides apath all three already implement (fazm instead built a bespoke unix-socket tools server per agent). Mind
the two gaps Zed left: v1
McpServerHttphas nooauth/timeoutat all, so an authenticated server needs
_metaor stdio; andenv/headersare arrays of{name,value}, not dictionaries.
The error code is -32000 ("Authentication required", schema/v1 ErrorCode). The string
"auth_required" is documentation shorthand — it appears as a literal wire value only in a draft RFD, as
error.data.reason. Match the numeric code. Zed never writes 32000; it keys off the typed enum and drops a
generic message while surfacing a specific one:
rustif err.code == acp::ErrorCode::AuthRequired {
let mut error = AuthRequired::new();
if err.message != acp::ErrorCode::AuthRequired.to_string() { error = error.with_description(err.message); }
anyhow!(error)
} else { anyhow!(err) } // acp.rs:2074-2086
Three login paths. (1) authenticate in-band — a thin passthrough (:1924-1932); the UI renders one
button per advertised method, reverse order, first primary, description as tooltip
(conversation_view.rs:2254-2360); logout gated on auth.logout (:1934-1950). (2) Terminal auth — Zed
advertises auth.terminal = true (:782) and re-runs the agent command with the method's args/env as a visible
terminal task (connection.rs:69-89, id external-agent-{agent}-{method}-login, :1536-1538). Spec: "The terminal
process is not the ACP connection, so the Client MUST NOT send an authenticate request for a terminal method";
"A zero exit status signals success" [docs] /v1/authentication. Note AuthMethodTerminal.env is a map, unlike
every other env in the schema. (3) A legacy _meta["terminal-auth"] = {label, command, args, env} shim "to support
the _meta method prior to stabilization" (:1554-1586).
Success detection is screen-scraping, per agent:
rustlet success_patterns = match method.0.as_ref() {
"claude-login" | GEMINI_TERMINAL_AUTH_METHOD_ID => vec!["Login successful", "Type your message"],
_ => Vec::new(), // conversation_view.rs:2161-2167
};
With no patterns Zed waits on exit code; with patterns it polls terminal content once a second and kills the task on a
match (:2168-2237). Gemini's auth method is fabricated by Zed, not the agent — it replaces
response.auth_methods with a synthetic spawn-gemini-cli terminal method whose args strip
--experimental-acp/--acp, i.e. re-launching gemini without ACP so its own /auth runs (acp.rs:1067-1085,
GEMINI_TERMINAL_AUTH_METHOD_ID = "spawn-gemini-cli", :46).
Posture to copy, from Zed's own doc: "External Agents run through their own process and provider relationship.
Billing, legal terms, retention, and data handling are between you and the agent provider… An Anthropic API key
configured for Zed Agent does not automatically configure Claude Agent" (`docs/src/ai/external-agents.md:11-13,
43-47`). The only key Zed hands over is Google's.
Quota is not handled for external agents. ThreadError's RateLimitExceeded/PaymentRequired/etc.
(conversation_view.rs:123-161) are reachable only by downcasting to LanguageModelCompletionError — Zed's
native path (:173-227); an ACP agent's error falls through to Other { message, acp_error_code }
(:228-240). The one ACP-specific massage is a Gemini hack: an internal_error whose data.details contains `"This
operation was aborted" / "The user aborted a request" is rewritten to StopReason::Cancelled`, but **only when
suppress_abort_err is set by a just-sent cancel** (:1977-2007, flag set at :2012-2018; targets gemini-cli PR
#6656).
Three layers: turn = session/cancel notification (acp.rs:2012-2018); request = $/cancel_request →
-32800, entirely inside the pinned crate, surfacing as responder.cancellation() + run_until_cancelled (used at
:4593, 4607-4626, 4657-4709, 4799-4809, 5122-5133); process = killpg(SIGKILL) on Drop.
cancel_inner (acp_thread.rs:3905-3922), in order: flush the streaming buffer; cancel outstanding elicitations;
return early if there is no running turn; mark_pending_entries_as_canceled; then send the notification; then
await the in-flight prompt task. mark_pending_entries_as_canceled (:3929-3966) flips every ToolCall in `Pending
| WaitingForConfirmation | InProgress to Canceled, feeding respond_tx the outcome, and any InProgress`
ContextCompaction to Canceled. A failed send is logged at debug ("Permission request closed before cancellation
was delivered") and swallowed. This satisfies both spec MUSTs — answer every pending permission with cancelled, and
"preemptively mark all non-finished tool calls … as soon as it sends the session/cancel notification" [docs] —
by doing it before the send.
Late updates are accepted, by simply not filtering them: test_succeeding_canceled_toolcall (6260)
drives in_progress → cancel → asserts Canceled → tool_call_update{completed} → asserts Completed. Spec: "The Client
SHOULD still accept tool call updates received after sending session/cancel."
run_turn (:3734-3899) wraps every turn. A new prompt cancels the previous one with InterruptedByFollowUp and the
new send_task awaits that first (:3742-3752). A turn_id guard (is_same_turn) stops a superseded turn's late
response from flipping the UI (:3768-3781), and a dropped tx still clears running_turn so the panel exits
"generating" (:3775-3790). stopReason handling (:3793-3884): end_turn → snapshot the plan, emit Stopped;
max_tokens → Err(MaxOutputTokensError), i.e. an error, not a completion; cancelled → cancel pending entries
and skip the plan snapshot; refusal → if a completed tool call with raw_output exists after the last user
message the refusal is about tool output (just emit Refusal), otherwise **truncate the entry list back to before
that user message** and emit EntriesRemoved + Refusal (:3822-3856); max_turn_requests → no special case.
Spec's StopReason set is exactly those five [docs] /v1/prompt-turn.
There are no timeouts on the wire — none on initialize, session/new, prompt or anything else. The
only timers in the ACP path are the 250 ms post-initialize-error grace (acp.rs:1006-1009), a 60 s WSL sandbox wrap
(terminal.rs:353) and registry HTTP fetches (30 s / 10 s). A hung agent hangs the thread until the user stops it or
the process dies.
LoadError (acp_thread.rs:2228-2262) = `Unsupported{command,current_version,minimum_version} | FailedToInstall |
Exited{status, stderr} | Other; the stderr` is the trailing contiguous block, which is what makes a crash
actionable. AcpThreadEvent (:2154-2178) is the 21-variant UI vocabulary — `StatusChanged, NewEntry,
EntryUpdated(usize), EntriesRemoved(Range), ToolAuthorizationRequested/Received, Retry(RetryStatus),
Stopped(StopReason), Error, LoadError, Refusal, SubagentSpawned, …`. Mirror it one-for-one; the index-based
EntryUpdated/EntriesRemoved is what lets a list do incremental updates. `RetryStatus{last_error, attempt,
max_attempts, started_at, duration, meta} (:2062-2071`) exists but is only populated when the connection implements
AgentSessionRetry — the stock AcpConnection does not.
~120 tests in acp_thread.rs mod tests (from 4765) are the executable spec. Line numbers are the async fn. Build
the Swift equivalent of FakeAgentConnection (:8760-8958, with on_user_message(closure),
without_truncate_support(), with_auth_methods(…)) first — every oracle below depends on it.
| Group | Tests → what is pinned |
|---|---|
| Chunk folding | test_push_user_content_block 5448 (append, don't create) · …user_message_chunks_use_protocol_message_id_boundaries 5521 / …assistant_chunks_… 5687 (id splits, absent id merges) · …protocol_user_chunk_does_not_merge_into_optimistic_prompt 5624 · test_thinking_concatenation 5800 · …ignore_echoed_user_message_chunks_during_active_turn 5937 · test_send_command_does_not_echo_user_message 5866 |
| Tool calls | test_tool_call_not_found_creates_failed_entry 8961 · test_succeeding_canceled_toolcall 6260 · test_tool_call_location_resolves_external_file 6352 · test_no_pending_edits_if_tool_calls_are_completed 6837 · …preserves_embedded_text_resource 4893 / …renders_embedded_image_blob_resource 4956 / …falls_back_for_non_image_blob_resource 4995 · text_resource_markdown_uses_mime_type_for_code_blocks 4856 |
| Permissions | …duplicate_tool_call_update_preserves_open_permission_request_until_authorized 6402 (repeated non-terminal updates keep the card open, content replaced each time) · …permission_request_tracks_agent_status_until_resolved 6546 · …sets_waiting_status_on_existing_tool_call 6634 · test_terminal_tool_call_update_closes_open_permission_request 6774 (terminal status ⇒ pending request resolves Cancelled) · test_cancel_tool_call_authorization_resolves_permission_request 6719 |
| Turns / cancel / refusal | …returns_cancelled_response_and_marks_tools_as_cancelled 9699 · test_follow_up_message_during_generation_does_not_clear_turn 9475 · …stale_cancelled_response_does_not_cancel_current_compaction 9571 · …running_turn_cleared_when_send_task_dropped 10149 · test_tool_result_refusal 7177 vs test_user_prompt_refusal_emits_event 7276 / test_refusal 7337 · …max_tokens_cancels_pending_session_elicitation 8107 · …prompt_error_cancels_pending_session_elicitation 8052 |
| Filesystem | test_reading_from_line 6072 · test_reading_empty_file 6150 · test_reading_non_existing_file 6226 (→ -32002) · test_edits_concurrently_to_user 5993 (write diffs against the snapshot the agent read) |
| Terminals | …output_buffered_before_created_renders 5071 · …output_and_exit_buffered_before_created 5217 · …exit_preserves_visible_scrollback 5140 · …kill_allows_wait_for_exit_to_complete 5305 · test_restore_checkpoint_kills_terminal 9032 |
| Usage / title | …usage_update_populates_token_usage_and_cost 9909 · …without_cost_preserves_existing_cost 10010 · …response_usage_does_not_clobber_session_usage 10051 · …clearing_token_usage_also_clears_cost 10106 · …context_compaction_preserves_token_usage 9945 · …provisional_title_replaced_by_real_title 9775 · …session_info_update_replaces_provisional_title_and_emits_event 9833 |
| Elicitation | ~25 tests 7455–8726 (form/url modes, duplicate responses, unadvertised modes, non-browser URL rejection, cancel-all) — only relevant if you advertise the capability |
Live conformance harness: agent_servers/src/e2e_tests.rs, six bodies behind the e2e feature, generated
by common_e2e_tests! (:353-402) and run against the real CLIs — test_basic :20, test_path_mentions :51
(a ResourceLink makes the agent read the file), test_tool_call :109, test_tool_call_with_permission :155
(touch … | tee → WaitingForConfirmation → authorize → content contains "Hello"), test_cancel :255,
test_thread_drop :328 (no leaked strong refs). Port run_until_first_tool_call's **20 s timeout that panics on
expiry** (:453-479) too.
Also in agent_servers/src/acp.rs mod tests (2704-4395):
startup_returns_error_when_agent_exits_before_initialization 3769,
cursor_client_capabilities_include_parameterized_model_picker_meta 3016,
client_capabilities_include_{elicitation_without_acp_beta,boolean_config_options} 2721/3042,
terminal_auth_task_builds_spawn_from_prebuilt_command 3055,
legacy_terminal_auth_task_parses_meta_and_retries_session 3084,
first_class_terminal_auth_takes_precedence_over_legacy_meta 3131, trailing_stderr_only_uses_final_stderr_block
3182,
debug_log_records_each_json_rpc_batch_entry 3200 (batch arrays must be split),
session_directories_{use_ordered_paths_when_supported,drop_additional_paths_when_unsupported} 3272/3323,
additional_directories_support_respects_agent_capability 3464, test_close_session_during_in_flight_load 4198,
test_close_during_load_preserves_other_concurrent_loader 4294.
Ordered by cost-of-late-fix; the trailing tag is the evidence section.
protocolVersion: 1; give every wire enum an unknown(String) case — Zed has a fallback at every decode site (acp_thread.rs:1295, 1332, 1845, 2653), and crate 2.0.0 is an SDK version, not protocol 2. §A, §B, §C.5Thread with an explicit stackSize (≥4 MB), not a DispatchQueue — Zed uses spawn_dedicated because GCD's 512 KiB stacks overflow on the first message. §F.5$SHELL -c, not posix_spawn on the binary, and parse exit 127 as "not installed". §F.1/usr/bin:/bin and every agent fails to find node. §F.3setsid() at spawn, killpg(SIGKILL) on teardown — plus the reaper Zed lacks on macOS: a launch-time sweep of stale pgids and a SIGTERM/atexit hook. Zed's omission is zed#61303. §F.4tool_call_update.content as a full snapshot and tool-call text as an accumulated string — never append the array, never treat that text as a delta; only agent_message_chunk is a delta. §C.3, §C.4session/load, or the replayed transcript lands nowhere. §Gcancelled before sending session/cancel, and keep accepting updates afterwards — both spec MUST/SHOULDs, which Zed satisfies by ordering rather than filtering. §Jcurrent_status inside WaitingForConfirmation — that is what lets a pending update leave the card up and a completed one tear it down. §C.5InterruptedByFollowUp): both serialise to cancelled, only one is the user's doing. §D.2optionId. Look options up by kind, and treat an unknown kind as allow, as Zed's _ => arms do. §D.4, §D.6terminal: true you own a PTY, a shell wrapper and pager suppression — PAGER="", GIT_PAGER=cat, exec </dev/null; without them the first git log hangs forever. §E.2outputByteLimit the spec's way — keep the tail, snapped to a character (MUST) and line (nice) boundary. Zed keeps the head. §E.3, §M1fs/write_text_file should apply a diff to an open document, diffed against the snapshot the agent read — keep that snapshot even without an editor surface, to detect conflicts. §E.6, §E.7mcpServers entry, not a bespoke sidecar: one entry reaches all three agents. env and headers are arrays of {name,value}. §H_meta discipline — namespaced keys (_snappy.*) read through tolerant …_from_meta() -> Option<T> helpers; the spec forbids root-level custom fields outright. §C.1FakeAgentConnection before the UI — every oracle in §K depends on it. §Kfs on, terminal off); enable terminals when the PTY view exists. §E.1initialize and session/new and none on session/prompt is the sane consumer-app default. §JM1. Terminal truncation runs the wrong way in Zed. [contradiction, high confidence] Spec: "the Client truncates from the beginning of the output"; Zed's content.truncate(end_ix) keeps the head (terminal.rs:560-579), and its own UI copy says "the model received the first {size}" (thread_view.rs:7889-7906). Follow the spec; worth filing upstream.
M2. current_mode_update's field name. Schema says currentModeId (Zed destructures current_mode_id, acp_thread.rs:2633); the prose example on /v1/session-modes shows "modeId". Trust the schema, decode leniently.
M3. ToolKind::switch_mode is in the schema but missing from the docs list. Claude Code's exit-plan-mode uses it — do not let it hit your default branch.
M4. usage_update's cost semantics are undefined. Absence could mean "unchanged" or "cleared"; Zed chooses unchanged (acp_thread.rs:2645-2651, pinned at test 10010). Adopt it, but the ambiguity is real.
M5. No legal ToolCallStatus transition graph exists in either version — the only ordering rule anywhere is v2's "apply notifications in the order received per toolCallId". Build permissively; do not assert.
M6. stopReason: max_turn_requests has no handler in Zed — it falls through to a plain Stopped (:3793-3884). Whether it should read as an error (like max_tokens) is undecided.
M7. Quota exhaustion has no protocol representation. v1 has no usage-limit error and no plan-quota field; usage_update is context tokens plus optional cost, and Zed's typed rate-limit errors are unreachable for external agents (§I). Open for SnappyOS: where does "you've hit your 5-hour Claude limit" come from? Candidates: agent stderr, adapter _meta, or running Codex's app-server protocol alongside ACP for Codex only. cf. [issue] zed#55501.
M8. Zed does not implement session/fork (§G). If SnappyOS needs it (fazm did), build against the draft shape — session/load params in, session/new response out — gated on sessionCapabilities.fork.
M9. HTTP MCP oauth/timeout are dropped and have no home in v1 (acp.rs:4429-4430); an OAuth-protected MCP server forwarded over ACP fails inside the agent with no diagnostic. Use _meta, or require stdio for authenticated servers.
M10. The node rewrite breaks native-binary npm packages. Zed resolves an npx agent's bin and runs node <bin> (agent_server_store.rs:1387-1421, plus the cmd == "node" rewrite at :1284-1302); @zed-industries/codex-acp ships a Mach-O — [issue] zed#62716. Sniff the resolved bin (shebang vs Mach-O magic) and exec it directly when native, as fazm does by hard-coding the platform package path.
M11. A post-cancel update can resurrect a cancelled call (Canceled → Completed, test 6260). Spec-compliant, but it puts a green check on something the user cancelled — consider a "was cancelled" display flag.
M12. There is no agent restart anywhere — recovery is a manual Retry button; [issue] zed#62828 is the open bug. A consumer app wants supervised restart with backoff, but restart loses the session unless the agent supports session/resume, so restart policy and resume capability are coupled.
M13. Unverified in this pass. Whether wiedymi/swift-acp implements $/cancel_request, per-request cancellation tokens, and JSON-RPC batch splitting — all three Zed gets free from the Rust crate and all three are load-bearing (§F.5, §J). Probe before committing. Also unverified: whether @agentclientprotocol/claude-agent-acp currently emits messageId (Zed's merge rule tolerates either, §B.1), and the current codex-acp npm platform-package layout.
# Lane R1a — Zed as the reference ACP client, and the spec's client-side semantics
2026-09-02. Design input for a **Swift-native ACP host in SnappyOS.app** spawning
`@agentclientprotocol/claude-agent-acp`, `@agentclientprotocol/codex-acp` and `gemini --acp` over stdio via
`wiedymi/swift-acp`. Nothing here is invented; §M lists where Zed and the spec disagree.
Tags: **[src]** Zed working copy · **[docs]** official docs (agentclientprotocol.com, or Zed's own `docs/src` in the
same checkout) · **[issue]** tracker. Goes deeper than
`~/.claude/skills/snappy-agent-host/references/extract-agent-host.md` §D/§B, which is the starting point. Companion
file (same commit, full detail on plan/usage/modes/commands):
`~/.claude/skills/snappy-agent-host/references/zed-plan-usage-modes-commands.md`.
## A. Sources
**[src]** `/Users/robertboulos/projects/cloned-repos/zed` @ `8514ce3ba1ee7a6786e35f6566c7cfeb3c42d3b7`
(2 Sep 2026 15:41 UTC). Shallow + sparse: only `crates/{acp_thread,acp_tools,agent,agent_servers,
agent_settings,agent_ui,node_runtime,project,settings_content,terminal,util}` and `docs/` exist; `assets/` and
`crates/ui` are tracked but not checked out (read via `git show`).
| File | LoC | Owns |
|---|---:|---|
| `crates/acp_thread/src/acp_thread.rs` | 10200 | thread model, merge rules, permissions, cancel, fs, terminals, ~120 tests |
| `crates/acp_thread/src/connection.rs` | 1138 | `AgentConnection` trait = the whole client capability surface; `PermissionOptions` |
| `crates/acp_thread/src/{terminal,diff,mention}.rs` | 761/423/1773 | terminal entity + truncation; multibuffer diffs; @-mentions |
| `crates/agent_servers/src/acp.rs` | 5138 | **the wire client**: spawn, transport, `initialize`, session methods, all agent→client handlers |
| `crates/agent_servers/src/{agent_servers,custom,e2e_tests}.rs` | 135/431/500 | registry vs custom agents; live e2e suite |
| `crates/agent_ui/src/conversation_view{,/thread_view}.rs` | 11227/12974 | permission cards, tool cards, diffs, terminals, plan, usage |
| `crates/acp_tools/src/acp_tools.rs` | 842 | `dev::OpenAcpLogs`, the wire log viewer |
| `crates/project/src/{agent_server_store,agent_registry_store,environment}.rs` | — | command resolution, registry, shell env |
| `crates/util/src/{process,shell_builder,shell_env}.rs` | — | `setsid`/`killpg`, shell wrapping, env harvest |
| `crates/agent/src/thread.rs` | 8885 | Zed's **own** agent — matters only because it owns "always allow" persistence |
| `docs/src/ai/{external-agents,tool-permissions,mcp}.md` | — | Zed's user-facing contract |
**Protocol version, resolved.** `Cargo.toml:521` pins `agent-client-protocol = { version = "=2.0.0",
features = ["unstable"] }`, but the wire version is **1**: `MINIMUM_SUPPORTED_VERSION = ProtocolVersion::V1`
(`acp.rs:663`), `InitializeRequest::new(ProtocolVersion::V1)` (`:993`), reject if `response.protocol_version <
MINIMUM_SUPPORTED_VERSION` (`:1023`), schema imported as `agent_client_protocol::schema::{MaybeUndefined, v1 as acp}`
(`acp_thread.rs:7`). Crates.io confirms `unstable = [unstable_auth_methods, unstable_elicitation,
unstable_end_turn_token_usage, unstable_mcp_over_acp, unstable_session_fork]` and that **`unstable_protocol_v2` is a
separate feature not in that umbrella** **[docs]**. **Crate 2.0.0 ≠ protocol 2. Target `"protocolVersion": 1`.**
Spec source: `https://agentclientprotocol.com/llms-full.txt` (1.44 MB, the entire site in one fetch — use it, not
per-page requests) plus `schema/v1/schema.json` (170 `$defs`). `schema/v2/schema.json` exists; v2 is draft, removes
`fs/*`, all `terminal/*` and session modes, makes updates upserts, and renames `authenticate` → `auth/login`
**[docs]** /protocol/v2/migration. Not needed here.
## B. Data model — what Zed folds each `session/update` into
**One flat `Vec<AgentThreadEntry>`** (`acp_thread.rs:2094`), six variants (`:394-401`):
`UserMessage | AssistantMessage | ToolCall | Elicitation(id) | CompletedPlan(Vec<PlanEntry>) | ContextCompaction`. The
*live* plan, token usage, modes, config options and available commands are thread-level fields, **not** entries
(`:2095-2118`); only a finished plan is snapshotted into the list (`snapshot_completed_plan`, `:3602`).
`handle_session_update` (`:2549-2655`) is the whole dispatch:
| `sessionUpdate` | Zed's fold | Cite |
|---|---|---|
| `user_message_chunk` | append to last `UserMessage` if ids mergeable; **echo-suppressed** (B.2) | `2555-2586` |
| `agent_message_chunk` | merge into last `AssistantMessage`'s last `Message` chunk | `2587-2596` |
| `agent_thought_chunk` | same, into a `Thought` chunk; Message↔Thought flip always starts a new chunk | `2597-2604` |
| `tool_call` | **upsert** — existing id updates in place, new id pushes | `2605-2607`, `3190` |
| `tool_call_update` | field update; **unknown id → synthesised `Failed` entry, no error** | `2608-2610`, `3115` |
| `plan` | **full replace** of `plan.entries` | `2611-2613`, `3577` |
| `session_info_update` | `title` only, only when `MaybeUndefined::Value`; `updatedAt` ignored | `2614-2624` |
| `available_commands_update` | replace list, emit event | `2625-2632` |
| `current_mode_update` | emit `ModeUpdated`; the **connection**, not the thread, stores it | `2633-2636` |
| `config_option_update` | emit `ConfigOptionsUpdated` | `2637-2640` |
| `usage_update` | `max_tokens = size`, `used_tokens = used`; **`cost` only overwritten when present** | `2641-2652` |
| anything else | **`_ => {}` — silent no-op** | `2653` |
Spec confirms 11 variants is the closed v1 set **[docs]** schema/v1. A Swift `Codable` enum needs a default case or it
breaks the day an agent ships a new kind.
**B.1 Chunk merging.** One function decides everything:
```rust
fn can_merge_message_chunks(existing: Option<&MessageId>, incoming: Option<&MessageId>) -> bool {
match (existing, incoming) { (Some(a), Some(b)) => a == b, _ => true } // :383-391
}
```
`messageId` is **advisory** — it only ever *splits*, and an absent id on either side merges. v1 makes it optional (v2
requires it) **[docs]**. Assistant path (`:2770-2860`): text with an active streaming target goes to the smooth-stream
buffer and returns early (`:2779-2790`); otherwise merge into the last chunk if `indented` matches, the
Message/Thought variant matches and ids are mergeable, adopting the incoming id when the existing one is `None`
(`:2810-2826`); a variant or id mismatch pushes a **new chunk in the same entry** (`:2827-2840`); a
non-`AssistantMessage` last entry pushes a new entry (`:2841-2858`).
**B.2 Echo suppression.** Zed pushes the user prompt optimistically before calling `session/prompt`. An
echoed `user_message_chunk` is dropped iff the last entry is an optimistic user message whose `chunks` already
`contains(&content)` and ids are mergeable — and the echo's `messageId` is stolen while dropping it (`:2559-2586`).
The user path adds one extra guard: never merge an optimistic-without-id into a non-optimistic-with-id (`:2721-2724`).
Tests: `…ignore_echoed_user_message_chunks…` (`5937`), `…does_not_merge_into_optimistic_prompt` (`5624`).
**B.3 Smooth streaming.** Text is not appended immediately; `StreamingTextBuffer` reveals it on a 16 ms timer
over a 200 ms target (`:2126-2143`, `TASK_UPDATE_MS = 16`, `REVEAL_TARGET = 200.0`), flushed at every entry boundary,
on cancel, on error and at turn end (`:2944, 3903, 3794, 3884`). This is why Zed types rather than stutters.
**B.4 `ContentBlock`** (`:1346-1363`) → `Empty | Markdown | EmbeddedResource | ResourceLink | Image`. `text`
and `audio` collapse to markdown/empty; `image` is base64-decoded eagerly (`:1487`); an `EmbeddedResource` whose
resource is a blob with an image mime type is *also* decoded as an image (`:1493`), otherwise it becomes a fenced
block whose language comes from the mime type (`text_resource_render_mode`, `:1757`). Spec baseline: agents MUST
support `text` and `resource_link` in prompts; `image`/`audio`/`resource` are gated on `promptCapabilities` **[docs]**
/protocol/v1/content.
## C. Tool calls — state machine and merge rules
**C.1 The struct** (`acp_thread.rs:860-880`). Fifteen fields, of which **five come from `_meta`**, not the
spec: `tool_name`, `subagent_session_info`, `sandbox_authorization_details`, `sandbox_fallback_authorization_details`,
`sandbox_not_applied` (helpers `:69-292`). Plus `raw_input_markdown` (pre-rendered) and `resolved_locations` (paths
resolved to open buffers + anchors). This is the practical answer to "what is `_meta` for": everything the spec has no
field for. Spec: "**Implementations MUST NOT add any custom fields at the root** of a type that's part of the
specification" **[docs]** /v1/extensibility.
**C.2 `tool_call` is an upsert.** `upsert_tool_call_inner` (`:3200-3252`) updates in place when
`index_for_tool_call` finds the id, else pushes. Lookup is a **reverse linear scan** ("typically the last one, or very
close to the end", `:3277-3278`) — no hash map.
**C.3 `tool_call_update` merge, field by field** (`update_fields`, `:951-1114`):
| Field | Rule | Cite |
|---|---|---|
| `kind` | replace | `1119` |
| `status` | `update_acp_status` — **not** a plain assign (C.5) | `1123` |
| `title` | replace, after kind-dependent rewriting (C.6); also pushes into embedded terminals | `1146-1163` |
| `content` | **positional replace-with-reuse, then truncate** (C.4) | `1165-1189` |
| `locations` | wholesale replace, then async re-resolve every path to a buffer | `1191`, `3157` |
| `rawInput` | replace + re-render markdown | `1195` |
| `rawOutput` | replace **and**, *only if `content.is_empty()`*, push a rendered markdown block into content | `1200-1210` |
| `_meta` | each recognised key **merged in, never cleared** — absent key keeps the old value | `1127-1144` |
Matches the schema exactly: `ToolCallUpdate.content` is described as **"Replace the content collection."** and
`locations` as **"Replace the locations collection."** **[docs]** schema/v1. There is no append primitive in v1 —
`tool_call_content_chunk` is a v2 addition **[docs]**.
**C.4 Why the content merge is not a naive replace:**
```rust
let mut new_content_len = content.len();
for (old, new) in self.content.iter_mut().zip(content.by_ref()) {
if !old.update_from_acp(new, …)? { new_content_len -= 1; } // in-place, keeps the UI entity
}
for new in content { if let Some(n) = ToolCallContent::from_acp(new, …)? { self.content.push(n) }
else { new_content_len -= 1 } }
self.content.truncate(new_content_len); // :1165-1189
```
Semantically a replace; implemented index-wise so a live `Markdown`, a `Diff`'s multibuffer or a `Terminal` survives
instead of being recreated — the comment says "reused across snapshots instead of being recreated (which flickers)"
(`:1856-1858`). `new_content_len` decrements for every item that fails to materialise (unknown `ToolCallContent`
variant → `Ok(None)`, `:1845`), so unknown kinds drop without shifting.
**The non-obvious part — tool-call text is a SNAPSHOT, not a delta:**
```rust
match new_content.strip_prefix(¤t) {
Some("") => {} // identical
Some(suffix) => markdown.append(suffix, cx), // grew: append the tail only
None => markdown.reset(new_content.into(), cx), // changed: replace whole
} // :1468-1485
```
An agent streaming tool output over v1 resends the **entire accumulated string** each update. That is the exact
opposite of `agent_message_chunk`, where each chunk **is** a delta. Getting these backwards is the easiest way to
double or truncate text in a new client.
**C.5 Status machine.** Zed's enum is a superset of the wire's (`:1263-1285`): the spec's v1 set is
`pending | in_progress | completed | failed` (**`cancelled` is v2-only** **[docs]**), Zed adds three client-local
states — `WaitingForConfirmation { current_status, options, respond_tx, kind }`, `Rejected`, `Canceled` — which never
go on the wire (`as_acp_status()` maps the last two to `None`, `:1305-1312`).
```rust
fn update_acp_status(&mut self, status: acp::ToolCallStatus) {
if let WaitingForConfirmation { current_status, .. } = &mut self.status
&& matches!(status, Pending | InProgress) { *current_status = status; } // card stays up
else { self.status = status.into(); } // terminal status wins
} // :1088-1098
fn status_after_permission_grant(s) -> ToolCallStatus {
match s.into() { Pending => InProgress, other => other } // :1314-1317
}
```
So a `pending`/`in_progress` update while a permission card is open **does not dismiss it**; a `completed`/`failed`
update **does** — and because the old status (holding `respond_tx`) is dropped, the agent's pending
`session/request_permission` resolves to `Cancelled`. `From<acp::ToolCallStatus>` has a catch-all `_ => Pending`
(`:1287-1297`): the crate's v1 enums are open. The spec defines **no** legal transition table in either version; build
permissively.
**C.6 Titles are kind-dependent** (`:888-896`, re-applied on every update `:1147-1162`): `Execute` verbatim
as plain text (shell commands must never render as markdown); `Edit` markdown-escaped (file paths); anything else
collapsed to its **first line + "…"**. `ToolKind` has **ten** values in the schema — `read | edit | delete | move |
search | execute | think | fetch | switch_mode | other` — and **`switch_mode` is missing from the docs prose list**
**[docs]**; Claude Code's exit-plan-mode uses it.
**C.7 Unknown `toolCallId` fabricates a visible failure** (`:3124-3151`): kind `Fetch`, status `Failed`,
label and content both `"Tool call not found"`, pushed as a new entry — and `Ok(())` returned, so the agent sees no
error. Pinned by `test_tool_call_not_found_creates_failed_entry` (`8961`). A quieter client would drop the update and
the user would never learn the agent is confused.
**C.8** `ToolCallUpdate` in `acp_thread` is an enum of three (`:1905-1910`): `UpdateFields` (the wire one),
`UpdateDiff`, `UpdateTerminal`. The latter two attach a live entity without JSON round-trip and both do a hard
`content.clear(); content.push(…)` (`:3170-3178`). An external agent can only produce `UpdateFields`.
## D. Permission flow, and the rest of the rendering surface
**D.1 Wire contract.** `session/request_permission` params: `{sessionId, toolCall: ToolCallUpdate,
options: PermissionOption[]}`, all required. **v1 has no `title`/`description`/`subject`** (v2 adds them), so the
prompt must be rendered from the embedded `ToolCallUpdate`. `PermissionOption` requires `{optionId, name, kind}`;
kinds are `allow_once | allow_always | reject_once | reject_always`; the response is
`{outcome:{outcome:"selected",optionId}}` or `{outcome:{outcome:"cancelled"}}` **[docs]** /v1/tool-calls. Two MUSTs:
on turn cancellation the client **MUST** answer `cancelled`, and it **MUST** answer *all* pending permission requests
that way **[docs]** /v1/tool-calls, /v1/prompt-turn.
**D.2 Three outcomes, not two** (`acp_thread.rs:1230-1246`): `Cancelled`, `InterruptedByFollowUp`,
`Selected(…)`. The middle one serialises to `cancelled` but is produced by `run_turn` when the user types a follow-up
while a card is open (`:3742`) — so the UI can say "superseded" rather than "you cancelled". Cheap now, impossible to
retrofit. `SelectedPermissionOutcome` carries `{option_id, option_kind, params}` but
**only `option_id` is sent** (`:1224-1228`); the rest is local bookkeeping.
**D.3 `AuthorizationKind`** (`:1248-1261`) splits `PermissionGrant` (allow ⇒ InProgress, reject ⇒ Rejected)
from `ActionChoice` (always ⇒ InProgress; the caller interprets `option_id`, e.g. "Save vs Discard before editing a
dirty buffer"). External agents are always `PermissionGrant` (`agent_servers/acp.rs:4602`).
**D.4 Resolution** (`authorize_tool_call`, `:3433-3478`): reject kinds ⇒ `Rejected`, allow kinds ⇒
`status_after_permission_grant(current_status)`, and — twice — **`_ =>` treats an unrecognised kind as allow**
(`:3452, 3459`), because the enum is open and a future `allow_for_session` must not read as a reject. Status flips
**before** the response is sent, and `.ok()` swallows a dropped receiver. The mirror, `cancel_tool_call_authorization`
(`:3420-3431`), used when the agent cancels its own request, is a **no-op unless the status is still
`WaitingForConfirmation`** — a late cancel cannot clobber a decision already made.
**D.5 Zed persists nothing for external agents.** `PermissionOptions` has three variants
(`connection.rs:579-586`), and external agents always get `Flat(args.options)` (`acp.rs:4601`); the
`Dropdown`/`DropdownWithPatterns` variants are built **only** in `crates/agent/src/thread.rs` (`984, 1000, 1055,
1165`). `ConversationView::authorize_tool_call` (`conversation_view.rs:467-497`) emits telemetry, calls the thread,
notifies — no settings write; `grep always_allow crates/agent_ui/` finds only test strings. Persistence lives in
`Thread::persist_permission_outcome` (`agent/src/thread.rs:6520-6560`), which string-matches option-id prefixes
`always_allow:` / `always_deny:` / `always_allow_mcp:` / `always_deny_mcp:` and writes `agent.tool_permissions.*`
(`:6588-6628`); `"allow"`/`"deny"` are once-only.
Zed's rule model is still worth mining (`docs/src/ai/tool-permissions.md`): precedence is *built-in security rules →
`always_deny` → `always_confirm` → `always_allow` → per-tool `default` → global `default`* (`:120-127`); the only
non-overridable rules are recursive deletes of `/`, `~`, `$HOME`, `.`, `..` in the terminal tool, matched
case-insensitively against the raw command **and each parsed sub-command of a chain** (`:176-190`); the menu offers
"Allow once / Deny once", "Always for `<tool>`", and "Always for `<pattern>`" only "when a safe pattern can be
extracted", MCP tools getting tool-level only (`:191-203`).
**D.6 The card is inline, force-expanded, and never modal.** Appended to the tool-call body
(`thread_view.rs:8671`; terminal variant `:8002-8015`), with `is_open |= needs_confirmation` and `is_collapsible =
has_content && !needs_confirmation` (`:8211-8216`) — so it cannot be collapsed away. `Flat` rendering (`:9801-9911`)
is a **vertical stack in the agent's exact order, unfiltered**:
`div().p_1().border_t_1().w_full().v_flex().gap_0p5().children(options.iter()…)` (`:9814-9820`), label = `option.name`
verbatim.
| kind | icon | colour | action | cite |
|---|---|---|---|---|
| `AllowOnce` | `Check` | Success | `agent::AllowOnce` | `9840-9845` |
| `AllowAlways` | `CheckDouble` | Success | `agent::AllowAlways` (none for id `allow_thread`) | `9846-9857` |
| `RejectOnce` | `Close` | Error | `agent::RejectOnce` | `9858-9863` |
| `RejectAlways` **and any unknown kind** | `Close` | Error | **none** | `9864-9869` |
| id `SANDBOX_FALLBACK_RETRY_OPTION_ID` | `RotateCcw` | Muted | none | `9828-9838` |
No per-kind button *style* — no filled danger button, no primary; icon and icon colour carry it all.
**Nothing is focused or auto-focused**: the `focus_handle` is the ThreadView's and is used only to resolve
keybinding badges (`:9893`), so there is no "Return accepts the default". A badge is drawn only on the
**first** pending call and only once per kind (`seen_kinds` ArrayVec, `:9885-9895`); `is_first` = head of a
global FIFO queue (`:8075-8088`).
Keymap (context `"AcpThread"`, `assets/keymaps/`, read via `git show`): macOS `cmd-y` AllowOnce, `cmd-alt-y`
AllowAlways, `cmd-alt-z` RejectOnce, `cmd-alt-a` OpenPermissionDropdown; Linux `shift-alt-a` / `shift-alt-q` /
`shift-alt-x` / `ctrl-alt-a`. Actions at `agent_ui.rs:268-298`; handlers `thread_view.rs:2504-2540`. For a `Flat` list
the keyboard path resolves via `first_option_of_kind(AllowOnce | RejectOnce)` (`conversation_view.rs:518-560`) — **the
shortcut picks the first option of that kind whatever its `optionId`**. `permission_option_for_action` (`:505-521`)
additionally prefers an option whose id equals `SandboxPermission::AllowAlways.as_id()` (`"allow_always"`,
`acp_thread.rs:135-162`) for the AllowAlways action.
**Escape does not dismiss the card — it cancels the turn.** `menu::Cancel` on the `AcpThread` element →
`cancel_generation` → `AcpThread::cancel` (`thread_view.rs:12152-12159, 1972-1979`). Correct, because there is no
third option: the agent is blocked until answered.
**A floating duplicate appears when the card scrolls off-screen.**
`render_main_agent_awaiting_permission` (`:3554-3632`) returns `None` while the entry is visible and otherwise
re-renders the same buttons above the editor in `ToolCallLayout::Floating` (`max_h_40().overflow_y_scroll()`,
`:8660-8668`) with a sand spinner, header `"Awaiting Confirmation"` / `"…(N)"`, and a **"Scroll"** button that jumps
to the entry (`:3592-3631`); subagents get `"Subagents Awaiting Permission:"` (`:3456-3520`). This is the fix for the
worst failure mode of an inline permission UI — the user not knowing the agent is blocked.
**A homograph gate worth stealing.** Allow buttons are *disabled* while a surprising-Unicode warning is
unacknowledged: `disabled = allow_disabled && is_allow` where `allow_disabled =
sandbox_confusables_block_allow(tool_call, cx)` (`:9871-9880, 8005`, impl `:9034-9048`, banner `:9053+`,
`agent_ui/src/unicode_confusables.rs`). Deny and Retry stay enabled, and the **keyboard shortcuts check the same
gate** (`:2520-2536`) so they cannot bypass the banner.
> Caveat: `Flat` is not exclusively the external shape — Zed's own agent emits it for sandbox prompts, which
> is why the flat renderer carries native-only id special cases. A third-party agent's ids never match, so
> every option gets the plain kind-based treatment.
**D.7 Tool-call, diff and terminal cards.**
`ToolKind` → icon (`thread_view.rs:10010-10021`): `Read|Search → ToolSearch`, `Edit → ToolPencil`, `Delete →
ToolDeleteFile`, `Move|SwitchMode → ArrowRightLeft`, `Execute → ToolTerminal`, `Think → ToolThink`, `Fetch → ToolWeb`,
`Other | _ → ToolHammer`. Three overrides run first (`:9971-10009`): an `Edit` with exactly one location uses the
**language file icon**; that case when failed *and* the diff was revealed adds a warning triangle tooltipped
"Interrupted Edit"; a subagent call uses the agent icon.
Status → visual is deliberately minimal: **no per-call spinner, no per-call checkmark** — the *thread-level* spinner
carries running state (`:7341-7385`, `SpinnerVariant::Sand` + "Awaiting Confirmation" when a permission is pending,
`Dots` otherwise). The only glyph is failure — `Close`/Error, or `XCircle`/Muted + "Interrupted Edit" for a cancelled
edit with no revealed diff (`:8531-8554`) — plus a **dashed** card border (`:7965, 10393`). A `Rejected` call renders
**no body** (`:8432`); a `Canceled` call with no visible content
**is hidden entirely** (`:6396-6405`). Subagent cards *do* get icons (`:10633-10662`).
Collapse state is a `HashSet<ToolCallId>` (`entry_view_state.rs:50, 76-91`) — **default collapsed, and a failed or
running call does not auto-expand**. Two settings-gated auto-expands on view events (`thread_view.rs:1268-1286`):
`NewDiff` if `expand_edit_card`, `NewTerminal` if `expand_terminal_card`; `TerminalMovedToBackground` auto-collapses.
The chevron is hover-only (`:8502-8530`). **There is no line limit or truncation of tool output in the UI** — the only
caps are a 200-char title elide, an 8-entry subagent preview, `max_h_64` on thinking, `max_w_96/max_h_96` on images,
`max_h_40` when floating.
**`rawOutput` is never rendered** (zero hits in `crates/agent_ui/src/`; it exists only for the refusal gate,
`acp_thread.rs:3842`). **`rawInput` is shown twice**, gated by `should_show_raw_input = !is_terminal_tool && !is_edit
&& !has_image_content` (`:8205`): behind a second disclosure "View Raw Input"/"Raw Input:" while awaiting confirmation
(`:8268-8335`), and inside expanded output as `"Raw Input:"` then `"Output:"` (`:8351-8371`). Rendering is
`markdown_for_raw_output` (`acp_thread.rs:4721-4762`): scalars bare, objects/arrays as a pretty-printed ```json fence.
**Diff cards are a real read-only `Editor` over a `MultiBuffer`** with full Tree-sitter highlighting
(`entry_view_state.rs:655-700`: `EditorMode::Full{SizeByContent}`, no gutter/diagnostics/minimap, read-only,
`set_expand_all_diff_hunks`, `HiddenUnstagedDiffHunkRenderer`, text at `TextSize::Small`). Model side
(`acp_thread/src/diff.rs:18-83`): `Diff::finalized` builds a `MultiBuffer::without_headers`, loads the language for
the path, and installs **only the hunk ranges** as excerpts with `excerpt_context_lines` of context;
`Diff::needs_update` compares base text and rope contents (`:192-210`) so an unchanged diff is not rebuilt — which is
what `update_from_acp` consults before replacing a diff entity (`acp_thread.rs:1866-1877`). The inline card has **no
keep/reject buttons** and appears only once `has_revealed_range` is true (`thread_view.rs:10377-10385`); review lives
in `AgentDiff` — per-hunk Reject/Keep with keybinding badges (`agent_diff.rs:812-858`), toolbar Reject All/Keep All
(`:1138-1168`), actions `agent::{Keep, Reject, KeepAll, RejectAll, UndoLastReject}` (`agent_ui.rs:259-268`), macOS
`cmd-y`/`cmd-alt-z`/`shift-alt-y`/`shift-alt-z`. Stats render as green `+ n` / red `‒ n` per file and as a whole-turn
rollup (`thread_view.rs:3310, 4153`).
**Terminal cards.** `TerminalToolHeader` (`agent_ui/src/ui/terminal_tool_header.rs`) shows: working dir with
**`.truncate_start()`** so long paths elide from the *left* (`:145-153`); hover-only disclosure; elapsed time
**only past 10 s** (`ELAPSED_DISPLAY_THRESHOLD`, `:7`, gate `:108-110`); a rotating `LoadCircle` plus a red
**Stop** button tooltipped "Stop This Command" with the note *"Also possible by placing your cursor inside the
terminal and using regular terminal bindings"* (`:172-197`); a truncation `Info` icon (`:198-207`); exit code as
`Close`/Error, `"Exited with code {code}"` (`:208-219`); a sandbox `LockOff` warning (`:220-239`). The command arrives
via `.command_slot`, not owned by the header. Body (`thread_view.rs:7820-8016`): the **live terminal renders only when
expanded** (`:7971`), in `h_72()` when scrollable; the card is *not* shown as running while awaiting confirmation
(`:7911`); Stop calls `terminal.stop_by_user(cx)` then, if `cancel_generation_on_terminal_stop`, cancels the whole
turn (`:7924-7934`). Truncation copy, verbatim (`:7889-7906`) — note "**the first**", Zed's head-retention leaking
into the UI (§E.3, §M1):
> "Output exceeded terminal max lines and was truncated, the model received the first {size}."
> "Output is {orig} long, and to avoid unexpected token usage, only {sent} was sent back to the agent."
**D.8 Plan, usage, modes, config options, slash commands** — full detail in the companion file; the
load-bearing points only:
*Plan.* `update_plan` (`acp_thread.rs:3577-3600`) is a **positional replace with entity recycling**, the same
trick as C.4; `Plan::stats()` counts `InProgress` **toward `pending`** (`:1969-1995`). Three further mutations:
`clear_completed_plan_entries` at the start of each turn (`:3609`, from `run_turn` `:3739`); at the end of a
non-cancelled turn a non-empty plan with `pending == 0` is `mem::take`n into the transcript as `CompletedPlan`
(`:3602`); `clear_plan()` for the dismiss button. Spec: "The Agent **MUST** send a complete list … **The Client MUST
replace the current plan completely**" **[docs]** /v1/agent-plan — and entries have
**no id**, so full replace is the only correct implementation. UI: a sticky activity bar above the editor,
**collapsed by default** (`thread_view.rs:603, 1018, 3084-3195`); icons `TodoProgress`/Accent with a 2 s
rotate, `TodoComplete`/Success, `TodoPending`/Muted (`:3830-3852`); strikethrough only for Completed;
**`priority` is stored and never rendered.**
*Usage.* `usage_update` is an absolute snapshot for `size`/`used`, and **`cost` is sticky** — replaced only
when present (`acp_thread.rs:2639-2651`). `TOKEN_USAGE_WARNING_THRESHOLD = 0.8` drives
`TokenUsageRatio::{Normal,Warning,Exceeded}` (`:2028-2052`), but the **ring itself breaks at 0.85** with no red state
(`thread_view.rs:4696-4702`); 16 px ring, 2 px stroke, in the editor toolbar (`:4682-4866`). Percentage and cost are
**tooltip-only**; cost is `"{amount:.2} {currency}"`, 4 decimals under $0.01 (`:4687-4694`) — a trailing ISO code, not
a symbol. The token-limit callout is explicitly suppressed for external agents (`:11826`).
*Modes vs config options are mutually exclusive* (`conversation_view.rs:1313-1350`): if the connection reports
`session_config_options`, the mode **and** model selectors are set to `None`. Spec agrees — "Clients
**SHOULD** use them instead of the `modes` field. Modes will be removed in a future version" **[docs]**
/v1/session-config-options. **Neither view caches state**: the update handler only calls `cx.notify()` and the render
re-reads the connection (`:1868-1871`). Both change paths are two-step (persist default, then send) and
**errors are only `log::error!`d — never surfaced, never rolled back** (`mode_selector.rs:65-84`,
`config_options.rs:776-807`). `select` is a picker (searchable at ≥5), `boolean` a Switch, and **any other type
renders an empty div** (`config_options.rs:445-608`).
*Slash commands.* `available_commands_update` replaces the list wholesale (`acp_thread.rs:2624-2631`); invocation
is the spec's — the text rides a normal `session/prompt` **[docs]** /v1/slash-commands. Zed's trigger is **not
leading-`/` only**: it scans right-to-left for a `/` at column 0 or preceded by whitespace, so `"Lorem /help"`
completes (`completion_provider.rs:2022-2071`); a command with `input: Some` requires an argument and merely inserts
text, one without **submits immediately** (`:1595-1610`); category grouping comes from a `_meta` key
(`"native"`/`"mcp"`, `acp_thread.rs:85-121`) that external agents never send; bare `/login`/`/logout` are
intercepted client-side (`thread_view.rs:1505-1537`).
**D.9 OS notifications.** `agent_ui/src/ui/agent_notification.rs` is a **borderless GPUI popup**, not an
OS-native notification: 450×72, top-right of the target display, `WindowKind::PopUp`, `focus: false`, transparent
(`:32-67`), with **View** and **Dismiss** buttons. Five triggers, all in `handle_acp_thread_event`
(`conversation_view.rs`): `ToolAuthorizationRequested` → "Waiting for tool confirmation" (`:1665`);
`ElicitationRequested` → "Waiting for input" (`:1669`); turn finished → "Finished running tools" or "New message"
(`:1729`); `Refusal` (`:1742`); `Error` → "Agent stopped due to an error" (`:1757`). Gating (`:2907-2990`): **at most
one at a time** (`:2914`); suppressed entirely when the window is active *and* the panel shows this conversation
(`:2879-2891`); `notify_when_agent_waiting` selects `PrimaryScreen`/`AllScreens`/`Never` with `request_attention()` on
the non-`Never` paths; turn-finished is suppressed when a queued message was just auto-sent (`:1725`). The
suppress-when-visible rule is what decides whether the app feels attentive or naggy.
## E. Terminals and the filesystem
**E.1 Zed says yes to everything** (`client_capabilities_for_agent`, `acp.rs:767-795`):
`fs{read_text_file:true, write_text_file:true}`, `terminal(true)`, `auth{terminal:true}`,
`session.config_options.boolean`, `elicitation{form, url}`, plus `_meta` keys `"terminal_output": true` and
`"terminal-auth": true` (and `"parameterizedModelPicker"` for Cursor only, `:773-775`). Contrast fazm, which declines
fs entirely. `terminal(true)` means **Zed owns the PTY for every command the agent runs**. The nine agent→client
handlers it registers (`acp.rs:707-750`): `session/request_permission`, `fs/{write,read}_text_file`,
`terminal/{create,kill,release,output,wait_for_exit}`, `elicitation/create`, plus notifications `session/update` and
`elicitation/complete`. Spec: `session/request_permission` is the
**only baseline client method**; everything else is capability-gated **[docs]**.
**E.2 `terminal/create` does not exec the command.** `handle_create_terminal` (`acp.rs:4972-5030`) →
`create_terminal_entity` (`terminal.rs:615-666`): resolve the directory environment for `cwd`; then
```rust
env.insert("PAGER".into(), "".into());
// Override user core.pager (e.g. delta) which Git prefers over PAGER
env.insert("GIT_PAGER".into(), "cat".into()); // terminal.rs:670-675
```
then apply the request's `env` array **on top** (agent wins, `:622`); pick the shell
(`get_default_system_shell_preferring_bash()`, `:627-634`); and wrap: `ShellBuilder::new(&shell,
is_windows).redirect_stdin_to_dev_null().build(Some(command), &args)` (`:636-638`), which quotes each arg, joins them,
and prefixes `exec </dev/null\n` for POSIX shells — the comment explains the newline: *"so that it is already active
if the command contains a syntax error. Otherwise, with -i, dash will fall back to an interactive shell"*
(`shell_builder.rs:100-109`). Finally a real PTY task (`:640-652`). So `{command:"ls", args:["-la"]}` becomes
`/bin/bash -c 'exec </dev/null\nls -la'`. A Swift host doing `Process()` on the raw command will behave differently
for shell syntax and **hang on the first pager**. `AcpThread::create_terminal` (`acp_thread.rs:4387-4535`) is the
richer path used by Zed's own agent: same steps plus optional Seatbelt sandbox wrapping (`terminal.rs:363`) and a
headless branch that drops the PTY because *"Headless hosts have no controlling TTY, so PTY setup fails with
`ENOTTY`"* (`:4417-4420`).
**E.3 Truncation — Zed contradicts the spec.**
```rust
if let Some(limit) = self.output_byte_limit && content.len() > limit {
let mut end_ix = limit.min(content.len());
while !content.is_char_boundary(end_ix) { end_ix -= 1; }
end_ix = content[..end_ix].rfind('\n').unwrap_or(end_ix); // don't truncate mid-line
content.truncate(end_ix); // keeps [0..end_ix] = the HEAD
} // terminal.rs:560-579
```
Spec: *"Once exceeded, earlier output is truncated… **the Client truncates from the beginning of the output**… The
Client **MUST** ensure truncation happens at a character boundary"* **[docs]** /v1/terminals. Zed honours the
character-boundary MUST (and snaps to a line boundary too) but **retains the wrong end**. For a build log that is the
difference between seeing the compiler banner and seeing the errors. §M1. `current_output` (`:540-558`) sets
`truncated = original_content_len > content.len()`.
**E.4 Exit, kill, release.** `_output_task` (`:471-509`) awaits the PTY, snapshots
`TerminalOutput { ended_at, exit_status, content, original_content_len, content_line_count }`, releases PTY resources
and tears the sandbox down on a background thread. It is a `Shared<Task<…>>`, so
**multiple `terminal/wait_for_exit` calls are safe and all resolve identically** (`:518-520`). `stop_by_user`
sets an `AtomicBool` before killing so awaiting code can distinguish a user stop from a natural exit (`:530-538`) —
the wire has no field for this. `kill_terminal` keeps the entry in the map; `release_terminal` **removes and kills**
(`acp_thread.rs:4543-4571`), matching *"After release the terminal ID becomes invalid for all other `terminal/*`
methods"* — while the entity survives inside any `ToolCallContent::Terminal` that referenced it, satisfying *"the
client SHOULD continue to display its output after release"* **[docs]**. Shape trap: `terminal/output` **nests**
`{exitStatus:{exitCode,signal}}`; `terminal/wait_for_exit` returns `{exitCode, signal}` **flat** **[docs]** schema/v1.
**E.5 Out-of-order events.** `ToolCallContent::from_acp` **errors** if the terminal id is unknown
(`acp_thread.rs:1841-1845`), so `AcpThread` keeps `pending_terminal_output` and `pending_terminal_exit` maps
(`:2109-2110`) drained in the `Created` arm (`:4658-4676`). Three tests pin it (§K).
**E.6 `fs/read_text_file`** (`:4212-4288`): 1-based → 0-based (`line.saturating_sub(1)`, `:4220`, matching
the spec's "Line numbers are 1-based"), `limit` defaults to `u32::MAX`. Reads through `project.open_buffer` — i.e.
**the unsaved buffer**, which is the method's stated purpose: *"These methods enable Agents to access unsaved editor
state"* **[docs]** /v1/file-system. Every read is logged to the `ActionLog` and its snapshot cached in
`shared_buffers` (`:4256-4263`) so a later write diffs against **what the agent saw**. Past-EOF is an explicit
`invalid_params` with the real extent (`:4266-4272`); a missing path is `resource_not_found` = **-32002**
(`:4234-4236`). The "agent location" cursor moves so the user follows along — but **only for the root session**
(`parent_session_id.is_none()`, `:4225`), so a subagent cannot yank the viewport.
**E.7 `fs/write_text_file` is not a file write** (`:4292-4385`): open the buffer, take the cached snapshot,
compute `text_diff(old, new)` on a background thread, map to anchor ranges, apply as one transaction tagged
`BufferEditSource::Agent`, log `buffer_edited`, run format-on-save if configured, then `save_buffer`. Applying a
*diff* is what preserves cursor, folds, selection and undo history; `try content.write(to:)` throws all of that away.
Spec: `path` "**MUST** be absolute. The Client **MUST** create the file if it doesn't exist."
## F. Spawn, env, process supervision
**F.1 Never exec the binary directly.**
```rust
let builder = ShellBuilder::new(&Shell::System, cfg!(windows)).non_interactive();
let mut child = builder.build_std_command(Some(path.clone()), &args);
child.envs(env.clone());
if let Some(cwd) = /* local projects only */ { child.current_dir(cwd); }
let mut child = Child::spawn(child, Stdio::piped(), Stdio::piped(), Stdio::piped())?; // acp.rs:849-860
```
`Shell::System` = `$SHELL` or `/bin/sh`; `.non_interactive()` drops `-i`, giving `["$SHELL","-c","<quoted path>
<quoted args>"]` (`shell_builder.rs:36-39`, `shell.rs:359-380`). Consequences: nvm/mise/asdf/homebrew shims resolve;
**a missing binary is exit 127 from the shell**, arriving as `LoadError::Exited{status, stderr}` rather than a spawn
error (`acp.rs:242-247, 957-974`; regression test `startup_returns_error_when_agent_exits_before_initialization`,
`:3769`). `cwd` is the first ordered project path, set only for local projects (`:852-860`) — Zed sets it on **both**
the process and per session, where fazm sets it only per session.
**F.2 Command resolution.** Seam = `ExternalAgentServer` (`agent_server_store.rs:117-143`) with four impls,
all producing `AgentServerCommand { path, args, env }` (`:34-41`, serde renames `path` → `"command"`). The registry
file is **`registry.json`, not `agent.json`**: `https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json`,
1 h throttle, 30 s fetch timeout (`agent_registry_store.rs:20-25`), cached at
`external_agents_dir()/registry/registry.json`; entries carry `distribution = { binary?: {"<os>-<arch>":
{archive,cmd,args,sha256?,env}}, npx?: {package,args,env} }` (`:628-677`), binary preferred only when the platform is
present (`:439-450`).
**Zed never runs `npx`** — it `npm install`s into a per-agent dir with managed Node, reads `package.json`'s
`bin`, and execs `node <resolved entry>` (`agent_server_store.rs:1387-1421`); the version pin is a **ceiling range**
`pkg@0.0.0 - <version>`, not `<=`, because Windows npm/PowerShell strips the quotes (`:1436-1468`). Managed Node:
`const VERSION: &str = "v24.11.0"`, `bin/node` / `bin/npm` (`node_runtime.rs:605-616`), installed to
`data_dir()/node/node-{version}-{darwin|linux|win}-{x64|arm64}` from nodejs.org, health-checked by `node <npm>
--version` with private cache and blank npmrcs, wiping and re-downloading on failure (`:621-725`). A registry `cmd ==
"node"` is **rewritten to managed Node**; anything else must be a `./`-relative path inside the extracted archive,
with `..` rejected (`:1284-1302`) — that rewrite is what breaks packages whose `bin` is a native Mach-O **[issue]**
zed#62716, which is exactly `@zed-industries/codex-acp`'s shape. Custom agents get `shellexpand::tilde` and **no
existence check** (`:1476-1497, 1637`).
**F.3 Env layering** (later wins). npx: `project shell env → npm_command_env → distribution.npx.env →
extra_env → settings agent_servers.<id>.env` (`:1380-1421`); archive the same with
`distribution.binary.<platform>.env`; custom puts `extra_env` last (`:1485-1493`). The **project shell env** is
harvested by running a **login + interactive** shell in the worktree and re-execing Zed with `--printenv` to a private
fd (`environment.rs:173-250`, `shell_env.rs:107-178`, itself `setsid`-detached at `:154`). Proxy: `HTTP(S)_PROXY` plus
a `NO_PROXY` fallback of `"localhost,127.0.0.1"` explicitly so local MCP servers aren't proxied
(`agent_servers.rs:113-135`).
Per-agent injections, complete (`custom.rs:229-253`, ids at `:17-20`):
| id | injection |
|---|---|
| `claude-acp` | `ANTHROPIC_API_KEY=""` — **blanked, not removed**, so the CLI falls back to its own subscription auth |
| `codex-acp` | forwards `CODEX_API_KEY` and `OPEN_AI_API_KEY` from Zed's own env if set |
| `gemini` | `SURFACE=zed`, plus `GEMINI_API_KEY` from `GEMINI_API_KEY` → `GOOGLE_AI_API_KEY` → keychain (`:288-303`) |
| any | `NO_BROWSER=1` when the client lacks WSL interop (`:226-228`) |
**Nothing is ever unset** — `child.envs(env)` merges over Zed's inherited environment; no `env_clear()`.
(fazm by contrast *deletes* `CLAUDECODE` so the nested-session guard doesn't break `--resume`.)
**F.4 Supervision.** `setsid()` in `pre_exec` (`util.rs:405-419`); kill is
`killpg(pid, SIGKILL)` (`process.rs:114-121`) driven from `impl Drop for AcpConnection` (`acp.rs:1528-1534`). **On
Windows only**, a job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` kills the tree even on a crash
(`process.rs:56-102`); **on macOS there is no equivalent** — no `kill_on_drop`, no SIGTERM grace, no reaper. That is
zed#61303 (55 orphaned processes / 3.1 GB) **[issue]**. stderr is line-read for the process lifetime, logged as `agent
stderr: {line}`, and stored in a 2000-entry ring (`acp.rs:913-928, 48, 201-218`); `trailing_stderr()` extracts only
the **contiguous trailing block** so a crash callout shows the last error (`:220-239`).
**There is no automatic respawn** — recovery is a "Retry" button and a "Reload Agent" menu item through
`AgentConnectionStore::restart_connection` (`conversation_view.rs:983-990, 2748-2758`;
`agent_connection_store.rs:127-141`). Process exit is raced against startup twice, with a **250 ms** grace after an
`initialize` error so the richer exit error wins (`acp.rs:1002-1021`); afterwards `_wait_task` fans
`LoadError::Exited` to every live session (`:1027-1034, 1510-1526`).
**F.5 Transport, and the stack-size trap.** Newline-delimited JSON-RPC via
`agent_client_protocol::Lines`, tee'd both ways into the debug log (`acp.rs:888-911`). Framing, request ids and
`$/cancel_request` live inside the crate — the literal string does not appear in Zed's tree. The IO future runs on a
**dedicated thread**:
> "…in unoptimized builds its dispatch chain needs ~0.5 MiB of stack per inbound message, which overflows the
> fixed 512 KiB stacks of the GCD workers that poll background tasks on macOS, crashing dev builds as soon as
> an agent sends its first message." (`acp.rs:930-949`, restated `:665-677`)
Swift inherits the same 512 KiB non-main-thread default — run the reader on a `Thread` with an explicit `stackSize`. A
second bridge pushes handler work onto an mpsc drained by a foreground task (`:284-394, 976-989`); the Swift analogue
is a `MainActor` handler queue fed from the reader thread.
**Malformed input is never fatal**: non-JSON lines dropped (`:94-96`), unparsable ids warn and skip
(`:122-138`), undeserialisable `error` objects degrade to `internal_error` (`:140-150`), read errors log and continue
(`:893-895`), **JSON-RPC batch arrays are split per entry** (`:99-104`), unknown-session notifications warn and drop
(`:4824-4830`), unknown-session requests answer `"unknown session: {id}"` (`:4551-4560`), and every outgoing error is
logged with its method first (`:4562-4571`).
## G. Sessions — new / load / resume / list / fork
All three creation requests share a shape (`acp.rs:1446-1470`): `cwd` = first ordered project path,
`additionalDirectories` = the rest **but only when `sessionCapabilities.additionalDirectories` is advertised**
(otherwise dropped, `:1473-1492, 1722-1727`), and `mcpServers`. An empty path list errors `"Working directory cannot
be empty"`. Spec: `session/new` **requires** `mcpServers` (send `[]`); `session/resume` makes it optional; every
additional dir must be absolute and must be re-sent on every load/resume — "omitting the field or providing an empty
array does not restore stored roots implicitly"
**[docs]** /v1/session-setup.
**`load` vs `resume` differ in who replays.** Spec: on `session/load` the agent "**MUST** replay the entire
conversation … in the form of `session/update` notifications" and respond only "**when all** the conversation entries
have been streamed"; on `session/resume` it "**MUST NOT** replay the conversation history … before responding"
**[docs]**. That replay is why Zed keeps `sessions` **and** `pending_sessions` side by side (`acp.rs:401-402,
1166-1301`) — updates arrive *before* the response — and reference-counts the lifecycle so a `session/close` racing an
in-flight load fails it with `"session was closed before load completed"` (`:1817-1881`). Replay and live streaming
share one handler (`handle_session_notification`, `:4816+`), so the UI cannot tell them apart. A client that registers
the session on the *response* loses the entire transcript.
Selection is a three-way branch (`conversation_view.rs:1131-1165`): `supports_load_session()` → `load_session`; else
`supports_resume_session()` → `resume_session` with `resumed_without_history = true`; else error `"Loading or resuming
sessions is not supported by this agent."` The resumed case renders a callout — *"Resumed Session … This agent does
not support viewing previous messages. However, your session will still continue from where you last left off."*
(`thread_view.rs:11486-11496, 12137`).
Capability gates all read off `InitializeResponse.agentCapabilities` (`acp.rs:1102`): `loadSession` (`:1711`),
`sessionCapabilities.resume` (`:1715`), `.additionalDirectories` (`:1722`), `.close` (`:1813`), `.list`/`.delete`
(`:1045-1065`), `auth.logout` (`:1934`). **`session/fork` is a draft RFD**, not in the v1 schema, and one of the five
features behind the crate's `unstable` flag **[docs]** /rfds/session-fork —
**Zed does not implement it** (no `fork_session` on `AgentConnection`, `connection.rs:91-260`). fazm does, at
its bridge layer.
Post-creation Zed fires up to two more requests: `session/set_mode` for the configured default (optimistic, rolled
back on failure; an unknown mode only warns with the available list, `:1624-1672`) and `session/set_config_option` per
default, validated against the advertised options (`:1303-1436`).
Host-side persistence: a `draft_prompt: Option<Vec<ContentBlock>>` and `ui_scroll_position`
(`acp_thread.rs:2114-2118`); **git checkpoints per user message** (`:294-308, 3672-3685, 4062-4179`) with
`restore_checkpoint`/`rewind` (`:3983-4060`) — Zed's answer to what every other host does with worktrees; `title` +
`provisional_title` (`:2091-2092`). Import enumerates an agent's own `session/list` and adds missing ones as
**archived metadata rows only** — history loads lazily on open (`thread_import.rs:352-387, 855`); sessions without a
working directory are skipped and re-import is idempotent **[docs]** `docs/src/ai/external-agents.md:181-190`.
## H. MCP server injection
`mcp_servers_for_project` (`acp.rs:4397-4443`) is the whole of it — and the identical list goes to `session/new`,
`session/load` and `session/resume` (`:1612, 1743, 1787`).
| Fact | Detail |
|---|---|
| Source | **Only** Zed's configured context servers. `name` = the context-server id. |
| stdio | `{name, command, args, env:[{name,value}]}` — `acp::EnvVariable`, an **array of objects**; schema requires all four fields (`:4415-4424`) |
| http | `{type:"http", name, url, headers:[{name,value}]}`; **`timeout` and `oauth` silently discarded** (`:4429-4437`) |
| sse | **never emitted** (exists in schema, deprecated by MCP) |
| Remote projects | stdio skipped unless the config sets `remote: true` (`:4415`) |
| Zed's own tools | **not injected** — externals get Zed's fs and terminal through ACP client methods, not MCP |
Spec: "All Agents **MUST** support the stdio transport"; http/sse are capability-gated; "Agents **SHOULD** connect to
all MCP servers specified by the Client" **[docs]** /v1/session-setup. Zed's doc hedges: "check
**both** Zed's MCP server configuration and the agent's native MCP configuration"
(`docs/src/ai/external-agents.md:194-198`).
> **This is the injection point for SnappyOS.** One `mcpServers` entry — stdio, or http against
> `localhost:3147` — gives Claude, Codex and Gemini the Snappy toolset with zero per-agent work, and rides a
> path all three already implement (fazm instead built a bespoke unix-socket tools server per agent). Mind
> the two gaps Zed left: v1 `McpServerHttp` has no `oauth`/`timeout` at all, so an authenticated server needs
> `_meta` or stdio; and `env`/`headers` are arrays of `{name,value}`, not dictionaries.
## I. Auth, login, quota
**The error code is `-32000`** ("Authentication required", schema/v1 `ErrorCode`). The string
`"auth_required"` is documentation shorthand — it appears as a literal wire value only in a draft RFD, as
`error.data.reason`. **Match the numeric code.** Zed never writes `32000`; it keys off the typed enum and drops a
generic message while surfacing a specific one:
```rust
if err.code == acp::ErrorCode::AuthRequired {
let mut error = AuthRequired::new();
if err.message != acp::ErrorCode::AuthRequired.to_string() { error = error.with_description(err.message); }
anyhow!(error)
} else { anyhow!(err) } // acp.rs:2074-2086
```
**Three login paths.** (1) `authenticate` in-band — a thin passthrough (`:1924-1932`); the UI renders one
button per advertised method, reverse order, first primary, `description` as tooltip
(`conversation_view.rs:2254-2360`); `logout` gated on `auth.logout` (`:1934-1950`). (2) **Terminal auth** — Zed
advertises `auth.terminal = true` (`:782`) and re-runs the agent command with the method's `args`/`env` as a visible
terminal task (`connection.rs:69-89`, id `external-agent-{agent}-{method}-login`, `:1536-1538`). Spec: "The terminal
process is not the ACP connection, so the Client **MUST NOT** send an `authenticate` request for a terminal method";
"A zero exit status signals success" **[docs]** /v1/authentication. Note `AuthMethodTerminal.env` is a **map**, unlike
every other `env` in the schema. (3) A legacy `_meta["terminal-auth"] = {label, command, args, env}` shim "to support
the `_meta` method prior to stabilization" (`:1554-1586`).
**Success detection is screen-scraping, per agent:**
```rust
let success_patterns = match method.0.as_ref() {
"claude-login" | GEMINI_TERMINAL_AUTH_METHOD_ID => vec!["Login successful", "Type your message"],
_ => Vec::new(), // conversation_view.rs:2161-2167
};
```
With no patterns Zed waits on exit code; with patterns it polls terminal content once a second and kills the task on a
match (`:2168-2237`). **Gemini's auth method is fabricated by Zed, not the agent** — it replaces
`response.auth_methods` with a synthetic `spawn-gemini-cli` terminal method whose args strip
`--experimental-acp`/`--acp`, i.e. re-launching `gemini` *without* ACP so its own `/auth` runs (`acp.rs:1067-1085`,
`GEMINI_TERMINAL_AUTH_METHOD_ID = "spawn-gemini-cli"`, `:46`).
Posture to copy, from Zed's own doc: "External Agents run through their own process and provider relationship.
Billing, legal terms, retention, and data handling are between you and the agent provider… An Anthropic API key
configured for Zed Agent does not automatically configure Claude Agent" (`docs/src/ai/external-agents.md:11-13,
43-47`). The only key Zed hands over is Google's.
**Quota is not handled for external agents.** `ThreadError`'s `RateLimitExceeded`/`PaymentRequired`/etc.
(`conversation_view.rs:123-161`) are reachable only by downcasting to `LanguageModelCompletionError` — Zed's
*native* path (`:173-227`); an ACP agent's error falls through to `Other { message, acp_error_code }`
(`:228-240`). The one ACP-specific massage is a Gemini hack: an `internal_error` whose `data.details` contains `"This
operation was aborted"` / `"The user aborted a request"` is rewritten to `StopReason::Cancelled`, but **only when
`suppress_abort_err` is set by a just-sent cancel** (`:1977-2007`, flag set at `:2012-2018`; targets gemini-cli PR
#6656).
## J. Cancellation, timeouts, errors
Three layers: **turn** = `session/cancel` notification (`acp.rs:2012-2018`); **request** = `$/cancel_request` →
`-32800`, entirely inside the pinned crate, surfacing as `responder.cancellation()` + `run_until_cancelled` (used at
`:4593, 4607-4626, 4657-4709, 4799-4809, 5122-5133`); **process** = `killpg(SIGKILL)` on `Drop`.
`cancel_inner` (`acp_thread.rs:3905-3922`), in order: flush the streaming buffer; cancel outstanding elicitations;
**return early if there is no running turn**; `mark_pending_entries_as_canceled`; *then* send the notification; then
await the in-flight prompt task. `mark_pending_entries_as_canceled` (`:3929-3966`) flips every `ToolCall` in `Pending
| WaitingForConfirmation | InProgress` to `Canceled`, feeding `respond_tx` the outcome, and any `InProgress`
`ContextCompaction` to `Canceled`. A failed send is logged at debug ("Permission request closed before cancellation
was delivered") and swallowed. This satisfies both spec MUSTs — answer every pending permission with `cancelled`, and
"preemptively mark all non-finished tool calls … as soon as it sends the `session/cancel` notification" **[docs]** —
by doing it *before* the send.
**Late updates are accepted, by simply not filtering them**: `test_succeeding_canceled_toolcall` (`6260`)
drives `in_progress → cancel → asserts Canceled → tool_call_update{completed} → asserts Completed`. Spec: "The Client
**SHOULD** still accept tool call updates received after sending `session/cancel`."
`run_turn` (`:3734-3899`) wraps every turn. A new prompt cancels the previous one with `InterruptedByFollowUp` and the
new `send_task` awaits that first (`:3742-3752`). A `turn_id` guard (`is_same_turn`) stops a superseded turn's late
response from flipping the UI (`:3768-3781`), and a dropped `tx` still clears `running_turn` so the panel exits
"generating" (`:3775-3790`). `stopReason` handling (`:3793-3884`): `end_turn` → snapshot the plan, emit `Stopped`;
**`max_tokens` → `Err(MaxOutputTokensError)`, i.e. an error, not a completion**; `cancelled` → cancel pending entries
and **skip** the plan snapshot; `refusal` → if a completed tool call with `raw_output` exists after the last user
message the refusal is about tool output (just emit `Refusal`), otherwise **truncate the entry list back to before
that user message** and emit `EntriesRemoved` + `Refusal` (`:3822-3856`); `max_turn_requests` → no special case.
Spec's `StopReason` set is exactly those five **[docs]** /v1/prompt-turn.
**There are no timeouts on the wire** — none on `initialize`, `session/new`, `prompt` or anything else. The
only timers in the ACP path are the 250 ms post-`initialize`-error grace (`acp.rs:1006-1009`), a 60 s WSL sandbox wrap
(`terminal.rs:353`) and registry HTTP fetches (30 s / 10 s). A hung agent hangs the thread until the user stops it or
the process dies.
`LoadError` (`acp_thread.rs:2228-2262`) = `Unsupported{command,current_version,minimum_version} | FailedToInstall |
Exited{status, stderr} | Other`; the `stderr` is the trailing contiguous block, which is what makes a crash
actionable. `AcpThreadEvent` (`:2154-2178`) is the 21-variant UI vocabulary — `StatusChanged, NewEntry,
EntryUpdated(usize), EntriesRemoved(Range), ToolAuthorizationRequested/Received, Retry(RetryStatus),
Stopped(StopReason), Error, LoadError, Refusal, SubagentSpawned, …`. Mirror it one-for-one; the **index-based**
`EntryUpdated`/`EntriesRemoved` is what lets a list do incremental updates. `RetryStatus{last_error, attempt,
max_attempts, started_at, duration, meta}` (`:2062-2071`) exists but is only populated when the connection implements
`AgentSessionRetry` — the stock `AcpConnection` does not.
## K. Tests worth copying as oracles
~120 tests in `acp_thread.rs mod tests` (from `4765`) are the executable spec. Line numbers are the `async fn`. Build
the Swift equivalent of **`FakeAgentConnection`** (`:8760-8958`, with `on_user_message(closure)`,
`without_truncate_support()`, `with_auth_methods(…)`) first — every oracle below depends on it.
| Group | Tests → what is pinned |
|---|---|
| Chunk folding | `test_push_user_content_block` `5448` (append, don't create) · `…user_message_chunks_use_protocol_message_id_boundaries` `5521` / `…assistant_chunks_…` `5687` (id splits, absent id merges) · `…protocol_user_chunk_does_not_merge_into_optimistic_prompt` `5624` · `test_thinking_concatenation` `5800` · `…ignore_echoed_user_message_chunks_during_active_turn` `5937` · `test_send_command_does_not_echo_user_message` `5866` |
| Tool calls | **`test_tool_call_not_found_creates_failed_entry` `8961`** · **`test_succeeding_canceled_toolcall` `6260`** · `test_tool_call_location_resolves_external_file` `6352` · `test_no_pending_edits_if_tool_calls_are_completed` `6837` · `…preserves_embedded_text_resource` `4893` / `…renders_embedded_image_blob_resource` `4956` / `…falls_back_for_non_image_blob_resource` `4995` · `text_resource_markdown_uses_mime_type_for_code_blocks` `4856` |
| Permissions | **`…duplicate_tool_call_update_preserves_open_permission_request_until_authorized` `6402`** (repeated non-terminal updates keep the card open, content replaced each time) · `…permission_request_tracks_agent_status_until_resolved` `6546` · `…sets_waiting_status_on_existing_tool_call` `6634` · **`test_terminal_tool_call_update_closes_open_permission_request` `6774`** (terminal status ⇒ pending request resolves `Cancelled`) · `test_cancel_tool_call_authorization_resolves_permission_request` `6719` |
| Turns / cancel / refusal | `…returns_cancelled_response_and_marks_tools_as_cancelled` `9699` · `test_follow_up_message_during_generation_does_not_clear_turn` `9475` · `…stale_cancelled_response_does_not_cancel_current_compaction` `9571` · `…running_turn_cleared_when_send_task_dropped` `10149` · `test_tool_result_refusal` `7177` vs `test_user_prompt_refusal_emits_event` `7276` / `test_refusal` `7337` · `…max_tokens_cancels_pending_session_elicitation` `8107` · `…prompt_error_cancels_pending_session_elicitation` `8052` |
| Filesystem | `test_reading_from_line` `6072` · `test_reading_empty_file` `6150` · `test_reading_non_existing_file` `6226` (→ `-32002`) · **`test_edits_concurrently_to_user` `5993`** (write diffs against the snapshot the agent *read*) |
| Terminals | `…output_buffered_before_created_renders` `5071` · `…output_and_exit_buffered_before_created` `5217` · `…exit_preserves_visible_scrollback` `5140` · `…kill_allows_wait_for_exit_to_complete` `5305` · `test_restore_checkpoint_kills_terminal` `9032` |
| Usage / title | `…usage_update_populates_token_usage_and_cost` `9909` · **`…without_cost_preserves_existing_cost` `10010`** · `…response_usage_does_not_clobber_session_usage` `10051` · `…clearing_token_usage_also_clears_cost` `10106` · `…context_compaction_preserves_token_usage` `9945` · `…provisional_title_replaced_by_real_title` `9775` · `…session_info_update_replaces_provisional_title_and_emits_event` `9833` |
| Elicitation | ~25 tests `7455`–`8726` (form/url modes, duplicate responses, unadvertised modes, non-browser URL rejection, cancel-all) — only relevant if you advertise the capability |
**Live conformance harness**: `agent_servers/src/e2e_tests.rs`, six bodies behind the `e2e` feature, generated
by `common_e2e_tests!` (`:353-402`) and run against the **real CLIs** — `test_basic` `:20`, `test_path_mentions` `:51`
(a `ResourceLink` makes the agent read the file), `test_tool_call` `:109`, `test_tool_call_with_permission` `:155`
(`touch … | tee` → `WaitingForConfirmation` → authorize → content contains "Hello"), `test_cancel` `:255`,
`test_thread_drop` `:328` (no leaked strong refs). Port `run_until_first_tool_call`'s **20 s timeout that panics on
expiry** (`:453-479`) too.
Also in `agent_servers/src/acp.rs mod tests` (`2704-4395`):
`startup_returns_error_when_agent_exits_before_initialization` `3769`,
`cursor_client_capabilities_include_parameterized_model_picker_meta` `3016`,
`client_capabilities_include_{elicitation_without_acp_beta,boolean_config_options}` `2721`/`3042`,
`terminal_auth_task_builds_spawn_from_prebuilt_command` `3055`,
`legacy_terminal_auth_task_parses_meta_and_retries_session` `3084`,
`first_class_terminal_auth_takes_precedence_over_legacy_meta` `3131`, `trailing_stderr_only_uses_final_stderr_block`
`3182`,
**`debug_log_records_each_json_rpc_batch_entry` `3200`** (batch arrays must be split),
`session_directories_{use_ordered_paths_when_supported,drop_additional_paths_when_unsupported}` `3272`/`3323`,
`additional_directories_support_respects_agent_capability` `3464`, `test_close_session_during_in_flight_load` `4198`,
`test_close_during_load_preserves_other_concurrent_loader` `4294`.
## L. Recommendations for the Swift host
Ordered by cost-of-late-fix; the trailing tag is the evidence section.
1. **Target `protocolVersion: 1`; give every wire enum an `unknown(String)` case** — Zed has a fallback at every decode site (`acp_thread.rs:1295, 1332, 1845, 2653`), and crate 2.0.0 is an SDK version, not protocol 2. *§A, §B, §C.5*
2. **Run the stdio reader on a `Thread` with an explicit `stackSize` (≥4 MB), not a `DispatchQueue`** — Zed uses `spawn_dedicated` because GCD's 512 KiB stacks overflow on the first message. *§F.5*
3. **Spawn through `$SHELL -c`, not `posix_spawn` on the binary**, and parse exit 127 as "not installed". *§F.1*
4. **Harvest a login+interactive shell environment once at launch and cache it** — a Finder-launched app otherwise gets `/usr/bin:/bin` and every agent fails to find `node`. *§F.3*
5. **`setsid()` at spawn, `killpg(SIGKILL)` on teardown — plus the reaper Zed lacks on macOS**: a launch-time sweep of stale pgids and a SIGTERM/atexit hook. Zed's omission is zed#61303. *§F.4*
6. **Treat `tool_call_update.content` as a full snapshot and tool-call text as an accumulated string** — never append the array, never treat that text as a delta; only `agent_message_chunk` is a delta. *§C.3, §C.4*
7. **Register the session id before sending `session/load`**, or the replayed transcript lands nowhere. *§G*
8. **Answer every pending permission with `cancelled` before sending `session/cancel`, and keep accepting updates afterwards** — both spec MUST/SHOULDs, which Zed satisfies by ordering rather than filtering. *§J*
9. **Model status as 7 client states, not the wire's 4**, carrying `current_status` inside `WaitingForConfirmation` — that is what lets a `pending` update leave the card up and a `completed` one tear it down. *§C.5*
10. **Distinguish "cancelled" from "superseded by a follow-up"** (`InterruptedByFollowUp`): both serialise to `cancelled`, only one is the user's doing. *§D.2*
11. **Ship the passthrough permission UI first — no local "always allow".** Render the agent's options verbatim in its order and let it remember; add persistence later on Zed's precedence model. *§D.5*
12. **Never hard-code an `optionId`.** Look options up by `kind`, and treat an unknown kind as *allow*, as Zed's `_ =>` arms do. *§D.4, §D.6*
13. **Copy the floating "Awaiting Confirmation" duplicate** shown when the real card scrolls off screen, with its Scroll button — the fix for the worst inline-permission failure mode. *§D.6*
14. **Copy the confusables gate**: disable *allow* (buttons **and** shortcuts) until a homograph warning is acknowledged; leave deny enabled. *§D.6*
15. **If you advertise `terminal: true` you own a PTY, a shell wrapper and pager suppression** — `PAGER=""`, `GIT_PAGER=cat`, `exec </dev/null`; without them the first `git log` hangs forever. *§E.2*
16. **Implement `outputByteLimit` the spec's way — keep the tail**, snapped to a character (MUST) and line (nice) boundary. Zed keeps the head. *§E.3, §M1*
17. **Buffer terminal output and exit that arrive before the create is registered.** *§E.5*
18. **`fs/write_text_file` should apply a diff to an open document, diffed against the snapshot the agent read** — keep that snapshot even without an editor surface, to detect conflicts. *§E.6, §E.7*
19. **Inject Snappy's tools as one `mcpServers` entry**, not a bespoke sidecar: one entry reaches all three agents. `env` and `headers` are arrays of `{name,value}`. *§H*
20. **Adopt Zed's `_meta` discipline** — namespaced keys (`_snappy.*`) read through tolerant `…_from_meta() -> Option<T>` helpers; the spec forbids root-level custom fields outright. *§C.1*
21. **Build `FakeAgentConnection` before the UI** — every oracle in §K depends on it. *§K*
22. **Ship the wire log on day one**: both directions plus stderr, batch arrays split, request ids paired to method names. Zed's docs tell users to attach it to every bug report. *§F.5*
23. **Do not advertise a capability you have not implemented.** Start at fazm's posture (`fs` on, `terminal` off); enable terminals when the PTY view exists. *§E.1*
24. **Decide timeouts deliberately.** Zed has none and hangs; a generous watchdog on `initialize` and `session/new` and none on `session/prompt` is the sane consumer-app default. *§J*
## M. Open questions and contradictions
**M1. Terminal truncation runs the wrong way in Zed.** *[contradiction, high confidence]* Spec: "the Client truncates from the beginning of the output"; Zed's `content.truncate(end_ix)` keeps the head (`terminal.rs:560-579`), and its own UI copy says "the model received the **first** {size}" (`thread_view.rs:7889-7906`). Follow the spec; worth filing upstream.
**M2. `current_mode_update`'s field name.** Schema says `currentModeId` (Zed destructures `current_mode_id`, `acp_thread.rs:2633`); the prose example on /v1/session-modes shows `"modeId"`. Trust the schema, decode leniently.
**M3. `ToolKind::switch_mode` is in the schema but missing from the docs list.** Claude Code's exit-plan-mode uses it — do not let it hit your default branch.
**M4. `usage_update`'s `cost` semantics are undefined.** Absence could mean "unchanged" or "cleared"; Zed chooses unchanged (`acp_thread.rs:2645-2651`, pinned at test `10010`). Adopt it, but the ambiguity is real.
**M5. No legal `ToolCallStatus` transition graph exists** in either version — the only ordering rule anywhere is v2's "apply notifications in the order received per `toolCallId`". Build permissively; do not assert.
**M6. `stopReason: max_turn_requests` has no handler in Zed** — it falls through to a plain `Stopped` (`:3793-3884`). Whether it should read as an error (like `max_tokens`) is undecided.
**M7. Quota exhaustion has no protocol representation.** v1 has no usage-limit error and no plan-quota field; `usage_update` is context tokens plus optional cost, and Zed's typed rate-limit errors are unreachable for external agents (§I). **Open for SnappyOS: where does "you've hit your 5-hour Claude limit" come from?** Candidates: agent stderr, adapter `_meta`, or running Codex's app-server protocol alongside ACP for Codex only. cf. **[issue]** zed#55501.
**M8. Zed does not implement `session/fork`** (§G). If SnappyOS needs it (fazm did), build against the draft shape — `session/load` params in, `session/new` response out — gated on `sessionCapabilities.fork`.
**M9. HTTP MCP `oauth`/`timeout` are dropped and have no home in v1** (`acp.rs:4429-4430`); an OAuth-protected MCP server forwarded over ACP fails inside the agent with no diagnostic. Use `_meta`, or require stdio for authenticated servers.
**M10. The `node` rewrite breaks native-binary npm packages.** Zed resolves an npx agent's `bin` and runs `node <bin>` (`agent_server_store.rs:1387-1421`, plus the `cmd == "node"` rewrite at `:1284-1302`); `@zed-industries/codex-acp` ships a Mach-O — **[issue]** zed#62716. Sniff the resolved bin (shebang vs Mach-O magic) and exec it directly when native, as fazm does by hard-coding the platform package path.
**M11. A post-cancel update can resurrect a cancelled call** (`Canceled → Completed`, test `6260`). Spec-compliant, but it puts a green check on something the user cancelled — consider a "was cancelled" display flag.
**M12. There is no agent restart anywhere** — recovery is a manual Retry button; **[issue]** zed#62828 is the open bug. A consumer app wants supervised restart with backoff, but restart loses the session unless the agent supports `session/resume`, so restart policy and resume capability are coupled.
**M13. Unverified in this pass.** Whether `wiedymi/swift-acp` implements `$/cancel_request`, per-request cancellation tokens, and JSON-RPC **batch** splitting — all three Zed gets free from the Rust crate and all three are load-bearing (§F.5, §J). Probe before committing. Also unverified: whether `@agentclientprotocol/claude-agent-acp` currently emits `messageId` (Zed's merge rule tolerates either, §B.1), and the current `codex-acp` npm platform-package layout.
Deep-dive companion to the Zed lane report. Repo zed-industries/zed @ 8514ce3 (2026-09-02), sparse local clone /Users/robertboulos/projects/cloned-repos/zed (the ui crate is NOT in the clone — CircularProgress, Disclosure, Switch, Callout internals only via call sites). Paths relative to crates/. All [src]. Extracted 2026-09-02.
Model (acp_thread/src/acp_thread.rs): Plan { entries: Vec<PlanEntry> } :1952-1954; PlanEntry { content: Entity<Markdown>, priority, status } :1996-2001 (content is Markdown, from_acp :2003-2011); PlanStats { in_progress_entry: Option<&PlanEntry>, pending, completed } :1957-1962. Plan::stats() :1969-1995: InProgress counts toward pending and in_progress_entry is the FIRST in-progress entry. Plan is a field on the thread (plan: Plan :2097), not an entry.
A plan update REPLACES the plan positionally, recycling markdown entities to avoid flicker — update_plan :3577-3600: zip old/new → replace content, priority, status in place; push extras; truncate(new_len). Dispatch SessionUpdate::Plan(plan) => update_plan :2610-2612. Three more mutations to replicate: (1) on each new turn clear_completed_plan_entries drops Completed entries :3609-3614 (called from run_turn :3739); (2) on turn end (not cancelled) if non-empty and stats().pending == 0, the plan is mem::taken into the transcript as AgentThreadEntry::CompletedPlan(entries) :3602-3607, call site :3827-3829; (3) clear_plan() :3616-3619. Markdown export renders - [x] under ## Plan :806-814. E2E test to mirror: agent_ui/src/conversation_view.rs:5624-5681.
Where it renders: a sticky "activity bar" above the message editor, collapsed by default. plan_expanded: bool on ThreadView agent_ui/src/conversation_view/thread_view.rs:603, default false :1018 (edits_expanded: false :1017, queue_expanded: true :1019). render_activity_bar :3084-3195 returns None when plan + changed buffers + queue are empty and no permission request :3100-3106. Sibling of the scrolling conversation list, before the editor: .child(conversation) … .children(render_activity_bar) … .child(render_message_editor) :12453-12481. Chrome: centered, flex_basis(max_content_width), bg(activity_bar_bg), border_1().border_b_0(), rounded_t_md(), shadow only on opaque windows :3126-3148. Section order: awaiting-permission → plan → divider → edits → divider → message queue :3149-3189.
Summary row render_plan_summary :3687-3792. Mode 1, collapsed with an in-progress entry :3696-3739: Label "Current:" (Small/Muted) + the entry's markdown text_xs, text_muted, line_clamp(1); right, when pending > 0: fade gradient + "{pending} left" (pending INCLUDES the in-progress one). Mode 2, expanded or no in-progress :3740-3767: status_label = pending == 0 ? "All Done" : completed == 0 ? "{len} Tasks" : "{completed}/{len}"; row Label "Plan" left, status right. Row chrome :3768-3791: Disclosure("plan_disclosure", plan_expanded) left; right IconButton("dismiss-plan", Close) XSmall/Square, tooltip "Clear Plan", clear_plan + stop_propagation; whole-row click toggles.
Entry list render_plan_entries :3794-3868: .max_h_40().overflow_y_scroll() :3801-3803; rows py_1 px_2 gap_2 justify_between bg(editor_background), bottom border except last :3811-3821; row tooltip = raw markdown source :3808-3809,3866; right-edge fade w_8 :3859-3865. Status → icon :3830-3852: InProgress → IconName::TodoProgress, Color::Accent, with_rotate_animation(2) (2 s); Completed → TodoComplete, Color::Success; Pending | _ → TodoPending, Color::Muted. Strikethrough only for Completed (plan_label_markdown_style agent_ui/src/conversation_view.rs:3666-3688: thickness px(1.), color text_muted.opacity(0.8)); all plan text is text_muted regardless of status. Priority renders NOTHING — stored (:1999, updated :3591) but never read by UI.
Completed-plan transcript card render_completed_plan :3870-3938, dispatched :6449-6451: px_5 py_1p5, rounded_md, border_1 tool_card_border_color; header bg(tool_card_header_bg), "Completed Plan" + "— {n} step(s)"; rows always TodoComplete/Success, text_xs text_muted, default_markdown_style — no strikethrough; not collapsible.
Storage (acp_thread.rs): token_usage: Option<TokenUsage> :2106, cost: Option<SessionCost> :2107; TokenUsage { max_tokens, used_tokens, input_tokens, output_tokens, max_output_tokens: Option<u64> } :2013-2020; SessionCost { amount: f64, currency: SharedString } :2022-2026; event TokenUsageUpdated :2160. UsageUpdate is an absolute snapshot (REPLACE) for size/used; cost is sticky (replaced only when present) :2639-2651. PromptResponse.usage fills input_tokens/output_tokens without clobbering max/used (behind AcpBetaFeatureFlag) :3867-3874, test test_response_usage_does_not_clobber_session_usage :10051-10103. update_token_usage(None) clears cost :3103-3109. TOKEN_USAGE_WARNING_THRESHOLD = 0.8 :2028; ratio() → Normal if max == 0, Exceeded if used >= max, Warning if ≥ 0.8 :2030-2052.
Ring indicator in the message-editor toolbar bottom row, left of profile/config/mode/model selectors and the send button thread_view.rs:4441-4452 (inside render_message_editor :4331). render_token_usage :4682-4863, None when no usage :4684. ring_size px(16), stroke px(2) :4715-4716; progress_ratio = used/max :4709-4713; color: single break at 0.85 → status().warning, else text_muted :4696-4702 (NOT the 0.8 callout threshold; no red state). Two layouts: split (show_split = supports_split_token_display() :4676-4685, native-thread models only) with ArrowUp input ring input/(max - max_output) and ArrowDown output ring output/max_output :4785-4841; single CircularProgress::new(used, max, 16px) :4845-4862. Percentage "{round(ratio*100)}%" :4718; humanize_token_count agent_ui/src/agent_ui.rs:550-570 (3.4k, 42k). Tooltip TokenUsageTooltip :5709-5810: min_w_40, header "Context", "{pct}" • "{used}" / "{max}" with • colored text_disabled.opacity(0.6); split rows "Input:"/"Output:"; then a top-bordered "Cost" section; then AGENTS.md/project-rules section.
Cost format :4687-4694: precision = (0 < amount < 0.01) ? 4 : 2; "{amount:.prec$} {currency}" → "0.42 USD", "0.0034 USD" (currency code trailing, not a symbol). Only shown in the tooltip. Round-trip test acp_thread.rs:9922-9941.
Token-limit callout render_token_limit_callout :11825-11883: suppressed when dismissed, when NOT a native thread (as_native_thread(cx).is_none() :11826 — external ACP agents never get it), or when max_tokens >= MIN_COMPACTION_CONTEXT_WINDOW :11837-11839. Warning → Severity::Warning, IconName::Warning, "Thread reaching the token limit soon"; Exceeded → Error, XCircle, "Thread reached the token limit" :11843-11855; description "To continue, run /compact or start a new thread and @-mention this one" :11857; button "Start New Thread" :11866-11879; rendered between the thread error and the editor :12477. Telemetry strings "warning"/"exceeded" :1423-1451.
Selector choice is mutually exclusive agent_ui/src/conversation_view.rs:1313-1350: if connection.session_config_options(session_id) is Some → only ConfigOptionsView, mode_selector = None; model_selector = None ("Config options take precedence over legacy mode/model selectors"); else ModelSelectorPopover + ModeSelector. Trait hooks AgentConnection::session_modes / session_config_options acp_thread/src/connection.rs:239-252.
mode_selector.rs (303 lines): ModeSelector { connection: Rc<dyn AgentSessionModes>, agent_server, menu_handle, fs, setting_mode: bool } :16-22; trait AgentSessionModes { current_mode(), all_modes(), set_mode(id, cx) -> Task } connection.rs:303-309. The view holds NO local mode state: CurrentModeUpdate → AcpThreadEvent::ModeUpdated acp_thread.rs:2632-2635; UI handler just cx.notify() ("The connection keeps track of the mode") conversation_view.rs:1868-1871; render re-reads connection.current_mode() :134, label falls back to "Unknown" :134-141. Trigger Button("mode-selector-trigger", name) LabelSize::Small, Color::Muted, end icon ChevronUp/Down by is_deployed(), .disabled(setting_mode) :145-155. Popover anchor(BottomRight), offset (0, -2px) :157-194; tooltip rows "Change Mode" / ToggleProfileSelector and "Cycle Through Modes" / CycleModeSelector :160-183. Menu build_context_menu :86-129: ContextMenuEntry(mode.name).toggleable(IconPosition::End, is_selected), documentation_aside with description :104-112, key context "ModeSelector" :127. set_mode :65-84: (1) persist agent_server.set_default_mode (agent_servers/src/custom.rs:127-142 → agent_servers.<id>.default_mode), (2) connection.set_mode task; setting_mode = true; on error only log::error! — no surface, no rollback. cycle_mode :43-59 wraps (i+1) % len. Actions agent_ui/src/agent_ui.rs:213-226: ToggleProfileSelector (config picker Mode → profile → mode menu thread_view.rs:12360-12379), CycleModeSelector (no-op unless ThreadStatus::Idle :12380-12406), ToggleModelSelector/CycleFavoriteModels (Model, favorites only :12407-12455).
config_options.rs (1375 lines): ConfigOptionsView is one child in the same toolbar row as the ring and send button thread_view.rs:4445-4451; renders h_flex().min_w_0().flex_wrap().gap_1().children(selectors) :272-286. ConfigOptionUpdate → ConfigOptionsUpdated event acp_thread.rs:2636-2639; view only cx.notify() conversation_view.rs:1872-1875; a spawned loop over config_options.watch(cx) (connection.rs:326-328) calls rebuild_selectors :53-63,229-241. No cached option state: every render calls config_options() and finds by id :355-359. Widget per kind :445-608: Select → PickerPopoverMenu with Button trigger :452-535; Boolean → ui::Switch with leading label :543-604; anything else (incl. string) → empty div :605 (no text-input widget; extract_options empty :900-936, count_config_options 0 :1085-1096, can_cycle false :145-152). Select trigger :407-441: current value NAME (find_option_name, fallback "Unknown" :378-390) truncated to 32 graphemes + … :425-432, Small/Muted, chevron by picker_handle.is_deployed(), .disabled(setting_value), id config-option-{id}. Tooltip :469-527: name, description, and — only for the FIRST Select in a category (handles_category_keybindings :392-405) — keybinding rows: Mode → "Change Mode"/"Cycle Through Modes"; Model → "Change Model"/"Cycle Favorite Models"; ThoughtLevel → "Change Thinking Effort"/"Cycle Thinking Effort" :490-524. Picker :315-348: PICKER_THRESHOLD = 5 :31, searchable when ≥ 5 (Picker::list vs nonsearchable_list :337-341), initial_width rems(20), placeholder "Select an option…" :727-729, anchor BottomRight :530-536. Rows render_match :809-895: ListItem.inset(true).spacing(Sparse), Check/Accent end slot when current :857-859, hover star favorite toggle :861-887, hover sets selected_description → DocumentationAside :836-849,897-916. Grouping :987-1024: "Favorites" section, then "All Options" or per-group.name separators (non-selectable :721-726; px_2 py_1 text_xs muted :811-822); handles Ungrouped/Grouped :912-935. Three change paths, same two-step (persist default, then set_config_option, errors only logged): picker confirm :776-807 (AgentConfigOptionValue::ValueId, then SessionConfigOptionValue::value_id, then DismissEvent); Switch on_click :586-603 (Boolean(next)); keyboard cycle_category_option :232-267 (Select wraps, Boolean toggles when !favorites_only; next_value_for_config :164-218). set_config_option returns the full updated list connection.rs:311-322; persistence agent_servers.<id>.default_config_options custom.rs:158-190. Switch: ToggleState from current_value :571-576, .label(name).label_position(Start).label_size(Small).label_color(Muted).disabled(setting_value) :578-586; Boolean tooltips have no keybinding rows :544-556.
Slash commands (completion_provider.rs): AvailableCommandsUpdate replaces the list wholesale acp_thread.rs:2624-2631; accessor :2342-2344; UI copies into session_capabilities and updates the placeholder conversation_view.rs:1835-1866. Placeholder :3334-3348: "Message {agent} — @ to include context, / for commands" when has_commands, else without the / clause (has_slash_completions = !commands.is_empty() || !skills.is_empty() :1847, message_editor.rs:91-93). ACP → UI mapping message_editor.rs:111-122: requires_argument = command.input.is_some(), category from a _meta key (COMMAND_CATEGORY_META_KEY, "native"/"mcp" acp_thread.rs:85-121; external agents send none → None). Group headers :396-409: Native → "Commands", Mcp → "MCP Server Commands", None → "Commands"; category_order Native 0, Mcp 1, None 2 :387-394. Trigger is NOT leading-/ only: SlashCommandCompletion::try_parse :2022-2071 scans right-to-left for a / not followed by whitespace and at col 0 or preceded by whitespace — "Lorem /help" triggers (test :2944-2960), last command wins; parses command + trimmed argument :2050-2061. is_completion_trigger :1896-1930 refuses to open once an argument is typed ("we don't support completing arguments"); sort_completions/filter_completions both false :1933-1939. Candidates search_slash_commands :1011-1068: slash_autocomplete_invoked first (lazy), union of commands/skills/local commands, fuzzy on NAME only, cap 100. Ordering: fuzzy score then group_by_relevance (groups contiguous, ordered by best member) :1522-1524,441-450; group keys Skill 0, Command 1+category, LocalCommand 4 :431-437. Menu row :1553-1613: label = name (+ {source} styled with the variable highlight, excluded from the filter range) :2561-2593 — description is NOT inline; documentation panel = MultiLinePlainText(description) :1590-1594; icon only for native /compact :1585-1588; section headers only while argument.is_none() :1454,1584; dynamic_width: true, is_incomplete: true :1665-1671. Accept :1556-1611: new_text with trailing space, "/{source}:{name} " when sourced :1557-1568; is_missing_argument = requires_argument && argument.is_none() :1582; confirm returns false and, only when the argument requirement is satisfied, cx.defer(confirm_command) — a no-argument command submits immediately; one needing an argument just inserts text :1595-1610. Skills render as mention links :1476-1500,1526-1552. Local commands PromptLocalCommand :193-235 (helpful/not-helpful, "Positive/Negative Feedback", ThumbsUp/Down; group "Actions"; accept erases the text and runs run_local_command :1618-1660). Native commands bypass the prompt on send: leading_native_command thread_view.rs:12640-12653 requires CommandCategory::Native; send() then uses send_command_queueing_remainder (bare command turn, trailing text queued as a follow-up) :1540-1557; MCP/external excluded because their trailing text is a real argument :12634-12639. Bare /login and /logout are intercepted client-side for auth when the agent has auth methods and no explicit logout command :1505-1537.
# Zed ACP client — plan rendering, usage/cost, modes, config options, slash commands
Deep-dive companion to the Zed lane report. Repo `zed-industries/zed` @ 8514ce3 (2026-09-02), sparse local clone `/Users/robertboulos/projects/cloned-repos/zed` (the `ui` crate is NOT in the clone — `CircularProgress`, `Disclosure`, `Switch`, `Callout` internals only via call sites). Paths relative to `crates/`. All [src]. Extracted 2026-09-02.
## A. Plan rendering
**Model** (`acp_thread/src/acp_thread.rs`): `Plan { entries: Vec<PlanEntry> }` `:1952-1954`; `PlanEntry { content: Entity<Markdown>, priority, status }` `:1996-2001` (content is Markdown, `from_acp` `:2003-2011`); `PlanStats { in_progress_entry: Option<&PlanEntry>, pending, completed }` `:1957-1962`. **`Plan::stats()` `:1969-1995`: `InProgress` counts toward `pending`** and `in_progress_entry` is the FIRST in-progress entry. Plan is a field on the thread (`plan: Plan` `:2097`), not an entry.
**A plan update REPLACES the plan positionally, recycling markdown entities to avoid flicker** — `update_plan` `:3577-3600`: zip old/new → replace content, priority, status in place; push extras; `truncate(new_len)`. Dispatch `SessionUpdate::Plan(plan) => update_plan` `:2610-2612`. Three more mutations to replicate: (1) on each new turn `clear_completed_plan_entries` drops `Completed` entries `:3609-3614` (called from `run_turn` `:3739`); (2) on turn end (not cancelled) if non-empty and `stats().pending == 0`, the plan is `mem::take`n into the transcript as `AgentThreadEntry::CompletedPlan(entries)` `:3602-3607`, call site `:3827-3829`; (3) `clear_plan()` `:3616-3619`. Markdown export renders `- [x]` under `## Plan` `:806-814`. E2E test to mirror: `agent_ui/src/conversation_view.rs:5624-5681`.
**Where it renders: a sticky "activity bar" above the message editor, collapsed by default.** `plan_expanded: bool` on `ThreadView` `agent_ui/src/conversation_view/thread_view.rs:603`, default `false` `:1018` (`edits_expanded: false` `:1017`, `queue_expanded: true` `:1019`). `render_activity_bar` `:3084-3195` returns `None` when plan + changed buffers + queue are empty and no permission request `:3100-3106`. Sibling of the scrolling conversation list, before the editor: `.child(conversation) … .children(render_activity_bar) … .child(render_message_editor)` `:12453-12481`. Chrome: centered, `flex_basis(max_content_width)`, `bg(activity_bar_bg)`, `border_1().border_b_0()`, `rounded_t_md()`, shadow only on opaque windows `:3126-3148`. Section order: awaiting-permission → plan → divider → edits → divider → message queue `:3149-3189`.
**Summary row** `render_plan_summary` `:3687-3792`. Mode 1, collapsed with an in-progress entry `:3696-3739`: `Label "Current:"` (Small/Muted) + the entry's markdown `text_xs`, `text_muted`, `line_clamp(1)`; right, when `pending > 0`: fade gradient + `"{pending} left"` (pending INCLUDES the in-progress one). Mode 2, expanded or no in-progress `:3740-3767`: `status_label = pending == 0 ? "All Done" : completed == 0 ? "{len} Tasks" : "{completed}/{len}"`; row `Label "Plan"` left, status right. Row chrome `:3768-3791`: `Disclosure("plan_disclosure", plan_expanded)` left; right `IconButton("dismiss-plan", Close)` XSmall/Square, tooltip "Clear Plan", `clear_plan` + `stop_propagation`; whole-row click toggles.
**Entry list** `render_plan_entries` `:3794-3868`: `.max_h_40().overflow_y_scroll()` `:3801-3803`; rows `py_1 px_2 gap_2 justify_between bg(editor_background)`, bottom border except last `:3811-3821`; row tooltip = raw markdown source `:3808-3809,3866`; right-edge fade `w_8` `:3859-3865`. **Status → icon** `:3830-3852`: `InProgress` → `IconName::TodoProgress`, `Color::Accent`, `with_rotate_animation(2)` (2 s); `Completed` → `TodoComplete`, `Color::Success`; `Pending | _` → `TodoPending`, `Color::Muted`. **Strikethrough only for Completed** (`plan_label_markdown_style` `agent_ui/src/conversation_view.rs:3666-3688`: `thickness px(1.)`, color `text_muted.opacity(0.8)`); all plan text is `text_muted` regardless of status. **Priority renders NOTHING** — stored (`:1999`, updated `:3591`) but never read by UI.
**Completed-plan transcript card** `render_completed_plan` `:3870-3938`, dispatched `:6449-6451`: `px_5 py_1p5`, `rounded_md`, `border_1` `tool_card_border_color`; header `bg(tool_card_header_bg)`, `"Completed Plan"` + `"— {n} step(s)"`; rows always `TodoComplete`/`Success`, `text_xs` `text_muted`, `default_markdown_style` — no strikethrough; not collapsible.
## B. Usage / cost / context window
**Storage** (`acp_thread.rs`): `token_usage: Option<TokenUsage>` `:2106`, `cost: Option<SessionCost>` `:2107`; `TokenUsage { max_tokens, used_tokens, input_tokens, output_tokens, max_output_tokens: Option<u64> }` `:2013-2020`; `SessionCost { amount: f64, currency: SharedString }` `:2022-2026`; event `TokenUsageUpdated` `:2160`. **`UsageUpdate` is an absolute snapshot (REPLACE) for `size`/`used`; cost is sticky (replaced only when present)** `:2639-2651`. `PromptResponse.usage` fills `input_tokens`/`output_tokens` without clobbering `max/used` (behind `AcpBetaFeatureFlag`) `:3867-3874`, test `test_response_usage_does_not_clobber_session_usage` `:10051-10103`. `update_token_usage(None)` clears cost `:3103-3109`. `TOKEN_USAGE_WARNING_THRESHOLD = 0.8` `:2028`; `ratio()` → `Normal` if `max == 0`, `Exceeded` if `used >= max`, `Warning` if `≥ 0.8` `:2030-2052`.
**Ring indicator** in the message-editor toolbar bottom row, left of profile/config/mode/model selectors and the send button `thread_view.rs:4441-4452` (inside `render_message_editor` `:4331`). `render_token_usage` `:4682-4863`, `None` when no usage `:4684`. `ring_size px(16)`, `stroke px(2)` `:4715-4716`; `progress_ratio = used/max` `:4709-4713`; **color: single break at 0.85 → `status().warning`, else `text_muted`** `:4696-4702` (NOT the 0.8 callout threshold; no red state). Two layouts: split (`show_split = supports_split_token_display()` `:4676-4685`, native-thread models only) with `ArrowUp` input ring `input/(max - max_output)` and `ArrowDown` output ring `output/max_output` `:4785-4841`; single `CircularProgress::new(used, max, 16px)` `:4845-4862`. Percentage `"{round(ratio*100)}%"` `:4718`; `humanize_token_count` `agent_ui/src/agent_ui.rs:550-570` (`3.4k`, `42k`). **Tooltip** `TokenUsageTooltip` `:5709-5810`: `min_w_40`, header "Context", `"{pct}" • "{used}" / "{max}"` with `•` colored `text_disabled.opacity(0.6)`; split rows "Input:"/"Output:"; then a top-bordered "Cost" section; then AGENTS.md/project-rules section.
**Cost format** `:4687-4694`: `precision = (0 < amount < 0.01) ? 4 : 2`; `"{amount:.prec$} {currency}"` → `"0.42 USD"`, `"0.0034 USD"` (currency code trailing, not a symbol). Only shown in the tooltip. Round-trip test `acp_thread.rs:9922-9941`.
**Token-limit callout** `render_token_limit_callout` `:11825-11883`: suppressed when dismissed, when NOT a native thread (`as_native_thread(cx).is_none()` `:11826` — external ACP agents never get it), or when `max_tokens >= MIN_COMPACTION_CONTEXT_WINDOW` `:11837-11839`. `Warning` → `Severity::Warning`, `IconName::Warning`, "Thread reaching the token limit soon"; `Exceeded` → `Error`, `XCircle`, "Thread reached the token limit" `:11843-11855`; description "To continue, run /compact or start a new thread and @-mention this one" `:11857`; button "Start New Thread" `:11866-11879`; rendered between the thread error and the editor `:12477`. Telemetry strings `"warning"`/`"exceeded"` `:1423-1451`.
## C. Modes, config options, slash commands
**Selector choice is mutually exclusive** `agent_ui/src/conversation_view.rs:1313-1350`: if `connection.session_config_options(session_id)` is `Some` → only `ConfigOptionsView`, `mode_selector = None; model_selector = None` ("Config options take precedence over legacy mode/model selectors"); else `ModelSelectorPopover` + `ModeSelector`. Trait hooks `AgentConnection::session_modes` / `session_config_options` `acp_thread/src/connection.rs:239-252`.
**`mode_selector.rs`** (303 lines): `ModeSelector { connection: Rc<dyn AgentSessionModes>, agent_server, menu_handle, fs, setting_mode: bool }` `:16-22`; trait `AgentSessionModes { current_mode(), all_modes(), set_mode(id, cx) -> Task }` `connection.rs:303-309`. **The view holds NO local mode state**: `CurrentModeUpdate` → `AcpThreadEvent::ModeUpdated` `acp_thread.rs:2632-2635`; UI handler just `cx.notify()` ("The connection keeps track of the mode") `conversation_view.rs:1868-1871`; render re-reads `connection.current_mode()` `:134`, label falls back to `"Unknown"` `:134-141`. Trigger `Button("mode-selector-trigger", name)` `LabelSize::Small`, `Color::Muted`, end icon `ChevronUp/Down` by `is_deployed()`, `.disabled(setting_mode)` `:145-155`. Popover `anchor(BottomRight)`, offset `(0, -2px)` `:157-194`; tooltip rows "Change Mode" / `ToggleProfileSelector` and "Cycle Through Modes" / `CycleModeSelector` `:160-183`. Menu `build_context_menu` `:86-129`: `ContextMenuEntry(mode.name).toggleable(IconPosition::End, is_selected)`, `documentation_aside` with description `:104-112`, key context `"ModeSelector"` `:127`. **`set_mode` `:65-84`: (1) persist `agent_server.set_default_mode` (`agent_servers/src/custom.rs:127-142` → `agent_servers.<id>.default_mode`), (2) `connection.set_mode` task; `setting_mode = true`; on error only `log::error!` — no surface, no rollback.** `cycle_mode` `:43-59` wraps `(i+1) % len`. Actions `agent_ui/src/agent_ui.rs:213-226`: `ToggleProfileSelector` (config picker Mode → profile → mode menu `thread_view.rs:12360-12379`), `CycleModeSelector` (**no-op unless `ThreadStatus::Idle`** `:12380-12406`), `ToggleModelSelector`/`CycleFavoriteModels` (Model, favorites only `:12407-12455`).
**`config_options.rs`** (1375 lines): `ConfigOptionsView` is one child in the same toolbar row as the ring and send button `thread_view.rs:4445-4451`; renders `h_flex().min_w_0().flex_wrap().gap_1().children(selectors)` `:272-286`. `ConfigOptionUpdate` → `ConfigOptionsUpdated` event `acp_thread.rs:2636-2639`; view only `cx.notify()` `conversation_view.rs:1872-1875`; a spawned loop over `config_options.watch(cx)` (`connection.rs:326-328`) calls `rebuild_selectors` `:53-63,229-241`. **No cached option state**: every render calls `config_options()` and finds by id `:355-359`. **Widget per kind** `:445-608`: `Select` → `PickerPopoverMenu` with Button trigger `:452-535`; `Boolean` → `ui::Switch` with leading label `:543-604`; **anything else (incl. string) → empty `div`** `:605` (no text-input widget; `extract_options` empty `:900-936`, `count_config_options` 0 `:1085-1096`, `can_cycle` false `:145-152`). Select trigger `:407-441`: current value NAME (`find_option_name`, fallback `"Unknown"` `:378-390`) truncated to 32 graphemes + `…` `:425-432`, Small/Muted, chevron by `picker_handle.is_deployed()`, `.disabled(setting_value)`, id `config-option-{id}`. Tooltip `:469-527`: name, description, and — only for the FIRST Select in a category (`handles_category_keybindings` `:392-405`) — keybinding rows: Mode → "Change Mode"/"Cycle Through Modes"; Model → "Change Model"/"Cycle Favorite Models"; ThoughtLevel → "Change Thinking Effort"/"Cycle Thinking Effort" `:490-524`. Picker `:315-348`: `PICKER_THRESHOLD = 5` `:31`, searchable when `≥ 5` (`Picker::list` vs `nonsearchable_list` `:337-341`), `initial_width rems(20)`, placeholder "Select an option…" `:727-729`, anchor BottomRight `:530-536`. Rows `render_match` `:809-895`: `ListItem.inset(true).spacing(Sparse)`, `Check`/Accent end slot when current `:857-859`, hover star favorite toggle `:861-887`, hover sets `selected_description` → `DocumentationAside` `:836-849,897-916`. Grouping `:987-1024`: "Favorites" section, then "All Options" or per-`group.name` separators (non-selectable `:721-726`; `px_2 py_1 text_xs` muted `:811-822`); handles `Ungrouped`/`Grouped` `:912-935`. **Three change paths, same two-step (persist default, then `set_config_option`, errors only logged):** picker confirm `:776-807` (`AgentConfigOptionValue::ValueId`, then `SessionConfigOptionValue::value_id`, then `DismissEvent`); Switch `on_click` `:586-603` (`Boolean(next)`); keyboard `cycle_category_option` `:232-267` (Select wraps, Boolean toggles when `!favorites_only`; `next_value_for_config` `:164-218`). `set_config_option` returns the full updated list `connection.rs:311-322`; persistence `agent_servers.<id>.default_config_options` `custom.rs:158-190`. Switch: `ToggleState` from `current_value` `:571-576`, `.label(name).label_position(Start).label_size(Small).label_color(Muted).disabled(setting_value)` `:578-586`; Boolean tooltips have no keybinding rows `:544-556`.
**Slash commands** (`completion_provider.rs`): `AvailableCommandsUpdate` replaces the list wholesale `acp_thread.rs:2624-2631`; accessor `:2342-2344`; UI copies into `session_capabilities` and updates the placeholder `conversation_view.rs:1835-1866`. Placeholder `:3334-3348`: `"Message {agent} — @ to include context, / for commands"` when `has_commands`, else without the `/` clause (`has_slash_completions = !commands.is_empty() || !skills.is_empty()` `:1847`, `message_editor.rs:91-93`). ACP → UI mapping `message_editor.rs:111-122`: `requires_argument = command.input.is_some()`, `category` from a **`_meta` key** (`COMMAND_CATEGORY_META_KEY`, `"native"`/`"mcp"` `acp_thread.rs:85-121`; external agents send none → `None`). Group headers `:396-409`: Native → "Commands", Mcp → "MCP Server Commands", None → "Commands"; `category_order` Native 0, Mcp 1, None 2 `:387-394`. **Trigger is NOT leading-`/` only**: `SlashCommandCompletion::try_parse` `:2022-2071` scans right-to-left for a `/` not followed by whitespace and at col 0 or preceded by whitespace — `"Lorem /help"` triggers (test `:2944-2960`), last command wins; parses `command` + trimmed `argument` `:2050-2061`. `is_completion_trigger` `:1896-1930` refuses to open once an argument is typed ("we don't support completing arguments"); `sort_completions`/`filter_completions` both `false` `:1933-1939`. Candidates `search_slash_commands` `:1011-1068`: `slash_autocomplete_invoked` first (lazy), union of commands/skills/local commands, fuzzy on NAME only, cap 100. Ordering: fuzzy score then `group_by_relevance` (groups contiguous, ordered by best member) `:1522-1524,441-450`; group keys Skill 0, Command 1+category, LocalCommand 4 `:431-437`. Menu row `:1553-1613`: label = name (+ ` {source}` styled with the `variable` highlight, excluded from the filter range) `:2561-2593` — **description is NOT inline**; documentation panel = `MultiLinePlainText(description)` `:1590-1594`; icon only for native `/compact` `:1585-1588`; section headers only while `argument.is_none()` `:1454,1584`; `dynamic_width: true`, `is_incomplete: true` `:1665-1671`. **Accept** `:1556-1611`: `new_text` with trailing space, `"/{source}:{name} "` when sourced `:1557-1568`; `is_missing_argument = requires_argument && argument.is_none()` `:1582`; confirm returns `false` and, only when the argument requirement is satisfied, `cx.defer(confirm_command)` — **a no-argument command submits immediately; one needing an argument just inserts text** `:1595-1610`. Skills render as mention links `:1476-1500,1526-1552`. Local commands `PromptLocalCommand` `:193-235` (`helpful`/`not-helpful`, "Positive/Negative Feedback", ThumbsUp/Down; group "Actions"; accept erases the text and runs `run_local_command` `:1618-1660`). **Native commands bypass the prompt on send**: `leading_native_command` `thread_view.rs:12640-12653` requires `CommandCategory::Native`; `send()` then uses `send_command_queueing_remainder` (bare command turn, trailing text queued as a follow-up) `:1540-1557`; MCP/external excluded because their trailing text is a real argument `:12634-12639`. Bare `/login` and `/logout` are intercepted client-side for auth when the agent has auth methods and no explicit `logout` command `:1505-1537`.