# Nucleic — Backend Adapter Internals (v0)

Message-by-message design for the two production adapters that implement `AgentBackend`
([BACKEND_PROTOCOL](BACKEND_PROTOCOL.md)): **ClaudeCodeAdapter** (stream-json + an in-process
MCP approval server) and **CodexAppServerAdapter** (JSON-RPC), plus the **CodexExecAdapter**
fallback. This is the deepest layer; everything here is adapter-private and absorbs CLI churn.

**Confidence key:** ✅ documented/stable · 🟡 observed/SDK-derived, pin before shipping ·
🔴 inferred, **the M0 spike must capture a real sample**. Treat 🔴/🟡 as the validation
backlog, not as fact.

> **Source-validation pass (2026-06-11).** Most former 🔴/🟡 items are now resolved against
> primary sources and retagged ✅ inline. Key resolutions:
> - **Claude permission tool wire contract** — CONFIRMED from the official Claude Code SDK docs
>   ("Custom permission prompt tool", archived 2025-05-31) + the shipping
>   `@anthropic-ai/claude-agent-sdk` `sdk.d.ts` (`PermissionResult`): input `{tool_name, input,
>   tool_use_id?}` (snake_case), return a **JSON-stringified** `{behavior,…}` in an MCP text
>   block. `behavior`/`message`/`updatedInput` — *not* `decision`/`reason`.
> - **Codex app-server** — CONFIRMED verbatim from `openai/codex` source
>   (`codex-rs/app-server-protocol/src/protocol/`, commit `7a19b14`): real methods are
>   `thread/start`, `turn/start`, `turn/interrupt`, `thread/resume` (not `newThread`/`startTurn`)
>   and the v2 approval decision enum is `accept`/`acceptForSession`/`decline`/`cancel`.
> - **Codex exec** — CONFIRMED from `codex-rs/exec/src/exec_events.rs` (rust-v0.139.0): the
>   exec JSONL surface is **snake_case** and distinct from app-server's camelCase.
>
> The genuinely residual unknowns (need a live capture, not docs) are listed in §6.

---

## 0. Shared infrastructure

### 0.1 ProcessHost contract

Both adapters spawn through `ProcessHost` (RUNTIME §3):

```swift
struct ProcessSpec { let executable: String; let args: [String]; let cwd: String
                     let env: [String:String]; let stdinMode: StdinMode }   // .pipe | .closed
protocol ProcessHandle: Sendable {
    var stdoutLines: AsyncThrowingStream<Data, Error> { get }   // NDJSON, one line per element
    var stderrLines: AsyncThrowingStream<Data, Error> { get }
    func writeLine(_ data: Data) async throws                   // appends '\n'
    func sendSignal(_ sig: Int32)                               // SIGINT / SIGTERM
    func wait() async -> Int32                                  // exit code
}
```

- **Line framing:** a buffered reader splits on `\n`, holding partial trailing bytes across
  reads (NDJSON messages can exceed a pipe-read). Lines are UTF-8 `Data`, decoded leniently.
- **Buffering:** child stdio is line-buffered; we never block the writer waiting on the reader
  (BACKEND_PROTOCOL note 7).

### 0.2 JSON-RPC client (Codex)

A small JSON-RPC 2.0 layer over `ProcessHandle` stdio:
- outbound **requests** correlated by `id` → `await`ed continuations;
- inbound **notifications** (no `id`) → event stream;
- inbound **server→client requests** (have `id` + `method`) → dispatched to handlers that must
  reply with a result keyed by the same `id`. **Approvals arrive this way.**

### 0.3 Lenient decoding

Every decoder tolerates unknown fields and unknown enum cases (→ `AgentEvent.raw`). No adapter
ever traps on an unrecognized native message; it logs + passes through.

### 0.4 `seq`

Adapters emit events in arrival order with a provisional local index; the **canonical `seq` is
assigned by `TranscriptWriter`** at append (RUNTIME §2.1). Adapters do not persist `seq`.

---

