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]>
25 KiB
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-sdksdk.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— notdecision/reason.- Codex app-server — CONFIRMED verbatim from
openai/codexsource (codex-rs/app-server-protocol/src/protocol/, commit7a19b14): real methods arethread/start,turn/start,turn/interrupt,thread/resume(notnewThread/startTurn) and the v2 approval decision enum isaccept/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-8Data, 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→awaited 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 sameid. 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 thisclaudeprocess can call it. One server may serve all sessions, keyed by token →SessionID. - Implements minimal MCP:
initialize,tools/list(advertises one toolapprove),tools/call. - ✅
approveinput 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
- ✅
approvereturn (CONFIRMED): a normal MCPCallToolResultwith one text content block whosetextis 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"),messageon deny,updatedInputon allow — notdecision/reason. The object must be JSON-stringified inside the text block.updatedInputis type-optional in the SDKPermissionResultbut 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-sdksdk.d.ts. The in-processPermissionResultalso hasinterrupt?/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:denysoclaudeexits 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_useforEdit/Write/MultiEdit/NotebookEdit, emitfileChange(path from input;emitsFileChangeEvents=falseflags it inferred). Authoritative diff still comes fromWorktreeManager. - Partial assembly:
content_block_start(tool_use)→ accumulateinput_json_delta→ finalize oncontent_block_stopintotoolCallCompleted.
1.5 resume / interrupt / shutdown
- resume: relaunch with
--resume <backendSessionID>(+--fork-sessionifResumeSpec.fork); same stdin/stdout wiring; UI history already restored from our transcript. - interrupt:
SIGINTto the process; the stream closes; a finalresultmay not arrive, so the adapter emitsrunFinished(outcome:.interrupted)itself. - shutdown: close stdin,
SIGTERM, await exit, tear down the MCP token.
1.6 Claude validation backlog
- ✅ Resolved: permission
approverequest + response schema (§1.2);--mcp-configaccepts both inline JSON strings and file paths, and thehttpserver entry withheaders(Bearer) is the documented shape (type:"http",url,headers);sseis 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);thinkingblock is{type:"thinking",thinking,signature};stream_eventpartial shapes confirmed as tabled in §1.4; theapprovecall carriestool_use_idin practice; verbatim golden lines committed underfixtures/claude/2.1.167/. - ⚠️ New M0 findings: (1) the adapter MUST pass
--permission-mode defaultfor interactive runs — a user-leveldefaultMode:"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--settingsin 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.idis needed forturn/interruptandturn/steer.UserInputis{type:"text",text,textElements?}(alsoimage/localImage/skill/mention).- ⚠︎ Names you'd expect but that are wrong/legacy:
newThread,startTurn,sendUserTurn,sendUserMessageare not current v2 methods. turn/steer {threadId, input, expectedTurnId}appends to an in-flight turn → setsBackendCapabilities.followUpWhileRunning = truefor 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
acceptWithExecpolicyAmendmentandapplyNetworkPolicyAmendmentstruct variants — but these amend exec/network policy, not the command string. There is no arbitrary command-rewrite, socanModifyToolInput = falsefor Codex stands; the UI hides "edit & allow". - ⚠︎ v1 (deprecated) path uses different camelCase methods
execCommandApproval/applyPatchApprovalwith aReviewDecisionenum (approved/approved_for_session/denied/abort, snake_case). We do not use these — they only fire for legacysendUserTurnturns. - The handler suspends on
ApprovalCoordinator.awaitexactly 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 = truefor Codex. - ✅ Token usage is a SEPARATE notification (
thread/tokenUsage/updated), not onturn/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 asthread/start, returns the thread withturnspopulated. (thread/forkbranches a new id;thread/readreads without resuming.) - interrupt:
turn/interrupt { threadId, turnId }→ empty{}result; server then emitsturn/completedwithstatus:"interrupted". Cleaner than a signal. - shutdown:
SIGTERMthe 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 theCodexExecDecoder/CodexExecBackendunattended 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 (startopens a thread; eachresumere-spawns andthread/resumes by id) —followUpWhileRunning = false; persistent-connectionturn/steerfollow-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/codex0.141.0): the whole protocol was regenerated withcodex app-server generate-tsand the wire shapes confirmed byte-for-byte — thejsonrpcheader IS omitted on the wire;initialize/thread.start/turn.startparam spelling; theitem/*envelope is{item, threadId, turnId}; the v2 decision enum isaccept/acceptForSession/decline/cancel(command + file change have dedicated response types; permissions returns{permissions, scope});PatchChangeKindis a tagged object{type, move_path};UserInput.textcarriestext_elements; there is noturn/failed(failure ridesturn/completed.turn.status="failed"withturn.error); reasoning deltas areitem/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 atfixtures/codex-appserver/0.141.0/andfixtures/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 runsapprovalPolicy: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/requestApprovalparams carry no path (onlyitemId), 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): thecodex execJSONL 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
ThreadEventtagged 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 separatethread/tokenUsage/updatednotification (§2.5). - ✅ Approval flags:
--ask-for-approval∈untrusted|on-request|never(on-failuredeprecated);--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.--jsonis experimental → tolerate unknowntype/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-schemaand 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).