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]>
13 KiB
Nucleic — Mac ↔ iPhone Sync Protocol (v0)
How the iPhone (thin client) talks to the Mac (host). The host owns all state — repos,
processes, transcripts. The phone subscribes, renders, answers approvals, and (later) sends
input. The wire protocol is a projection of the backend layer: it ships AgentEvents
and the approval round-trip from BACKEND_PROTOCOL.md across a secure
channel.
Status: design draft. The application message set is the stable contract. The two
transports (LAN now, Cloudflare relay later) sit behind one SecureChannel so the message
layer is transport-agnostic.
1. Invariants & goals
- Host is the single authority. The phone never holds canonical state; it mirrors a subscription. Conflicts (e.g. two phones answering one approval) are resolved by the host.
- End-to-end encrypted, always — even on LAN. We do not trust the local network or the relay. Plaintext exists only on the two paired devices. The relay forwards opaque frames.
- One message set, two transports. LAN and relay differ only in how bytes move; the
encrypted
Frames and the application messages inside them are identical. - Resumable & idempotent. Every subscription is driven by the monotonic
seqfrom the backend protocol. Reconnect → re-handshake → re-subscribesinceSeq→ catch up. No message is required exactly once; consumers dedupe on(sessionID, seq). - Capability-scoped per device. A paired device is granted a scope (
view/approve/control). v1 iPhone =approve. The host enforces; the protocol carries it.
2. Layer cake
┌─────────────────────────────────────────────┐
│ Application messages (§5) ClientMsg/HostMsg │ ← projection of AgentEvent + approvals
├─────────────────────────────────────────────┤
│ SecureChannel (Noise XX/IK, §4) │ ← E2EE, mutual device auth, framing
├─────────────────────────────────────────────┤
│ Transport (§3): LAN (Network.framework) │ Relay (WS via Cloudflare DO, later) │
└─────────────────────────────────────────────┘
Encryption and authentication live in SecureChannel, above the transport, so the relay is
a dumb pipe and LAN gets the same guarantees. Swapping transports never touches §4/§5.
3. Transports
3.1 LAN (v1)
- Host advertises
_nucleic._tcpvia Bonjour (NWListener+NWTxtRecordcarrying host identity-key fingerprint + protocol version). - Client browses, resolves, opens an
NWConnection(TCP). Then theSecureChannelhandshake (§4) runs over it. TLS is not relied on for secrecy — Noise provides E2EE and mutual auth; the TCP layer is just bytes. - Works only when co-located. No server to run. This is the entire v1 networking story.
3.2 Relay (later, Cloudflare)
- A Worker upgrades a WebSocket and routes it to a Durable Object keyed by
roomID(derived from the pairing group / host identity). The DO holds the host's and clients' sockets and forwards opaqueFrames between them; it also tracks presence. - The relay cannot decrypt — the same Noise session runs end-to-end through it. The DO
sees only
roomID, frame sizes, and timing. - Relay admission: a short-lived
relayToken(issued at pairing, rotatable) gates who may occupy a room, so strangers can't squat or flood. It authorizes routing, not content. - Selection: client tries LAN first (Bonjour hit) and falls back to relay; host registers
with the relay whenever reachable. A session can migrate transports mid-stream because
SecureChannel+seqcursors make reconnection seamless.
4. Security: pairing & SecureChannel
4.1 Identity
Each device generates a long-term identity keypair (X25519 for the Noise handshake, plus an Ed25519 signing key) in the Secure Enclave / Keychain. Public keys are exchanged once, at pairing, and pinned thereafter (trust-on-first-use, with the QR as the secure out-of-band channel).
4.2 Pairing (one time)
- Mac shows a QR containing: host identity public key, a one-time pairing secret, LAN
connection hint, and (later) a relay
roomID+ bootstrap token. - iPhone scans → has the host's public key over a trusted OOB channel.
- Devices run an authenticated handshake (Noise XX, the pairing secret mixed in as a PSK to bind the exchange to this QR and stop MITM). On success each stores the other's pinned identity key and a derived long-term shared secret.
- Host records the device in its paired-devices table with a
scope(defaultapprove) and a human label. The user can revoke a device anytime (drops the pin → future handshakes fail).
4.3 Session handshake (every connect)
Reconnecting uses Noise IK (client already knows the host's static key), giving mutual
auth + forward secrecy without re-pairing. Output: fresh per-session symmetric keys. All
subsequent Frames are AEAD-encrypted with a per-direction nonce counter.
4.4 Frame
Frame = length-prefixed AEAD ciphertext
plaintext(Frame) = { seqNonce: UInt64, msg: HostMsg | ClientMsg } // CBOR-encoded
Replay/reorder protection comes from the AEAD nonce counter; the transport guarantees in-order delivery (TCP / WS), so a gap means a dropped connection → reconnect + resync.
5. Application messages
CBOR-encoded (compact for the event-heavy path; AgentEvent is already Codable). Two
enums, both Sendable. Identifiers (SessionID, ApprovalID, Decision, AgentEvent)
are exactly the backend-protocol types.
5.1 Client → Host
enum ClientMsg: Codable {
case hello(Hello) // proto version, device id, scope claim
case listSessions
case subscribe(Subscribe) // sessionID + sinceSeq (+ verbosity)
case unsubscribe(SessionID)
case approvalRespond(ApprovalID, Decision)
case sendInput(SessionID, AgentInput) // scope ≥ approve; queued per backend rules
case interrupt(SessionID) // scope ≥ control
case ping
// reserved for scope=control / later: startSession, mergeSession, discardSession …
}
struct Subscribe: Codable {
let sessionID: SessionID
let sinceSeq: UInt64? // nil → host sends a snapshot + tail, not full history
let verbosity: Verbosity // .statusOnly | .coalesced | .full
}
enum Verbosity: String, Codable { case statusOnly, coalesced, full }
5.2 Host → Client
enum HostMsg: Codable {
case welcome(Welcome) // accepted scope, host info, capabilities
case sessionList([SessionSummary])
case snapshot(SessionSnapshot) // status + metadata + recent events + cursor
case events(SessionID, [AgentEvent]) // ordered batch; carries seq range
case approvalRequested(ApprovalRequest) // pushed to all subscribers
case approvalResolved(ApprovalResolved) // dismiss on other clients (first wins)
case sessionUpdated(SessionSummary) // status/diff-stat changes
case error(WireError)
case pong
}
struct SessionSummary: Codable {
let sessionID: SessionID
let projectName: String
let backend: BackendID
let status: SessionStatus // idle/running/awaitingApproval/awaitingInput/finished/error
let title: String
let branch: String
let lastSeq: UInt64
let diffStat: DiffStat? // +added / −removed / files
let updatedAt: Date
}
struct SessionSnapshot: Codable {
let summary: SessionSummary
let recentEvents: [AgentEvent] // tail window, oldest→newest
let pendingApprovals: [ApprovalRequest]
let cursor: UInt64 // client continues from here
}
5.3 Catch-up & history
- Cold subscribe (
sinceSeq == nil): host returns asnapshot— current status, pending approvals, and the last N events — not the whole transcript (which can be huge). The client may page older history on demand (a futureloadBefore(sessionID, beforeSeq, limit); out of v1 scope but the cursor model already supports it). - Warm resubscribe (
sinceSeq = k): host replays events> kfrom the canonical transcript JSONL, then live-tails. Because the transcript is the source of truth (BACKEND_PROTOCOL §6), catch-up is just a file read from offset. - Client dedupes on
(sessionID, seq); replays are harmless.
5.4 Approvals across multiple clients (first-responder-wins)
The host broadcasts approvalRequested to every subscriber of that session (and the Mac UI).
The first valid approvalRespond the host accepts wins; it forwards the Decision to the
backend (BACKEND_PROTOCOL §4) and broadcasts approvalResolved so other clients dismiss the
prompt. Late responses get error(.alreadyResolved). decidedBy on ApprovalResolved
records which device answered.
5.5 Throttling / verbosity (ties to BACKEND_PROTOCOL open-Q #4)
Verbosity lets a constrained client opt down:
.full— everyAgentEvent, including partialassistantText/toolCallInputDelta. Good on LAN..coalesced— host buffers partial deltas and flushes on block boundary or every ~150 ms; collapsestoolCallInputDeltainto the finaltoolCallCompleted. Default over relay/cell..statusOnly— onlysessionUpdated,approvalRequested,runFinished. For background / lock-screen monitoring.
The host coalesces before encryption, per subscriber, so each client gets the firehose it asked for without affecting others or the canonical transcript.
6. Push notifications (wake the phone)
When a session enters awaitingApproval or awaitingInput and the target device has no
foreground subscription, the host triggers an APNs push (directly if it has creds, else
via the relay/push service later). Payload is deliberately minimal — session title +
reason, no code or diff content — for privacy and because the push path isn't the E2EE
channel. Tapping the notification opens the app, which establishes the SecureChannel and
pulls the actual ApprovalRequest over the encrypted link.
Device push tokens are registered with the host at pairing/connect; the relay (later) stores
only deviceID → pushToken, never content.
7. Connection lifecycle
discover/relay ─▶ tcp/ws connect ─▶ Noise handshake (IK) ─▶ hello/welcome
▲ │
│ reconnect (backoff) ▼
└──────────────── drop ◀── live: subscribe → snapshot → events/approvals
- Drop (sleep, network change, transport switch): client reconnects with backoff,
re-handshakes (no re-pairing), re-subscribes each open session with its last
seq. Idempotent. - Host sleeps / quits: sessions are local processes that keep running (or are suspended by the OS); on host wake the canonical transcript still holds the truth and clients resync.
- Revocation: removing a device on the host drops its pinned key; its next handshake fails
with
error(.unauthorized).
8. Errors
struct WireError: Codable { let code: Code; let message: String; let sessionID: SessionID? }
enum Code: String, Codable {
case unauthorized // bad/revoked device or insufficient scope
case unknownSession
case alreadyResolved // lost the approval race
case unsupported // e.g. interrupt on a non-control scope
case backpressure // host overloaded; client should back off
case protocolVersion // version mismatch; client must upgrade
}
9. Versioning
hello/welcomeexchange a protocol version; mismatch →error(.protocolVersion).- Messages decode leniently; unknown enum cases/fields are ignored (forward-compatible), so a
newer host can add
HostMsgcases without breaking older clients.
10. Open questions
- Noise pattern choice — XX for pairing + IK for reconnect is the plan; confirm against a vetted Swift Noise implementation (or wrap libsodium / use CryptoKit primitives directly).
- APNs ownership — does the Mac host hold APNs credentials directly (needs an app server / provider token), or is push delegated entirely to the Cloudflare relay phase? Likely the latter, which means no remote push until M5 (LAN clients only get in-app/local alerts).
- Relay token model — issuance, rotation, and revocation of
relayToken; how the room is provisioned at pairing before the relay exists (forward-compat field in the QR). - History paging — finalize
loadBeforesemantics + how far back the phone can scroll vs. what stays Mac-only. - Control scope on iPhone — v1 is
approve-only; decide when/if to exposestartSession/merge/discardfrom the phone (scope=control), which expands the message set in §5.1.