## 1. ClaudeCodeAdapter

### 1.1 Invocation ✅(flags) 🟡(exact combo)

```
claude
  -p
  --output-format stream-json
  --input-format  stream-json
  --verbose
  [--include-partial-messages]                  # when verbosity == .full
  --permission-prompt-tool mcp__nucleic__approve
  --mcp-config <generated.json>                 # registers our in-process server
  [--model <id>] [--append-system-prompt <s>]
  [--add-dir <path> ...]
  [--resume <backendSessionID> [--fork-session]]
cwd = worktree.path
stdin = open pipe (NDJSON user messages)
```

✅ The SDK option name is `permissionPromptToolName`; the CLI flag is `--permission-prompt-tool`.
✅ **Eval order** (official docs): `settings.json` / `--allowedTools` / `--disallowedTools` are
checked **before** the permission tool is invoked. Optimization: pre-allow cheap read-only tools
(`Read`/`Glob`/`Grep`) via `--allowedTools` so they never round-trip through our MCP server — the
`approve` tool only fires for genuinely gated actions.

### 1.2 In-process MCP approval server — the bridge ✅(contract) 🟡(transport choice)

The cleverest piece. Claude must call *our* code when a tool is gated, and that call must block
until a human (Mac or iPhone) answers. We do this with a **localhost HTTP MCP server hosted
inside the app**, registered via `--mcp-config`:

```jsonc
// generated.json
{
  "mcpServers": {
    "nucleic": {
      "type": "http",
      "url": "http://127.0.0.1:<ephemeralPort>/mcp",
      "headers": { "Authorization": "Bearer <perSessionToken>" }
    }
  }
}
```

- Bound to `127.0.0.1`, ephemeral port, **per-session bearer token** so only this `claude`
  process can call it. One server may serve all sessions, keyed by token → `SessionID`.
- Implements minimal MCP: `initialize`, `tools/list` (advertises one tool `approve`),
  `tools/call`.
- ✅ `approve` **input** schema (CONFIRMED — fields are snake_case on the wire):

```jsonc
{ "tool_name": "Bash", "input": { "command": "rm -rf build/" }, "tool_use_id": "toolu_…" }
// tool_use_id is optional; there is NO "description" field (earlier guess was wrong)
```

- Handler flow:

```
tools/call(approve, {tool_name, input, tool_use_id?}) arrives
  → look up SessionID from bearer token
  → consult always_rule (RUNTIME §5): match? return mapped allow immediately, no human
  → else: ApprovalCoordinator.await(ApprovalRequest)   // SUSPENDS the HTTP response
         (this is what emits AgentEvent.approvalRequested into the pipeline)
  → on resolve(Decision): map → reply text content (below), return tools/call result
```

- ✅ `approve` **return** (CONFIRMED): a normal MCP `CallToolResult` with **one text content
  block** whose `text` is the **JSON-stringified** decision object:

```jsonc
// the MCP result envelope:
{ "content": [ { "type": "text", "text": "<JSON-stringified object below>" } ] }

// allow — updatedInput is expected even when unchanged (echo the original input back):
{ "behavior": "allow", "updatedInput": { "command": "rm -rf build/" } }
// deny:
{ "behavior": "deny", "message": "Denied: publish not permitted" }
```

> ✅ **Resolved (was the top 🔴).** Keys are `behavior` (`"allow"`/`"deny"`), `message` on deny,
> `updatedInput` on allow — **not** `decision`/`reason`. The object must be JSON-stringified
> inside the text block. `updatedInput` is type-optional in the SDK `PermissionResult` but the
> docs direct you to always include it (echo the original if unmodified). Source: Claude Code
> SDK "Custom permission prompt tool" (archived 2025-05-31) + `@anthropic-ai/claude-agent-sdk`
> `sdk.d.ts`. The in-process `PermissionResult` also has `interrupt?`/`updatedPermissions?`/
> `toolUseID?`, but the MCP wire shape is the two cases above.

