36 KiB
Nucleic — Multi-Device Mesh + Session Transfer
Status (2026-07-04): in progress. Phases 1, 2 & 4 done — the relay data path is live end-to-end against the deployed
nucleic-edgeWorker; Phase 5 feature-complete except the two-Mac memory-carry spike (engine + destination + source-driver/UI + moved-session visibility + relaunch recovery + bulk hand-off + arrived-from provenance + stranded-arrival "activate anyway"); Phase 3 in progress — iOS build unbroken (was failing since the P4/P5 merge) +IdentityStoremulti-host registry + migration + host switcher + the simultaneous[HostID: HostConnection]multiplexer (compiles + demo-verified; live two-Mac test + host-qualified notifications remain). Test counts below are green as of this writing (Swift: 775 core + 107 protocol; edge: 50). The iOS app builds + runs in the simulator (xcodebuild). Design derived from a 6-lane code explore + 3 design passes + adversarial critique; full working notes in the session planlively-puzzling-cloud.md.
Goal
Any Nucleic device (Mac, iPhone, future cloud nucleicd) connects to any other over the
user-selected set of connection methods, picking the best available path automatically — the user
chooses methods via multi-select (Local network / Tailnet / Covalence), and the UI
strongly recommends at least Tailnet or Relay. Plus session transfer between Macs (take the
laptop to work → hand sessions to a Mac that stays home). A stepping stone toward Nucleic Cloud
(CLOUD_RUNTIME.md), not a fork.
Locked invariants
- N hosts × M viewers over the one wire protocol. No replication/consensus; each session has
exactly one owning host (single-writer
SessionController). A Mac is both a host (existingSyncHost) and a client (dials sibling Macs with the sameSyncClientthe iPhone uses). SyncProtocol.versionstays 1. Everything additive + capability-gated.ClientMsgdecoding throws on unknown tags → a client must not send a new verb unless the host advertised the matchingWireCapabilitiesflag. NewHostMsgtags are safe (.unknownfallback). New struct fields usedecodeIfPresentdefaults (thecanFetchDiffpattern).HostID= full SHA-256 hex of the host static key (DeviceIdentity.hostID), never the display name. Multi-host clients key all state by(hostID, sessionID); the wire stays unqualified (one connection = one host). The relayroomIdis the same string.- Transfer = 2-phase commit, tombstone-before-commit. Git state moves as a
git bundleover the E2EE channel (no shared-remote requirement). Tombstone =moved_to_device_idcolumns +archived, not a newSessionStatuscase (that would silently break shipped iPhones' list decode). Agent-memory carry (native transcript +backendSessionID) is best-effort with a fresh-context fallback; credentials never transfer. - Every difference is a declared capability, never an assumption.
Canonical names
PeerKind (raw-string: mac/iphone/cloud) · PeerCapabilities {canHost, canRunAgents} ·
PeerAddresses {lanHint, tailnet, relayRoomID} · SyncTransportSet (key nucleic.sync.transports)
· Hello.deviceKind + Hello.clientCaps: WireClientCapabilities {mesh, canHost, canRunAgents} ·
WireCapabilities.canListPeers / .canReceiveSessionTransfer · Mac dialer PeerClient · iOS store
key nucleic.pairedHosts.
Phase status
✅ Phase 1 — Multi-select connection methods (Mac) — DONE, tested
SyncTransportSetin TailnetTransport.swift (resolveEnabled/saveEnabled, keynucleic.sync.transports, envNUCLEIC_SYNC_TRANSPORTS), migrates the legacy single-choice value (tailnet→{lan, tailnet}).CompositeSyncListener(SyncTransport.swift) is now partial-failure tolerant with anonChildStartFailurecallback; only a total failure throws. A pending Tailscale login no longer takes down LAN.- AppStore.swift
startSyncServerlistens on every enabled method at once; per-method health insyncActiveTransports/syncTransportHealth; pairing QR carries all live hints;HostInfo.hostIDfixed toDeviceIdentity.hostID. - RemoteAccessView.swift: three method toggles (Local network / Tailnet / Covalence — relay disabled until Phase 2), status dots, a non-removable last method, and a persistent LAN-only recommendation banner.
- Tests:
SyncTransportSettingTests,CompositeSyncListenerTests.
✅ Phase 2 — Relay data path — DONE; live relay smoke-tested end-to-end
Done + verified (security-critical):
- Worker hardening in
cloud/nucleic-edge: relayEnroll.ts adds X25519 proof-of-possession enrollment — a host must prove it holds the static private key that derives itsroomId, closing the hole where anyone holding the public host key could enroll and evict the real host. Server-sideroomId = sha256(staticKey)derivation; token minting bound to the PoP-proven room. room.ts replaces broadcast-to-all with role-routed per-peer forwarding (a[0x01][8-byte deviceId tag]envelope, one-host-per-room eviction, 20 MiB frame cap). Tests:test/relayEnroll.test.ts,test/room.test.ts(edge suite 50 green, typecheck + wrangler dry-run clean). - Cross-stack contracts pinned to test vectors so a deploy can't silently break:
RelayEnrollment.swift (PoP proof matches
the Worker byte-for-byte), RelayEnvelope.swift
(routing tag + wrap/unwrap match
room.ts),PairingPayloadrelay fields (roomID / token / URL, additive). Tests:RelayEnrollmentTests,RelayEnvelopeTests,PairingPayloadTransportTests.
Formerly deploy-gated remainder — landed once nucleic-edge went live (relay.nucleic.blakeslee.xyz):
- Shared client leg: RelayTransport.swift —
RelayAPI(one base URL for REST + WS;NUCLEIC_RELAY_URLdev override),RelayWebSocket(ordered sends, ping keepalive, ping-confirmed connect),RelayFrameChannel(membership → connection token trade,WireFramingprefix inside WS binary messages, fail-fast when presence says the room has no host),RelayPresence. - Host side: RelayAccess.swift (PoP enrollment via
RelayEnrollment, room credential in the login Keychain — separate from the push credential — membership minting with re-enroll-on-401) and RelayListener.swift (SyncListenerdemuxing the room socket into per-tag virtualFrameChannels viaRelayEnvelope, presence-driven channel reaping, capped-backoff redial). Wired intoAppStore.startSyncServerbehind the.relaytransport checkbox (now enabled inRemoteAccessView);currentLocalAddressesadvertisesrelayRoomID. - Credential distribution, both routes: the pairing QR carries a bootstrap membership
(
beginPairingmints for a freshpair-…id, so first contact can ride the relay), and a new additiveHostMsg.relayMembership(WireRelayMembership) is pushed after every hello (SyncHost.register→ mint bound to the device's real id) — which is how devices paired before the relay adopt it and how the ~90-day token refreshes. Old phones ignore the unknown tag (.unknownfallback). - iOS: relay is a real dial candidate (always last — direct paths win) in
HostConnectionpair + reconnect;PairedHostpersistsrelayRoomID/relayMembershipToken/relayURL(decode-defaulted); therelayMembershippush updates the registry in place (no reordering); 10 s handshake watchdog on the relay leg mirrors the LAN one. - Verified:
RelayTransportTests(URL building, presence, membership codec),RelayListenerTests(demux/reap/redial over a fake room socket), and a live smoke test against the deployed Worker — PoP enroll (server room derivation matches), host+client token mints/trades, and a two-socket frame round-trip through the Room DO with correct envelope tagging both directions.
Known limits: host-side revoke-on-unpair isn't wired (/v1/relay/revoke is admin-only today;
membership tokens age out in ≤90 days and Noise still gates content). A device that reconnects
mid-session keeps its tag, so the host converges through the fresh handshake failing the stale
handler (one retry) rather than instantly. Mac↔Mac (PeerClient) doesn't dial the relay yet —
PeerAddresses.relayRoomID is advertised, but sibling Macs hold no membership tokens.
✅ Phase 4 — Peer model, Mac↔Mac pairing, presence — DONE, tested
Foundation (P4a):
- PeerTypes.swift:
PeerKind,PeerCapabilities,WireClientCapabilities,PeerSummary. Hello.deviceKind+clientCaps(optional);WireCapabilities.canListPeers;ClientMsg.listPeers→HostMsg.peerList([PeerSummary]);PairedDevice.kind+capabilities(decode-defaulted custominit(from:), so pre-mesh JSON stores load unchanged).ConnectionHandlerrecordsdeviceKindat pairing;SyncHost.connectedDeviceIDs();AppStore.peerSummaries()advertisingcanListPeers;SyncClientthreadsdeviceKind/clientCaps+ surfaces a.peerListevent; iOSRemoteStore.meshPeers.- Tests:
WireMessageTests(codec + backcompat),SyncHostTests(loopbacklistPeers+ device-kind at pairing),FilePairedDeviceStoreTests(pre-mesh decode defaults).
Remainder (P4b) — done + verified:
- Addresses on the wire:
PeerAddresses {lanHint, tailnet, relayRoomID, updatedAt}(PeerTypes.swift); optionalHello.addresses/Welcome.addresses;ClientMsg.addressUpdate(PeerAddresses)gated on newWireCapabilities.canUpdateAddresses(the sender refreshes its own store row);PairedDevice.addresses(decode-defaulted). The relay hint is populated whenever the relay method is up (P2). PeerClient(PeerClient.swift): the Mac dials paired sibling Macs with the sameSyncClientthe iPhone uses —.controlclaim,deviceKind: mac,clientCaps {mesh: 1, canHost, canRunAgents}— serial LAN→tailnet dial with per-candidate timeout, capped backoff, live presence stream,listPeersonly when advertised. Dial seam (PeerDialer.swift):MacPeerDialer= new outboundLANDialChannel(dial-sideLANChannelwith a TCP-ready timeout) +TailnetNode.shared.dialoff the node the listener already runs.- Symmetric pairing, one store.
pair(with:)(pastednucleic://pairlink) pins the remote Mac into the samePairedDeviceStorethe host pins inbound devices into — and the accepting Mac's ordinary hello path pins the joiner — so one paste makes both directions dialable (what P5's "source dials destination" needs regardless of who pasted). Records withkind.canOwnSessions && capabilities.canHostare the dial targets; revoke = one store row. - Accepting-Mac confirm (locked decision #4): mac-kind first pairings consult
SyncHostBridge.approveMacPairing(defaulted true for tests/minimal hosts);AppStorepublishespendingMacPairRequest→ confirm dialog on the QR sheet, 120 s fail-closed timeout, denied on sheet close/server stop. Phones keep pure TOFU. SyncHostper-deviceID dedup: keep-newest with a configurable grace (default 2 s) — the old handler is closed after the grace; registry keyed post-hello only.- UI (RemoteAccessView.swift): "Paired
Macs" section (presence dot, transport, revoke, per-peer error), "Pair another Mac…"
paste-link sheet, QR sheet doubles as copy-link ("Add device…"), confirm dialog.
AppStore.meshPeers/inboundConnectedDeviceIDspublish live presence (SyncHostBridge.meshPeersChangedfires on pair/connect/disconnect/addressUpdate, which also fixed the stalepairedDeviceslist). - Tests:
WireMessageTests(PeerAddresses codec, legacy tolerance, addressUpdate, capability flag),SyncHostTests(dedup keep-newest + distinct-device coexistence, hello/welcome address exchange, addressUpdate → store, mac-confirm decline/phone-skip),PeerClientTests(pair-via-link pins both sides, IK reconnect after drop,listPeersgating vs a non-advertising host, unpair, self-pair/garbage-code rejects, endpoint derivation incl. bracketed IPv6),FilePairedDeviceStoreTests(foundation-era JSON without addresses still loads).
Hardening (adversarial review pass): a 4-dimension × 2-refuter review surfaced and fixed —
the mac-pairing confirm is now enforced on the reconnect promotion path too (a device can't
pair as a phone then reconnect claiming deviceKind: mac to skip the confirm), and the
existing-device hello branch requires the just-authenticated static key to match the pin (a
pairing party can't claim another device's deviceID to hijack its record/registry slot); the
startPeerClient post-commit awaits are guarded against a racing stopSyncServer (host-token
check); the mac-pairing confirm timer is stored and cancelled on resolve (no stale timer denying
a later attempt); pair() treats a pre-welcome wireError as terminal (no re-prompting the
accepting user per hinted endpoint) and classifies decline vs unreachable; consume()/pair()
have a handshake deadline (a silent endpoint no longer wedges the manage loop); setPresence
won't resurrect an unpaired peer from an in-flight event; meshPeersChanged no-ops once the
server is down (a closing handler no longer wipes pairedDevices); the pairing UI cancels an
in-flight pair() on dismiss; LANDialChannel cancels on .failed and enables TCP keepalive.
Known limits: addressUpdate fires only when this Mac's own addresses change (the pre-pair
refresh + setLocalAddresses); it's not yet wired to a live listener-port watcher. Mac↔Mac
revoke is one-sided — the revoked Mac keeps dialing and reads as offline (it can't distinguish
revocation from the host being down). Half-open detection is TCP-keepalive on the LAN path only;
a tailnet session's liveness is the tsnet layer's concern.
◐ Phase 3 — iOS multi-host — FOUNDATION STARTED
iOS build unbroken first (prereq): the P4/P5 mesh work added ClientMsg /
SyncClient.Event cases that were never handled in RemoteStore's two exhaustive switches (the
demo-simulator ClientMsg handler + the event handler), so NucleicRemote didn't compile —
swift build never builds the iOS target, so it landed unnoticed. Fixed (the new cases are inert on
a phone). The iOS app now builds for the simulator (xcodebuild … BUILD SUCCEEDED), so Phase 3 work
is compile-verifiable here.
Paired-host registry (done + iOS-build-verified): IdentityStore
(IdentityStore.swift) single
nucleic.pairedHost slot → a nucleic.pairedHosts registry (ordered [PairedHost], keyed by
fingerprint = hostID) with a one-time migration of the legacy value. New API
(pairedHosts/pairedHost(id:)/upsertPairedHost/removePairedHost) for the coming multiplexer; a
single-host bridge keeps every caller + behavior unchanged (loadPairedHost = active/most-recent,
savePairedHost = upsert+activate, clearPairedHost = remove active). SettingsView gains a
"Paired Macs" list (each host + fingerprint + "Active" + per-host remove) — the visible artifact +
the removal path now that a new pairing keeps (not overwrites) the prior Mac.
Host switcher + 2-host demo (done + simulator-verified): the safe, verifiable slice — switch
which paired Mac the phone views, reusing the existing (proven) single-connection reconnect rather
than rewriting the connection state machine. RemoteStore
(RemoteStore.swift) gains
activeHostID (the Mac the flat sessions/hostName/dashboard projection reflects), hostChoices
(paired registry live / mock hosts in demo), switchHost(to:) (live re-points via reconnect();
demo swaps the mock host, preserving in-demo edits); reconnect()/pair() set the active host and
unpair() forgets the active Mac and falls back to a remaining one. Demo seeds two mock Macs; a
toolbar host-switcher menu in SessionsView shows when >1 Mac. Verified in the iOS Simulator (demo):
the menu lists both Macs and switching swaps the whole session projection + the tab badge. Single-host
behavior is unchanged (one Mac ⇒ switcher hidden ⇒ flat state exactly as before).
Multiplexer (done, live path pending a two-Mac test): HostConnection
(HostConnection.swift) is the
per-host connection engine lifted out of RemoteStore — one instance owns a single Mac's
SyncClient + LAN→tailnet chain + reconnect + tailnet-node lifecycle + event stream, and keeps that
Mac's projection, talking back through a Callbacks struct. RemoteStore now owns
connections: [HostID: HostConnection] and connects every paired Mac at once: reconnect()
dials all (dropping since-unpaired ones), pair() adds a connection + makes it active while others
run, unpair() drops the active and switches to a remaining one. The flat @Published state mirrors
the active connection via callbacks, so switchHost() is instant (every Mac is already
connected — no reconnect). Aggregate concerns merge across hosts: the app badge counts needs-you
across all Macs, Live Activity summarizes all live sessions, and a notification-answered approval
broadcasts to every connection (no hostID in the notification yet). ~450 lines of connection
machine moved out of RemoteStore. Compiles + the demo host switcher is simulator-verified;
the live multi-connection path (simultaneous dials, reconnect/teardown, the shared embedded tailnet
node) is compile-verified only — needs two real Macs.
Remaining: the prerequisite spike (~1 h — confirm one TailnetNode.shared dials two Macs on the
same tailnet) and a live two-Mac test of the multiplexer; then host-qualified notifications / Live
Activity (userInfo carries hostID so respondFromNotification targets the owning host instead of
broadcasting). Optional next: an aggregate session list (all hosts' sessions grouped by Mac) rather
than the switch-active model.
✓ Mac sidebar — unified local/remote representation (mesh session sync)
The Mac app now represents peer Macs' projects and sessions identically to local ones, through one abstraction layer rather than a separate mesh section:
- Model:
ProjectSummary(Project.swift) is the project analogue ofSessionSummary— one compact shape both origins map into, withhostID/hostLabelas the origin marker. The localSessionSummarygainedhostID+init(wire:hostID:), so one row model (and oneSessionRow) serves both. Row identity is origin-qualified (sidebarRowID) because a moved session's local tombstone and its live twin mirrored back from the new owner share a rawSessionID. - Lists:
AppStore.sidebarProjectsinterleaves every connected peer's projects with local ones (name-sorted, like the local list);summaries(for:)/archivedSummaries(for:)serve remote projects from the peer's mirrored session list through the same comparators; Recents and the recent-count union the mesh.projectSummary(_:)is the origin-agnosticproject(_:). - One project, one row (whatever device it's on): every device in a mesh holds the same
projects, so a peer's copy of a project we also hold is never a second section — its chats fold
into ours and carry the origin themselves (
SessionSummary.foldedHostLabel→ a globe on the row). Devices register projects independently, so the same repo has a differentProjectIDon each one;AppStore.localProjectID(forPeerProject:on:named:)maps a peer's id onto ours viaProjectDescriptor.matches(UUID → root-commit SHA → normalized remote) read from the peer's presence card, falling back to the project name for the window before any card is published (presence is cast-backed, so a LAN-only pair may have none). A runner's clone that matches on none of those is still folded by itsCovalenceSessionMirrorrecord. Only a project a peer holds and we genuinely don't gets a header of its own, so its chats aren't lost. Tests:MeshUnifiedSidebarTests.peerCopyOfALocalProjectFoldsIntoOneSection,.peerProjectFoldsByRepoIdentityWhenNamesDiffer,.sameProjectOnTwoOriginsSharesOneSection. - Selection:
openSessionID.didSetroutes remote sessions itself (viaremoteSessionLocation, which prefers local records so tombstones aren't hijacked) — every writer ofopenSessionIDopens local and remote identically.openSession(_ summary:)is the row-level opener that honors the row's own origin. - Verbs: mesh Macs grant each other
.control, so the sidebar's context menu / swipes and the chat header dispatch the same wire verbs a phone uses (PeerClient.sendCommand): favorite, archive, delete, interrupt, rename, model/effort/auto/ship, andstartChatinto a remote project (new-chat composer picks across the mesh;RemoteProjectViewis the overview page). Owner-side fixes make mirrors converge:mutateSession(+ForRemote) broadcastsessionUpdated, anddeleteSessionpushes a freshsessionList(deletions had no broadcast at all). - Still local-only (hidden, not inert, for remote): Mark Done (the wire verb
markSessionDoneexists — the phone's Sessions list and chat ⋯ menu use it, gated onWireCapabilities.canMarkSessionDone— but this sidebar doesn't track that capability per peer yet, so peer rows stay hidden), project-level rename/archive/delete/convert, branch/worktree/attachments/ship in the composer, and the worktree-rooted chrome (panels, Build/Run, git ops).
◐ Phase 5 — Session transfer Mac→Mac — ENGINE + DESTINATION DONE; source-driver + UI pending
Engine (done + tested): the full 2-phase-commit transfer engine, proven end-to-end by a loopback test (real temp git repos + real GRDB + in-memory channel):
- Wire (TransferMessages.swift):
TransferOffer/Accept/Reject/Chunk/ChunkAckpayloads,SessionTransferRecord,ProjectDescriptor(matches by UUID / root commit / normalized remote),TransferRejectReason(raw-string, forward-compatible).ClientMsgsource→dest verbs +HostMsgdest→source replies; the replies also map toSyncClient.Event+messageLoop(the source receives them as a client).WireCapabilities.canReceiveSessionTransfergates every source send. - Persistence (v23):
moved_to_device_id/moved_attombstone columns (rides the existingarchivedread/sweep path — not a newSessionStatuscase) + asession_transfertable with a UNIQUE-while-active index; store methodsbeginTransfer/updateTransferState/activeTransfer/allActiveTransfers/tombstoneSession/activateTransferredSession(atomic + idempotent). - Git (TransferGit.swift +
WorktreeManager.swift):
createTransferBundle(prereq-based, prereq-free fallback),createFromTransfer(verify + fetch + worktree add),discardAfterTransfer,presentSHAs/branchExists;TransferGitProbefor repo shape (shallow/partial/LFS/submodule) + identity. git-bundle round-trip verified against real repos. - Transcript:
SessionHeader.maxSupported+TranscriptHeaderRewriter(repoint paths / dropbackendSessionIDfor the fresh-context fallback) — the first consumer of transcript versioning. - Coordinator/Importer/Package (Transfer/):
SessionTransferCoordinator(source, tombstone-before-commit, windowed chunk acks, typed errors),SessionTransferImporter(dest, stage-non-runnable → activate-on-commit, best-effort native memory carry with fresh-context fallback),SessionCarbonCopy(the Carbon Copy replica — items + per-item sha256, bundle built post-accept vshaveSHAs).
Destination integration (done + tested): ConnectionHandler dispatches the transfer verbs
gated on .control + mac peer (peerKind now persisted); AppStore owns the importer via a
StandardTransferImportEnvironment (project resolution by UUID/identity, native-path computation,
activateTransferredSession → reconstruct controller + broadcast), advertises the capability, and
teaches the archived-worktree sweep to skip a mid-transfer session. Tested through the real
AppStore bridge (capability advertised, unknown-project reject, known-project accept w/ haveSHAs).
Hardening (adversarial review, 4 dims × 2 refuters): fixed the critical
"never-runs-in-two-places" gap — a DB tombstone alone didn't make the source session inert, so
during the post-tombstone commit round-trip a connected peer could still sendInput/list/snapshot
it. Now a moving session is held in transferringSessions (hidden from sessionSummaries/
snapshot, refuses sendInput) for the whole transfer, loadSessions never rebuilds a controller
for a movedToDeviceID != nil session (relaunch safety), and any post-tombstone commit failure
surfaces as committedButUnconfirmed — which tears the source down (inert) and keeps the lock at
.tombstoned (worktree preserved) for recovery, never reviving or double-running it. Also fixed:
path-traversal in the importer (validate transferID/sessionID/branch as safe components before
building any filesystem path), the source now checks canReceiveSessionTransfer before sending,
tearDownMovedSession fully shuts the controller + reaps its container (no leak), and
resolveTransferProject's UUID fast-path no longer matches an archived project.
Source driver + UI (done + tested): PeerClient.openTransferChannel(to:) binds a
PeerClientTransferChannel to the destination's live SyncClient, routing the transfer-reply
SyncClient.Events through per-peer sinks (and .disconnected on a mid-transfer drop);
PeerPresence.canReceiveSessionTransfer is learned from the welcome. AppStore.moveSession(_:to:)
classifies transferability (TransferClassifier for status / nvrsion / nested-or-parent /
no-worktree + TransferGitProbe repo shape), quiesces via finalize(.auto("nucleic: transfer snapshot")), packages the branch + transcript (+ best-effort native memory) and the
ProjectDescriptor, then runs the coordinator over the peer channel; on success the source is
tombstoned + torn down. A "Move to Mac" submenu on the sidebar row lists connected peer Macs that
advertise the capability. Tested end-to-end over a real PeerClient channel + SyncHost
(PeerTransferChannelTests), not just the in-memory loopback.
Moved-session visibility (done + tested): a moved session no longer silently vanishes.
SessionSummary.movedTo: MovedDestination? {deviceID, deviceName}
(WireMessages.swift, additive /
decode-defaulted, trailing init param) carries where it went. The source keeps the tombstone as a
read-only row: AppStore.movedDestination(for:) resolves the destination's name from the
paired-device store; wireSummary stamps it; finalizeMovedTeardown re-upserts an archived+moved
Core summary; loadSessions re-surfaces moved tombstones on relaunch without rebuilding a
runnable controller; and sessionSummaries() includes them (from the DB) so a (re)subscribing
phone sees them. The Mac sidebar renders "Moved to " under Archived (name resolved live from
pairedDevices, so it upgrades past the persisted fallback once the sync server loads), suppresses
the mutating row actions, and the iOS row renders the host-baked name. Tests:
WireMessageTests.sessionSummaryCarriesMovedTo (+ legacy tolerance),
AppStoreTests.movedSessionSurfacesAsTombstoneAfterRelaunch (relaunch surfacing, no revive,
wire marker). iOS view edits not compiled here (separate Xcode target).
Relaunch recovery (done + tested): AppStore.recoverInterruptedTransfers() reconciles any
transfer a crash/quit left mid-flight — driven from launch (after loadSessions in
NucleicApp.swift) and again on every peer (re)connect (a
fire-and-forget, single-flight call in meshPeersChanged). Abandoned pre-tombstone outbound locks
(.offering/.streaming/.awaitingReady) are cleared (the source session lives here again, so the
UNIQUE-while-active guard frees for a re-move); orphaned inbound .staging is dropped
(discardStaleInboundTransfers); and a durably-tombstoned outbound source finishes its 2-phase
commit via SessionTransferCoordinator.recoverTombstoned — a bounded, idempotent re-send of
transferCommit to the destination peer that, on the confirm, discards the stale source worktree
and clears the lock (a destination that also relaunched and lost its staging just leaves the lock at
.tombstoned, never reviving the source). Tests: SessionTransferTests.recoverTombstoned* (both the
reconnect-completes and destination-lost-staging paths, over the real loopback + GRDB) +
AppStoreSyncBridgeTests.recoverInterruptedTransfersCleansUpAbandonedLocks.
Bulk hand-off (done + tested): the "take the laptop home, hand its sessions to the Mac that
stays" flow. AppStore.transferableSessions() lists the eligible live chats (session-level
TransferClassifier, excluding archived / mid-transfer / mid-turn / nested / no-worktree);
moveSessionsToPeer(_:to:label:) runs the selected chats through the same single-session path in
sequence (one per-peer channel at a time), folding per-chat failures into one lastError rollup and
counting a committedButUnconfirmed as moved. UI: a "Hand off…" button on each connected,
transfer-capable Paired-Mac row (RemoteAccessView.swift)
opens a HandoffSheet — a select-all-by-default checklist of eligible chats with a "Move N chats"
action. Test: AppStoreSyncBridgeTests.bulkHandoffListsEligibleAndRollsUpFailures (eligibility,
archived exclusion, unreachable-peer rollup + restore).
Arrived-from provenance (done + tested): the mirror of moved-to, on the destination. GRDB v24
adds arrived_from_device_id / arrived_at to session; the importer stamps them at staging (source
= the inbound offer's device id); Session carries the fields. Wire: additive
SessionSummary.arrivedFrom: ArrivedFrom? {deviceID, deviceName} (decode-defaulted). The Core summary
carries the raw source id (it rides every live-session summary, so no call site threads it) and the
Mac sidebar resolves the name live from pairedDevices, showing a subtle inbound chevron with the
source Mac in its tooltip; the wire summary bakes the resolved name (AppStore.arrivedSource(for:))
so a phone renders "Arrived from " (iOS folds it into the row subtext). Tests:
WireMessageTests.sessionSummaryCarriesArrivedFrom, SessionTransferTests.happyPath…
(real GRDB v24 round-trip of the provenance), AppStoreTests.arrivedSessionSurfacesProvenanceInSummaries.
Stranded-arrival recovery — "Activate anyway" (done + tested): the last 2PC gap — a destination
that relaunches after staging (.ready) but before commit. At .ready the importer now writes the
staged Session to …/transfers/<id>/staged-session.json
(SessionTransferImporter.swift),
so a relaunch (which loses the in-memory offer/staged session) can rebuild it:
recoverableInboundTransfers() scans .ready inbound locks + reads each manifest (dropping any whose
manifest is gone as unrecoverable); activateRecoveredTransfer(_:) inserts the session + flips the
lock to .activated (reusing the commit path) + notifies the env to build the controller;
clearInboundStaging(_:) drops the staging for a discard. AppStore.recoverInterruptedTransfers
surfaces these as pendingArrivedTransfers; activateArrivedSession/discardArrivedSession drive
the choice (discard also tears down the imported worktree/branch + placed transcript). UI: an
"Interrupted arrivals" section in RemoteAccessView.swift
with per-chat Activate / Discard. Tests: SessionTransferTests.recoverAndActivateStrandedInboundAfterDestRelaunch
.discardStrandedInboundClearsStagingAndLock(real fresh-importer "relaunch" over the same store/stagingRoot),AppStoreSyncBridgeTests.recoverInterruptedTransfersSurfacesStrandedArrival.
Transfer from either end — "bring it here" / brokered peer→peer (done + tested): the menu used to
appear only on a chat this Mac owns, because the wire had no way to ask another Mac to let go of one.
ClientMsg.requestSessionTransfer(SessionID, deviceID) closes that: the requester only names the
destination and the owner runs its own moveSession, so every transfer still originates at the
machine holding the worktree — the 2PC contract above is untouched. Gated by
WireCapabilities.canTransferOnRequest (field-additive; an older owner decodes false and the menu
stays hidden) plus the same .control-scope + session-owning-peer check as the transfer verbs, and
run off the message loop so packaging a session doesn't stall the connection.
AppStore.transferDestinations(for:) answers for either origin: a local chat lists the connected
session-owning peers as before; a peer's chat lists this Mac (always — it's connected to the owner
by definition) plus the owner's own online session-owning peers, which is what makes a peer→peer move
this Mac merely brokers possible. AppStore.transferSession(_:to:) routes accordingly.
Tests: WireMessageTests.transferClientMessagesRoundTrip + .transferOnRequestCapabilityToleratesOlderHosts,
MeshUnifiedSidebarTests.remoteSessionOffersThisMacAndTheOwnersOtherPeers.
Covalence fold on transfer (done + tested): moving a chat to a cloud runner records the same
CovalenceSessionMirror a runner dispatch does (AppStore.noteTransferLanding, on both commit
paths and on tombstone recovery) — the backstop for a runner clone whose repo identity doesn't match
ours, where the general one-project-one-row fold above can't tie the two together and the user would
see the project twice, once local and once globe-badged. Test:
MeshUnifiedSidebarTests.transferToCloudRunnerFoldsUnderTheOriginProject.
Remaining: the prerequisite spike (hand-copy a native transcript between two Macs, try
claude --resume) still gates whether memory carry stays on by default or the importer forces the
fresh-context fallback — the only Phase 5 item left, and it needs two physical Macs. (Nice-to-have
follow-up: clear the arrived-from marker once the user sends their first message in an adopted session.)
Compatibility & migrations
| Store | Old → New | Phase |
|---|---|---|
| Mac transport | nucleic.sync.transport single → nucleic.sync.transports set |
1 ✅ |
| PairedDevice JSON | + kind / capabilities, decode-defaulted |
4 ✅ |
| PairedDevice JSON | + addresses (PeerAddresses), decode-defaulted |
4 ✅ |
| PairingPayload | + optional relay roomID/token/URL CBOR keys |
2 ✅ |
| iOS hosts | nucleic.pairedHost single → nucleic.pairedHosts registry |
3 ☐ |
| GRDB | + moved_to_device_id/moved_at; + session_transfer table (v23) |
5 ✅ |
| GRDB | + arrived_from_device_id/arrived_at (v24, transfer provenance) |
5 ✅ |
Known test-isolation note
Headless swift test could deadlock: AppStore first-launch set the global
nucleic.container.serviceEnabled, flipping parallel SessionController tests into the sandboxed
path → a real Keychain read → an invisible ACL prompt → hang holding ClaudeLoginKeychain.lock. A
proper fix (task-local container settings + Keychain guard) has since landed on dev. If a run still
hangs, pre-seed the swiftpm-testing-helper defaults domain (serviceEnabled=0,
firstLaunchCompleted=1).