Files
nucleic/docs/ADAPTERS.md
T
NucleicandClaude Opus 4.8 609ce70539 Migrate Grok backend to ACP (grok agent stdio)
Replace the streaming-json CLI + generated PreToolUse-hook Grok adapter with grok's native ACP mode (JSON-RPC 2.0 over stdio). Adds GrokACPBackend/GrokACPDecoder/GrokACPDecisionMapping (a near-twin of the Codex app-server adapter); generalizes CodexJSONRPC into a shared JSONRPCConnection (jsonrpc-header flag, default off so Codex is byte-identical, plus endNotifications); deletes GrokBuildBackend, GrokStreamDecoder, GrokHookConfig and strips the /grok-hook HTTP bridge from MCPApprovalServer. Claude and Codex are left native. Preserves conflict-lock parity, native resume (session/load), and the hermetic fake-grok contract test. Full suite: 525 tests green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 02:52:24 -07:00

25 KiB
Raw Blame History

Nucleic — Backend Adapter Internals (v0)

Message-by-message design for the two production adapters that implement AgentBackend (BACKEND_PROTOCOL): 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/updatedInputnot 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):

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 idawaited 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:

// 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):
{ "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:
// 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:

{"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): 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.idSessionStarted.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 toolCallStartedtoolResult (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/resumes 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-approvaluntrusted|on-request|never (on-failure deprecated); --sandboxread-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 transport (Codex omits the "jsonrpc" header per its confirmed dialect; ACP includes it).