- **Cancel-run** maps to `behavior:deny` + `interrupt()` (§1.5).
- **Timeout/abandon:** if the session is killed while a call is suspended, the server returns
  `behavior:deny` so `claude` exits cleanly.

### 1.3 stdin — prompts & follow-ups 🟡

Initial prompt and each follow-up turn are written as NDJSON user messages:

```jsonc
{"type":"user","message":{"role":"user","content":[{"type":"text","text":"Refactor the auth layer…"}]}}
```

(🟡 exact envelope — `{"type":"user","content":"…"}` vs. the nested `message.content` block
form — pin in M0.) `AgentInput.context` parts are rendered as additional text blocks with a
labeled prefix. We never feed `tool_result` on stdin — tools execute inside `claude` itself.

### 1.4 stdout — native → AgentEvent

Without `--include-partial-messages`, the canonical stream is **whole messages** (✅); with it,
additional `stream_event` partials interleave (🟡). The adapter handles both.

| Native line | Decode | → `AgentEvent.kind` |
| --- | --- | --- |
| `{"type":"system","subtype":"init","session_id":…}` | ✅ | `sessionStarted` (capture `session_id`, model, tools, cwd) |
| `{"type":"assistant","message":{content:[{type:"text",text}]}}` | ✅ | `assistantText(isPartial:false)` |
| …`content:[{type:"thinking",…}]` | 🟡 | `thinking` |
| …`content:[{type:"tool_use",id,name,input}]` | ✅ | `toolCallStarted` + `toolCallCompleted` |
| `{"type":"user","message":{content:[{type:"tool_result",tool_use_id,content,is_error}]}}` | ✅ | `toolResult` |
| `{"type":"stream_event","event":{type:"content_block_delta",delta:{type:"text_delta",text}}}` | 🟡 | `assistantText(isPartial:true)` |
| …`delta:{type:"input_json_delta",partial_json}` | 🟡 | `toolCallInputDelta` |
| `{"type":"system","subtype":"api_retry",…}` | 🟡 | `error(recoverable:true)` |
| `{"type":"result","result",usage,total_cost_usd,duration_ms,session_id}` | ✅ | `runFinished` + `usage` |

- **fileChange synthesis:** on `tool_use` for `Edit`/`Write`/`MultiEdit`/`NotebookEdit`, emit
  `fileChange` (path from input; `emitsFileChangeEvents=false` flags it inferred). Authoritative
  diff still comes from `WorktreeManager`.
- **Partial assembly:** `content_block_start(tool_use)` → accumulate `input_json_delta` →
  finalize on `content_block_stop` into `toolCallCompleted`.

### 1.5 resume / interrupt / shutdown

- **resume:** relaunch with `--resume <backendSessionID>` (+ `--fork-session` if
  `ResumeSpec.fork`); same stdin/stdout wiring; UI history already restored from our transcript.
- **interrupt:** `SIGINT` to the process; the stream closes; a final `result` may not arrive,
  so the adapter emits `runFinished(outcome:.interrupted)` itself.
- **shutdown:** close stdin, `SIGTERM`, await exit, tear down the MCP token.

### 1.6 Claude validation backlog
- ✅ **Resolved:** permission `approve` request + response schema (§1.2); `--mcp-config` accepts
  **both inline JSON strings and file paths**, and the `http` server entry with `headers`
  (Bearer) is the documented shape (`type:"http"`, `url`, `headers`); `sse` is deprecated.
- ✅ **Resolved by the M0 live capture (2026-06-12, claude 2.1.167 — see
  [M0_RESULTS](M0_RESULTS.md)):** stdin user-message envelope is the nested
  `{"type":"user","message":{role,content:[…]}}` form (§1.3); `thinking` block is
  `{type:"thinking",thinking,signature}`; `stream_event` partial shapes confirmed as tabled in
  §1.4; the `approve` call carries `tool_use_id` in practice; verbatim golden lines committed
  under `fixtures/claude/2.1.167/`.
