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]>
280 lines
13 KiB
Markdown
280 lines
13 KiB
Markdown
# 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 `AgentEvent`s
|
||
and the approval round-trip from [BACKEND_PROTOCOL.md](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 `Frame`s 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 `Frame`s 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 `Frame`s 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
|
||
|
||
```swift
|
||
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
|
||
|
||
```swift
|
||
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
|
||
|
||
```swift
|
||
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.
|