Files
nucleic/docs/BACKEND_PROTOCOL.md
T
abkslmandClaude Opus 4.8 0e47298720 Add Nucleic design plan: Swift/SwiftUI Claude Code/Codex wrapper
Comprehensive design for a Conductor-style macOS app (host) + iPhone
(thin remote client) that runs parallel Claude Code / Codex sessions in
isolated git worktrees, with interactive per-session approvals.

PLAN.md is the hub; docs/ over-specifies each layer:
- BACKEND_PROTOCOL: normalized AgentEvent model, capabilities, approvals
- ADAPTERS: Claude MCP approval server + Codex app-server JSON-RPC,
  with wire contracts confirmed from primary sources
- SYNC_PROTOCOL: LAN/relay E2EE sync, seq-cursor catch-up
- WORKTREE_MANAGER: worktree lifecycle, diff, integrate, reconcile
- RUNTIME_ARCHITECTURE: single-writer pipeline, GRDB schema, concurrency
- UX_MACOS / UX_IOS: information architecture and approval flows
- OBSERVABILITY_AND_TESTING: redaction-aware observability + fixture harness

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-11 23:49:19 -07:00

20 KiB
Raw Blame History

Nucleic — Agent Backend Protocol (v0)

The contract every coding-agent CLI is normalized into. Both backends (Claude Code, Codex) and the entire Mac↔iPhone sync layer depend on this, so it is specified tightly here and the per-CLI quirks are pushed down into adapters.

Status: design draft. CLI-specific field shapes below are validated-against-docs but some are version-dependent and flagged ⚠︎ validate. The normalized types (our side) are the stable contract; adapters absorb CLI churn.


1. Design goals & invariants

  1. One event model, two (or N) CLIs. Adapters translate native output → AgentEvent. Nothing above the adapter knows whether it's talking to Claude or Codex.
  2. The local transcript is the source of truth. Every AgentEvent is appended to our own JSONL as it is produced. The UI renders from that, never from re-reading the CLI's native transcript. Native session files are used only to drive resume and as a recovery import.
  3. Capabilities, not assumptions. Backends differ (interactive approvals, input rewrite, partial streaming, sandbox). Each backend declares a BackendCapabilities; the UI adapts. We never assume a feature is present.
  4. Total per-session ordering. The adapter stamps every event with a monotonic seq. Consumers (UI, sync, persistence) rely on seq, not wall-clock time.
  5. Unknown-field tolerant. Adapters decode leniently and pass anything unrecognized through as .raw rather than crashing. CLI JSON is explicitly experimental (esp. Codex).

2. The protocol surface (Swift)

protocol AgentBackend: Sendable {
    static var id: BackendID { get }                 // .claudeCode | .codex
    var capabilities: BackendCapabilities { get }

    /// Begin a fresh run in a prepared worktree. Returns the live event stream.
    func start(_ run: RunSpec) -> AsyncThrowingStream<AgentEvent, Error>

    /// Reattach to an existing backend session (native resume).
    func resume(_ resume: ResumeSpec) -> AsyncThrowingStream<AgentEvent, Error>

    /// Queue a follow-up user turn (and/or injected context) on a running session.
    func send(_ input: AgentInput) async throws

    /// Answer an outstanding approval. No-op throws if backend lacks interactive approvals.
    func respond(to approvalID: ApprovalID, _ decision: Decision) async throws

    /// Cooperative interrupt of the current turn (keeps session resumable).
    func interrupt() async

    /// Terminate the process/connection and release resources.
    func shutdown() async
}

2.1 Capabilities

struct BackendCapabilities: Sendable {
    var interactiveApprovals: Bool         // can answer an approval mid-run?
    var allowAlwaysScopes: Set<AlwaysScope> // which "always allow" scopes are real
    var canModifyToolInput: Bool           // can we rewrite args on allow?
    var partialMessageStreaming: Bool      // token-level deltas available?
    var emitsThinking: Bool                // reasoning/thinking surfaced?
    var emitsFileChangeEvents: Bool        // native file_change events (vs. inferred)
    var nativeResume: Bool                 // --resume / resume <id>
    var sandboxModes: [SandboxMode]        // [] if not a first-class concept
    var followUpWhileRunning: Bool         // can `send` mid-turn, or only between turns
}
Capability Claude Code (stream-json + MCP) Codex (app-server) Codex (exec --json)
interactiveApprovals (fail-closed)
canModifyToolInput (updatedInput) (policy amendments only, not command rewrite)
partialMessageStreaming (--include-partial-messages) (item/agentMessage/delta, …/outputDelta) 🟡 item updates
followUpWhileRunning 🟡 (turn/steer)
emitsThinking (thinking blocks) (reasoning item) (reasoning item)
emitsFileChangeEvents (infer from Edit/Write) (file_change) (file_change)
nativeResume
sandboxModes [] (permission model) read-only/workspace-write/full same