- ⚠️ **New M0 findings:** (1) the adapter MUST pass `--permission-mode default` for interactive
  runs — a user-level `defaultMode:"auto"` auto-accepts edits *before* the permission tool is
  consulted; (2) CLI 2.1.167 emits unmodeled types passed through as `.raw`:
  `system/status`, `system/post_turn_summary`, `rate_limit_event`; (3) the child inherits the
  host's global plugins/MCP servers — consider `--strict-mcp-config`/curated `--settings` in M1.

---

## 2. CodexAppServerAdapter (default Codex path)

### 2.1 Why app-server

`codex exec` **cannot answer approvals** (fail-closed). Interactive approve/deny lives only in
`codex app-server`'s bidirectional JSON-RPC. So this is the default; `exec` is §3.

### 2.2 Process + framing ✅

```
codex app-server         # JSON-RPC 2.0 over stdio, newline-delimited (JSONL)
                         # "jsonrpc":"2.0" header is omitted on the wire; requests carry "id"
cwd / --cd = worktree.path ; model + sandbox via thread/turn params
```

✅ All method names, params, and enums below are **CONFIRMED verbatim** from `openai/codex`
source (`codex-rs/app-server-protocol/src/protocol/{common,v1,v2/*}.rs`, commit `7a19b14`). The
wire is **camelCase** (serde `rename_all="camelCase"`). The protocol has a current **v2**
(`thread/`,`turn/`,`item/`) surface and a deprecated **v1** surface; we target v2. You can
regenerate the exact schema for a pinned version: `codex app-server generate-ts` /
`generate-json-schema` (or read `codex-rs/app-server-protocol/schema/`).

### 2.3 Lifecycle ✅

```
→ initialize   {clientInfo:{name,title?,version}, capabilities?:{experimentalApi,…}}   (request)
← result       {userAgent, codexHome, platformFamily, platformOs}
→ initialized  (NOTIFICATION, no params)   ← REQUIRED before any other request
→ thread/start {model?, cwd?, approvalPolicy?, sandbox?|permissions?, ephemeral?, …}    (request)
← result       { thread: { id, sessionId, … } }            ← thread.id is the handle
   …also a thread/started notification
→ turn/start   { threadId, input:[UserInput], …per-turn overrides }                     (request)
← result       { turn: { id, … } }                         ← turn.id needed for interrupt/steer
←‹notifications›  thread/started · turn/started · item/* · turn/completed · thread/tokenUsage/updated
```

- `thread.id` → `SessionStarted.backendSessionID`. `turn.id` is needed for `turn/interrupt`
  and `turn/steer`.
- `UserInput` is `{type:"text",text,textElements?}` (also `image`/`localImage`/`skill`/`mention`).
- ⚠︎ Names you'd *expect* but that are **wrong/legacy**: `newThread`, `startTurn`,
  `sendUserTurn`, `sendUserMessage` are not current v2 methods.
- `turn/steer {threadId, input, expectedTurnId}` appends to an in-flight turn → sets
  `BackendCapabilities.followUpWhileRunning = true` for Codex.

### 2.4 Approvals — server→client requests ✅

The server *calls us*; we reply with the same JSON-RPC `id`. v2 methods (CONFIRMED):

| Inbound request `method` | → `AgentEvent` | Reply (result) |
| --- | --- | --- |
| `item/commandExecution/requestApproval` | `approvalRequested(risk: from command)` | `{ "decision": "accept" \| "acceptForSession" \| "decline" \| "cancel" }` |
| `item/fileChange/requestApproval` | `approvalRequested(risk:.write)` | same `decision` enum (accept/acceptForSession/decline/cancel) |
| `item/permissions/requestApproval` | `approvalRequested(risk:.unknown)` | **not** a `decision` — return `{ permissions:<granted subset>, scope:"turn"\|"session" }` |
| `mcpServer/elicitation/request` | surfaced as input/approval | elicitation response object |

