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]>
20 KiB
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
- 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. - The local transcript is the source of truth. Every
AgentEventis 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. - 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. - Total per-session ordering. The adapter stamps every event with a monotonic
seq. Consumers (UI, sync, persistence) rely onseq, not wall-clock time. - Unknown-field tolerant. Adapters decode leniently and pass anything unrecognized
through as
.rawrather 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
toolCallStartedwith full input and immediatelytoolCallCompleted(no deltas). fileChangemay arrive without a preceding tool call on backends that emit it natively (Codex). For Claude we synthesizefileChangefromEdit/Write/MultiEdittool calls (emitsFileChangeEvents = falsesignals 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
awaitingApprovalandawaitingInputare the two "needs a human" states that trigger notifications (Mac + iPhone).error(recoverable:true)(e.g. Claudeapi_retry, Codex-32001overload) does not leaverunning; 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— oursessionID,backend,backendSessionID,worktree,model,nativeTranscriptPath,createdAt, schemaversion. - Lines 1…N: one
AgentEventper line, inseqorder.
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]withcwd= 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.kindsystem/initsessionStarted(capturesession_id)assistantmessage w/textblockassistantText(isPartial:false)assistantmessage w/thinkingblockthinkingassistantmessage w/tool_useblocktoolCallStarted+toolCallCompletedusermessage w/tool_resulttoolResultstream_event(partial)assistantText/thinking/…(isPartial:true)/toolCallInputDeltasystem/api_retryerror(recoverable:true)resultrunFinished+usagefileChangesynthesized fromEdit/Write/MultiEdit/NotebookEdittool calls. ⚠︎ Without--include-partial-messagesthe canonical stream emits fullassistant/usermessage events (notstream_event); the adapter must handle both shapes.
7.2 Codex adapter (app-server, default)
- Spawn:
codex app-server(stdio JSON-RPC). One-timeinitialize, then per-runthread/turnRPCs;--cd/cwd = worktree, model + sandbox via params/config. - Approvals: server→client requests
item/commandExecution/requestApproval,item/fileChange/requestApproval,item/permissions/requestApproval→approvalRequested; reply per §4.1.mcpServer/elicitation/requestalso bridged. - Event mapping (thread/turn/item taxonomy):
Native → AgentEvent.kindthread.startedsessionStarted(thread_id= backendSessionID)turn.started(status only) item agent_messageassistantTextitem reasoningthinkingitem command_executionstarted/updated/completedtoolCallStarted→toolResult(+ exit code)item file_changefileChange(perchanges[])item mcp_tool_calltoolCallStarted/toolCallCompleted/toolResultitem web_searchtoolCall*(name=web_search)turn.completedturnCompleted+usageturn.failed/errorerror
7.3 Codex adapter (exec, unattended fallback)
- Spawn:
codex exec --json [-o last.txt] [--sandbox …] [--ask-for-approval never|untrusted] [-m] [-C <wd>]; resume viacodex 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
⚠︎ validateitem must be confirmed against a captured real stream from the pinned CLI version before that adapter ships. Recommended: afixtures/set of recorded native streams + golden normalizedAgentEventoutput, used in adapter unit tests.
9. Open questions
- Claude permission-tool reply keys — confirm
behavior/message/updatedInputvsdecision/reasonagainst the shippingclaude; adjust adapter only. - Codex input rewrite — can app-server amendments express arbitrary command edits, or do
we set
canModifyToolInput=falsefor Codex and hide "edit & allow" in the UI? - Follow-up mid-turn — does each backend accept a queued user turn while
running, or only atawaitingInput? SetsfollowUpWhileRunning. - Partial streaming cost — default
--include-partial-messageson (snappier UI, more events to persist/sync) vs off (coarser, cheaper). Likely on for local, throttled for iPhone sync.