Decision: the default Codex adapter is codex app-server (JSON-RPC) so interactive approvals work. exec --json is a secondary "unattended" adapter (CodexExecBackend) used only when the user opts into a fixed policy; its capability set advertises interactiveApprovals = false and the UI warns at session start.

2.2 Run / resume / input specs

struct RunSpec: Sendable {
    let sessionID: SessionID            // OUR id (uuid), not the backend's
    let worktree: WorktreePath
    let prompt: AgentInput
    let model: String?                  // nil → backend default
    let approvalPolicy: ApprovalPolicy  // interactive | fixed(rules)
    let sandbox: SandboxMode?           // Codex only; ignored by Claude
    let mcpConfigPath: URL?
    let appendSystemPrompt: String?
    let extraEnv: [String: String]
    let extraArgs: [String]             // escape hatch, adapter-validated
}

struct ResumeSpec: Sendable {
    let sessionID: SessionID
    let backendSessionID: String        // native id captured at start
    let worktree: WorktreePath
    let fork: Bool                      // start a new native id, keep our transcript
}

struct AgentInput: Sendable {
    enum Part { case text(String); case context(label: String, body: String) }
    let parts: [Part]
}

3. AgentEvent — the normalized event model

Every event carries an envelope; the kind holds the payload.

struct AgentEvent: Sendable, Codable {
    let sessionID: SessionID
    let seq: UInt64                 // monotonic per session, assigned by adapter
    let at: Date                    // adapter receive time
    let backend: BackendID
    let nativeType: String?         // original CLI type, for debugging/forensics
    let kind: Kind
}

extension AgentEvent {
    enum Kind: Sendable, Codable {
        case sessionStarted(SessionStarted)
        case assistantText(TextChunk)      // partial or complete assistant prose
        case thinking(TextChunk)           // reasoning / thinking
        case toolCallStarted(ToolCall)     // id + name known; input may be empty/partial
        case toolCallInputDelta(ToolInputDelta) // only when partialMessageStreaming
        case toolCallCompleted(ToolCall)   // full input finalized
        case toolResult(ToolResult)        // output of a tool call
        case fileChange(FileChange)         // add/update/delete a path
        case approvalRequested(ApprovalRequest)
        case approvalResolved(ApprovalResolved) // echoed for the transcript
        case usage(Usage)
        case turnCompleted(TurnCompleted)
        case runFinished(RunFinished)
        case error(AgentError)
        case raw(Raw)                       // unrecognized native event, passthrough
    }
}

3.1 Payloads

struct SessionStarted: Codable {
    let backendSessionID: String       // ← capture for resume
    let model: String
    let cwd: String
    let toolNames: [String]
    let nativeTranscriptPath: String?  // ~/.claude/projects/… or ~/.codex/sessions/…
}

struct TextChunk: Codable {
    let messageID: String              // groups deltas of one assistant message
    let text: String                   // delta if isPartial, else full text
    let isPartial: Bool
}

struct ToolCall: Codable {
    let toolCallID: String             // native tool id (toolu_… / item id)
    let name: String                   // "Bash", "Edit", "command_execution", …
    let input: JSONValue               // best-known input; complete on …Completed
    let parentToolCallID: String?      // sub-agent / nested
}

struct ToolInputDelta: Codable { let toolCallID: String; let partialJSON: String }

struct ToolResult: Codable {
    let toolCallID: String
    let content: JSONValue             // text or structured
    let isError: Bool
}

struct FileChange: Codable {
    enum Kind: String, Codable { case add, update, delete, rename }
    let path: String
    let kind: Kind
    let toolCallID: String?            // the call that produced it, if known
    let diffHint: String?              // unified diff if the CLI provides one
}

struct Usage: Codable {
    let inputTokens: Int?
    let cachedInputTokens: Int?
    let outputTokens: Int?
    let reasoningTokens: Int?
    let costUSD: Double?               // nil if backend doesn't report it
}

struct TurnCompleted: Codable { let stopReason: String?; let usage: Usage? }

struct RunFinished: Codable {
    enum Outcome: String, Codable { case completed, interrupted, errored, maxTurns }
    let outcome: Outcome
    let finalText: String?
    let totalUsage: Usage?
    let durationMs: Int?
}

struct AgentError: Codable {
    let recoverable: Bool              // e.g. api_retry vs fatal
    let message: String
    let native: JSONValue?
}

struct Raw: Codable { let native: JSONValue }