`Decision` mapping (BACKEND_PROTOCOL §4.1): `.allow→"accept"`, `.allowAlways(.session)→
"acceptForSession"`, `.deny→"decline"`, `.cancelRun→"cancel"`.

- ✅ **Input rewrite:** the command enum *does* have `acceptWithExecpolicyAmendment` and
  `applyNetworkPolicyAmendment` struct variants — but these amend exec/network *policy*, not the
  command string. There is no arbitrary command-rewrite, so **`canModifyToolInput = false` for
  Codex** stands; the UI hides "edit & allow".
- ⚠︎ v1 (deprecated) path uses different camelCase methods `execCommandApproval`/
  `applyPatchApproval` with a `ReviewDecision` enum (`approved`/`approved_for_session`/`denied`/
  `abort`, snake_case). We do **not** use these — they only fire for legacy `sendUserTurn` turns.
- The handler suspends on `ApprovalCoordinator.await` exactly like Claude's — the two backends
  are identical above the adapter.

### 2.5 Item/event mapping ✅

Notifications are `{ "method": "...", "params": {...} }`; the per-item lifecycle is
`item/started → (deltas) → item/completed`.

| Notification | → `AgentEvent.kind` |
| --- | --- |
| `thread/started {threadId, thread}` | `sessionStarted` |
| `turn/started {threadId, turn}` | status only |
| `item/started`/`item/completed` `agentMessage {id,text}` | `assistantText` |
| `item/agentMessage/delta` | `assistantText(isPartial:true)` |
| `item/*` `reasoning {id,summary,content}` · `item/reasoning/textDelta` | `thinking` |
| `item/*` `commandExecution {command,cwd,status,aggregatedOutput?,exitCode?}` · `item/commandExecution/outputDelta` | `toolCallStarted` → `toolResult` (isError = exitCode≠0) |
| `item/*` `fileChange {changes:[{path,kind}],status}` · `item/fileChange/patchUpdated` | `fileChange` per change (native, authoritative) |
| `item/*` `mcpToolCall {server,tool,…}` | `toolCallStarted/Completed` + `toolResult` |
| `item/*` `webSearch {query}` | `toolCall*` name=web_search |
| `thread/tokenUsage/updated {tokenUsage:{total,last:{totalTokens,inputTokens,cachedInputTokens,outputTokens,reasoningOutputTokens}}}` | `usage` |
| `turn/completed {turn:{status}}` (status: completed/interrupted/failed) | `turnCompleted` |
| `turn/failed` / `error` | `error` |

- ✅ **Streaming deltas exist** (`item/agentMessage/delta`, `…/outputDelta`,
  `…/reasoning/textDelta`) → `BackendCapabilities.partialMessageStreaming = true` for Codex.
- ✅ **Token usage is a SEPARATE notification** (`thread/tokenUsage/updated`), *not* on
  `turn/completed` — different from exec (§3). Cost in dollars is not reported →
  `Usage.costUSD = nil`.

### 2.6 resume / interrupt / shutdown ✅

- **resume:** `thread/resume { threadId, path?, excludeTurns? }` → same shape as `thread/start`,
  returns the thread with `turns` populated. (`thread/fork` branches a new id; `thread/read`
  reads without resuming.)
- **interrupt:** `turn/interrupt { threadId, turnId }` → empty `{}` result; server then emits
  `turn/completed` with `status:"interrupted"`. Cleaner than a signal.
- **shutdown:** `SIGTERM` the process after closing the connection.

### 2.7 Codex app-server validation backlog
- ✅ **Resolved:** all method names, params, approval decision enums, event taxonomy, token
  usage, interrupt, resume — confirmed from source at commit `7a19b14`.
- 🟡 **Pin per version:** the schema is versioned and some methods are gated behind
  `capabilities.experimentalApi`; regenerate (`generate-json-schema`) against the pinned Codex
  release and keep a fixture. Capture one real run for verbatim byte-level confirmation.

