Files
nucleic/docs/SYNC_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

13 KiB
Raw Blame History

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

  1. 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.
  2. 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.
  3. 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.
  4. Resumable & idempotent. Every subscription is driven by the monotonic seq from the backend protocol. Reconnect → re-handshake → re-subscribe sinceSeq → catch up. No message is required exactly once; consumers dedupe on (sessionID, seq).
  5. 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._tcp via Bonjour (NWListener + NWTxtRecord carrying host identity-key fingerprint + protocol version).
  • Client browses, resolves, opens an NWConnection (TCP). Then the SecureChannel handshake (§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 opaque Frames 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 + seq cursors 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)

  1. Mac shows a QR containing: host identity public key, a one-time pairing secret, LAN connection hint, and (later) a relay roomID + bootstrap token.
  2. iPhone scans → has the host's public key over a trusted OOB channel.
  3. 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.
  4. Host records the device in its paired-devices table with a scope (default approve) 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 a snapshot — 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 future loadBefore(sessionID, beforeSeq, limit); out of v1 scope but the cursor model already supports it).
  • Warm resubscribe (sinceSeq = k): host replays events > k from 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 — every AgentEvent, including partial assistantText/toolCallInputDelta. Good on LAN.
  • .coalesced — host buffers partial deltas and flushes on block boundary or every ~150 ms; collapses toolCallInputDelta into the final toolCallCompleted. Default over relay/cell.
  • .statusOnly — only sessionUpdated, 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/welcome exchange a protocol version; mismatch → error(.protocolVersion).
  • Messages decode leniently; unknown enum cases/fields are ignored (forward-compatible), so a newer host can add HostMsg cases without breaking older clients.

10. Open questions

  1. 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).
  2. 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).
  3. 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).
  4. History paging — finalize loadBefore semantics + how far back the phone can scroll vs. what stays Mac-only.
  5. Control scope on iPhone — v1 is approve-only; decide when/if to expose startSession/merge/discard from the phone (scope=control), which expands the message set in §5.1.