JSONValue is our standard recursive JSON enum (Codable, Sendable, Equatable).

3.2 Tool-call lifecycle (normalized)

toolCallStarted ──▶ [toolCallInputDelta]* ──▶ toolCallCompleted
                                                    │
                          (if gated) approvalRequested ──▶ approvalResolved
                                                    │
                                               toolResult
                                        (and/or fileChange × N)
  • Adapters that don't stream partial input simply emit toolCallStarted with full input and immediately toolCallCompleted (no deltas).
  • fileChange may arrive without a preceding tool call on backends that emit it natively (Codex). For Claude we synthesize fileChange from Edit/Write/MultiEdit tool calls (emitsFileChangeEvents = false signals the UI it's inferred, not authoritative).

4. Approvals — the normalized round-trip

This is the highest-divergence area; the model is designed around the asymmetry in §1.

struct ApprovalRequest: Codable, Identifiable {
    let id: ApprovalID                 // OUR uuid
    let sessionID: SessionID
    let toolCallID: String?            // links to the gated tool call
    let toolName: String
    let input: JSONValue
    let title: String                  // human summary ("Run: rm -rf build/")
    let risk: Risk                     // heuristic classification (below)
    let suggested: Decision?           // backend hint, if any
    let createdAt: Date
}

enum Risk: String, Codable { case readOnly, write, execute, network, destructive, unknown }

enum AlwaysScope: String, Codable {
    case session                       // this tool, this session
    case toolName                      // any call to this tool, this session
    case toolNameWithPattern           // tool + argument pattern (e.g. Bash(git *))
}

enum Decision: Sendable, Codable {
    case allow(updatedInput: JSONValue? = nil)
    case allowAlways(AlwaysScope)
    case deny(reason: String?)
    case cancelRun                      // deny + stop the whole run
}

struct ApprovalResolved: Codable {
    let id: ApprovalID
    let decision: Decision
    let decidedBy: String              // "mac-ui" | "iphone:<device>" — first responder wins
    let decidedAt: Date
}

Risk classification is computed by the adapter from toolName + input (e.g. Bash with rm/git push/curl → destructive/network; Read/Glob → readOnly). It drives UI emphasis and any auto-policy; it never decides on its own in interactive mode.

allowAlways semantics depend on backend support:

  • Where the backend has a native "for session" answer (Codex acceptForSession), use it.
  • Where it doesn't (Claude calls the permission tool every time), the adapter caches the rule locally and auto-answers subsequent matching requests without surfacing them. Either way, the UI behavior is identical — that's the point of normalizing here.

4.1 Approval mapping per backend

Both columns are source-confirmed (ADAPTERS §1.2 / §2.4). Claude's reply is the JSON object stringified inside an MCP text content block; Codex's is a JSON-RPC result.

Normalized Decision Claude (--permission-prompt-tool reply, JSON-stringified) Codex app-server (requestApproval result)
.allow() {"behavior":"allow","updatedInput":<orig>} {"decision":"accept"}
.allow(updatedInput:) {"behavior":"allow","updatedInput":<new>} n/a — canModifyToolInput=false; UI hides it
.allowAlways(.session) adapter-cached auto-allow + behavior:allow {"decision":"acceptForSession"}
.deny(reason:) {"behavior":"deny","message":<reason>} {"decision":"decline"}
.cancelRun behavior:deny + interrupt() {"decision":"cancel"}

Confirmed: Claude keys are behavior/message/updatedInput (not decision/reason), with updatedInput echoed even when unchanged; the input the tool receives is {tool_name, input, tool_use_id?}. Codex's v2 decision enum is accept/acceptForSession/decline/cancel. The deprecated v1 path (execCommandApproval/applyPatchApproval, approved/denied/abort) is not used. item/permissions/requestApproval replies with a granted-subset + scope, not a decision (ADAPTERS §2.4).

4.2 CodexExecBackend (no interactive approvals)

When a session runs under codex exec, respond throws Unsupported. Instead the run is created with a fixed ApprovalPolicy.fixed(rules) mapped to --sandbox + --ask-for-approval. A would-be approval becomes a fail-closed toolResult(isError) plus an .error(recoverable) event so the UI can show "blocked by policy (non-interactive)". Surface this clearly at session creation so users don't expect to be prompted.


5. Lifecycle / status state machine

Session.status is derived from the event stream:

            start()                 approvalRequested
  idle ───────────────▶ running ───────────────────▶ awaitingApproval
                          ▲ │                              │ approvalResolved
        approvalResolved  │ │ turnCompleted (no follow-up) │
                          │ ▼                              ▼
                       awaitingInput ◀───────────────── running
                          │
          runFinished     │  error(recoverable:false)
  running ──────────▶ finished        running ───────────▶ error
  • awaitingApproval and awaitingInput are the two "needs a human" states that trigger notifications (Mac + iPhone).
  • error(recoverable:true) (e.g. Claude api_retry, Codex -32001 overload) does not leave running; the adapter handles backoff and emits an info-level .error.

6. Persistence — our transcript is canonical (locked decision)

Each session writes ~/Library/Application Support/Nucleic/sessions/<sessionID>/transcript.jsonl:

  • Line 0: SessionHeader — our sessionID, backend, backendSessionID, worktree, model, nativeTranscriptPath, createdAt, schema version.
  • Lines 1…N: one AgentEvent per line, in seq order.

Metadata (status, counts, last activity, ahead/behind) lives in GRDB and points at this file.

Resume flow: (1) read our JSONL → rebuild UI state and the last known seq; (2) call backend.resume(ResumeSpec) with the stored backendSessionID; (3) continue appending new events from seq+1. We never depend on the CLI to re-emit history. If our file is lost we can import from nativeTranscriptPath as a degraded fallback.


7. Backend adapter mapping reference

7.1 Claude Code adapter

  • Spawn: claude -p --output-format stream-json --input-format stream-json --verbose [--include-partial-messages] --permission-prompt-tool mcp__nucleic__approve --mcp-config <generated> [--model] [--append-system-prompt] [--resume <id>] [--add-dir] with cwd = worktree.

  • Approvals: Nucleic hosts an in-process MCP server exposing approve. Claude calls it per gated tool with {tool_name, input, description} ⚠︎; the call blocks until the UI/phone resolves, then returns the mapped reply (§4.1).

  • Event mapping:

    Native stdout (type) AgentEvent.kind
    system/init sessionStarted (capture session_id)
    assistant message w/ text block assistantText(isPartial:false)
    assistant message w/ thinking block thinking
    assistant message w/ tool_use block toolCallStarted + toolCallCompleted
    user message w/ tool_result toolResult
    stream_event (partial) assistantText/thinking/…(isPartial:true) / toolCallInputDelta
    system/api_retry error(recoverable:true)
    result runFinished + usage

    fileChange synthesized from Edit/Write/MultiEdit/NotebookEdit tool calls. ⚠︎ Without --include-partial-messages the canonical stream emits full assistant/user message events (not stream_event); the adapter must handle both shapes.

7.2 Codex adapter (app-server, default)

  • Spawn: codex app-server (stdio JSON-RPC). One-time initialize, then per-run thread/turn RPCs; --cd/cwd = worktree, model + sandbox via params/config.
  • Approvals: server→client requests item/commandExecution/requestApproval, item/fileChange/requestApproval, item/permissions/requestApprovalapprovalRequested; reply per §4.1. mcpServer/elicitation/request also bridged.
  • Event mapping (thread/turn/item taxonomy):
    Native AgentEvent.kind
    thread.started sessionStarted (thread_id = backendSessionID)
    turn.started (status only)
    item agent_message assistantText
    item reasoning thinking
    item command_execution started/updated/completed toolCallStartedtoolResult (+ exit code)
    item file_change fileChange (per changes[])
    item mcp_tool_call toolCallStarted/toolCallCompleted/toolResult
    item web_search toolCall* (name=web_search)
    turn.completed turnCompleted + usage
    turn.failed / error error

7.3 Codex adapter (exec, unattended fallback)

  • Spawn: codex exec --json [-o last.txt] [--sandbox …] [--ask-for-approval never|untrusted] [-m] [-C <wd>]; resume via codex exec resume <id>.
  • Same item taxonomy as app-server for events; no approval channel (§4.2).

8. Versioning & validation

  • Protocol carries a schemaVersion; transcript header records it.
  • Adapters pin a tested CLI version range and tolerate unknown fields (decode → .raw).
  • Every ⚠︎ validate item must be confirmed against a captured real stream from the pinned CLI version before that adapter ships. Recommended: a fixtures/ set of recorded native streams + golden normalized AgentEvent output, used in adapter unit tests.

9. Open questions

  1. Claude permission-tool reply keys — confirm behavior/message/updatedInput vs decision/reason against the shipping claude; adjust adapter only.
  2. Codex input rewrite — can app-server amendments express arbitrary command edits, or do we set canModifyToolInput=false for Codex and hide "edit & allow" in the UI?
  3. Follow-up mid-turn — does each backend accept a queued user turn while running, or only at awaitingInput? Sets followUpWhileRunning.
  4. Partial streaming cost — default --include-partial-messages on (snappier UI, more events to persist/sync) vs off (coarser, cheaper). Likely on for local, throttled for iPhone sync.