> **Implementation status (v0, M3 — live-validated against codex-cli 0.141.0).** Built and
> tested in `Sources/NucleicCore/Codex/`: `CodexJSONRPC` (transport), `CodexAppServerDecoder`
> (§2.5), `CodexDecisionMapping` (§2.4), `CodexAppServerBackend`, and the `CodexExecDecoder` /
> `CodexExecBackend` unattended fallback (§3). The factory routes `.codex` → app-server and
> `.codexExec` → exec; the model picker selects the backend (`BackendID.forModel`: `gpt-*` →
> Codex). Both Codex backends use the **per-turn process model** (`start` opens a thread; each
> `resume` re-spawns and `thread/resume`s by id) — `followUpWhileRunning = false`;
> persistent-connection `turn/steer` follow-ups are a later optimization. Item types are
> normalized onto Claude's tool vocabulary so the UI, `RiskClassifier`, and command summaries
> are shared.
>
> **Validated live** (against `/opt/homebrew/bin/codex` 0.141.0): the whole protocol was
> regenerated with `codex app-server generate-ts` and the wire shapes confirmed byte-for-byte —
> the `jsonrpc` header IS omitted on the wire; `initialize`/`thread.start`/`turn.start` param
> spelling; the `item/*` envelope is `{item, threadId, turnId}`; the v2 decision enum is
> `accept`/`acceptForSession`/`decline`/`cancel` (command + file change have dedicated response
> types; permissions returns `{permissions, scope}`); `PatchChangeKind` is a tagged object
> `{type, move_path}`; `UserInput.text` carries `text_elements`; there is no `turn/failed`
> (failure rides `turn/completed.turn.status="failed"` with `turn.error`); reasoning deltas are
> `item/reasoning/textDelta`·`summaryTextDelta`. A real turn + a real resume were driven
> end-to-end (agentMessage, `agentMessage/delta`, `tokenUsage`, `turn/completed`), and golden
> captures are committed at `fixtures/codex-appserver/0.141.0/` and `fixtures/codex-exec/0.141.0/`.
>
> **Not yet built (follow-ups):** persistent-connection `turn/steer`; auto-approve wiring for
> Codex (the app-server backend currently always runs `approvalPolicy:on-request`); and Codex
> parity for Nucleic Control features (containers, conflict locks, git interceptor, `host_exec`),
> which are tied to Claude's in-process MCP server and need a separate design for Codex's JSON-RPC
> model — notably, `item/fileChange/requestApproval` params carry no path (only `itemId`), so
> edit-conflict pre-checking needs item-id correlation.

---

## 3. CodexExecAdapter (unattended fallback)

For `ApprovalPolicy.fixed`. No approval channel (`interactiveApprovals=false`).

```
codex exec --json [-o last.txt] [--output-schema schema.json] --sandbox <mode> \
  --ask-for-approval <never|untrusted|on-request> [-m <model>] [-C <worktree>]
# resume:  codex exec resume <SESSION_ID> | codex exec resume --last [--all]
```

> ✅ **Critical correction (CONFIRMED from `codex-rs/exec/src/exec_events.rs`, rust-v0.139.0):**
> the `codex exec` JSONL surface is **its own schema — snake_case — and is NOT the app-server
> v2 schema.** It needs a **separate decoder** from §2.5, not a shared one.

- ✅ Flag is `--json` (alias `--experimental-json`); "Print events to stdout as JSONL."
- ✅ Envelope `ThreadEvent` tagged by `"type"`: `thread.started {thread_id}`, `turn.started {}`,
  `turn.completed {usage}`, `turn.failed {error:{message}}`, `item.started/updated/completed
  {item}`, `error {message}`. **Note the dotted names** (`thread.started`) vs app-server's slash
  names (`thread/started`).
- ✅ `item` = `{ id, type, …}` snake_case: `agent_message {text}`, `reasoning {text}`,
  `command_execution {command, aggregated_output, exit_code?, status}`,
  `file_change {changes:[{path, kind:"add"|"delete"|"update"}], status}`, `mcp_tool_call {…}`,
  `web_search {query}`, `todo_list`, `error`.
- ✅ **Token usage is on `turn.completed.usage`** `{input_tokens, cached_input_tokens,
  output_tokens, reasoning_output_tokens}` — *inline*, unlike app-server's separate
  `thread/tokenUsage/updated` notification (§2.5).
- ✅ Approval flags: `--ask-for-approval` ∈ `untrusted|on-request|never` (`on-failure`
  deprecated); `--sandbox` ∈ `read-only|workspace-write|danger-full-access`. Rollout files at
  `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl` (suppressed by `--ephemeral`).
- A would-be approval fails closed → emit `toolResult(isError)` + `error(recoverable:false)` +
  UI banner (BACKEND_PROTOCOL §4.2). 🟡 The exact "fail-closed vs silently-skip" runtime path
  isn't quotable from source — capture one real run. `--json` is experimental → tolerate unknown
  `type`/fields and pin the version.

---

## 4. Validation fixtures harness

After the source-validation pass, the schemas are known; fixtures now exist to **lock byte-level
layout and detect drift**, not to discover the protocol. The M0 spike records, per pinned CLI
version, native streams + golden normalized output as adapter unit tests:

```
fixtures/claude/<version>/
  01-simple-turn.ndjson         + 01-simple-turn.events.json     (golden AgentEvent[])
  02-tool-approval.ndjson       + 02…events.json  + approval-call.json / reply.json
  03-partial-streaming.ndjson   + …
fixtures/codex-appserver/<version>/   01-turn.jsonrpc  02-approval.jsonrpc  03-file-change.jsonrpc
fixtures/codex-exec/<version>/        01-turn.jsonl    02-file-change.jsonl   # SEPARATE schema (§3)
```

- Codex needs **two** fixture sets — app-server (camelCase, slash methods) and exec (snake_case,
  dotted types) are different decoders.
- Each remaining 🟡 item (§1.6, §3) maps to a fixture that must exist before the adapter is
  "done"; the bulk that were 🔴 are already resolved from source.
- Adapter tests decode the recorded native stream → assert exact `AgentEvent[]` equality, and
  (for approvals) assert the reply bytes the CLI accepts.
- Re-recording on a CLI upgrade is the regression signal for protocol drift; for Codex, also
  re-run `codex app-server generate-json-schema` and diff.

---

## 5. Summary of cross-adapter symmetry

Despite wildly different wire formats, everything converges by design:

| Concern | Claude | Codex app-server | Grok (ACP) | Above the adapter |
| --- | --- | --- | --- | --- |
| Transport | NDJSON stdout + HTTP-MCP callback | JSON-RPC stdio (duplex) | JSON-RPC stdio (duplex) | — |
| Approval trigger | MCP `tools/call(approve)` | `…/requestApproval` request | `session/request_permission` request | `ApprovalCoordinator.await` |
| Approval reply | `{behavior,…}` text | `{decision}` result | `{outcome:{selected,optionId}}` result | one `Decision` |
| Events | message/stream_event | thread/turn/item | `session/update` notifications | one `AgentEvent` stream |
| Interrupt | SIGINT | `turn/interrupt {threadId,turnId}` | `session/cancel {sessionId}` | `interrupt()` |
| Resume | `--resume <id>` | `thread/resume {threadId}` | `session/load {sessionId}` | `ResumeSpec` |

All cells above are now source-confirmed (Claude via SDK docs/`.d.ts`; Codex via repo source at
`7a19b14`; Grok via the published Agent Client Protocol schema + xAI's `grok agent stdio` docs).
The `ApprovalCoordinator.await(...) -> Decision` suspension point is the single seam that makes
these completely different approval mechanisms look identical to the rest of the app — and Grok,
like Codex, is a JSON-RPC backend whose approvals arrive as server→client *requests*, so both
share the [`JSONRPCConnection`](../Sources/NucleicCore/JSONRPCConnection.swift) transport (Codex
omits the `"jsonrpc"` header per its confirmed dialect; ACP includes it).
