Peer Macs' projects and sessions now render and behave identically to local
ones across the Mac app, differentiated only by a globe badge — one
abstraction instead of a parallel mesh section.
Core: new ProjectSummary (project analogue of SessionSummary) with
hostID/hostLabel origin; SessionSummary gains hostID, init(wire:hostID:), and
an origin-qualified sidebarRowID; AppStore.sidebarProjects name-sorts the mesh
union; projectSummary(_:) is the origin-agnostic project(_:); origin-keyed
summaries(for: ProjectSummary) overloads keep same-ProjectID Macs (migrated
databases) from crossing session lists; openSessionID.didSet auto-routes
remote opens (local records shadow; live copies beat moved-tombstones);
origin-aware verbs dispatch the phone's wire verbs via PeerClient.sendCommand
with a listSessions pull as the convergence backstop and surfaced errors when
the peer is unreachable.
Owner-side broadcast fixes so viewers' mirrors converge: mutateSession(+
ForRemote) and createSession broadcast sessionUpdated; a new
HostBroadcast.sessionList is pushed on deleteSession / deleteProject /
setProjectArchived / transfer-restore (deletions previously broadcast
nothing); the wire startChat handler no longer reveals on the owner's screen.
UI: RootView renders one unified tree (MeshSessionRow / remoteProjectHeader /
meshHosts deleted); RemoteProjectView is the summary-driven overview;
NewChatComposer picks projects across the mesh and starts remote chats over
the wire (fails closed when a remembered project is unresolvable; attachments
never silently dropped); detail-view unarchive/discard/approvals/header
controls route to the owner — remote approvals previously no-oped silently,
and opening a remote Control chat fired a spurious setSessionAuto at its
owner.
Docs: SYNC_PROTOCOL sessionList push semantics; MESH_TRANSFER unified-
representation section. Tests: MeshUnifiedSidebarTests (9) — 821 green.
Reviewed by an adversarial multi-agent pass; all confirmed findings fixed.
Co-Authored-By: Claude Fable 5 <[email protected]>
The relay Worker (nucleic-edge at relay.nucleic.blakeslee.xyz) is deployed, so land the
formerly deploy-gated client side of the data path:
- NucleicProtocol/Sync/RelayTransport.swift: RelayAPI (one base URL for REST + WS,
membership -> connection token trade), RelayWebSocket (ordered sends, ping keepalive,
ping-confirmed connect), RelayFrameChannel (client leg, WireFraming inside WS binary,
presence fail-fast when the room has no host), RelayPresence.
- NucleicCore/Sync/RelayAccess.swift: X25519 PoP enrollment (RelayEnrollment), room
credential in the login Keychain (separate from the push credential), membership minting
with re-enroll-on-401.
- NucleicCore/Sync/RelayListener.swift: host SyncListener demuxing the room socket into
per-tag virtual FrameChannels via RelayEnvelope; presence-driven reaping; backoff redial;
injectable RelayRoomSocket seam for tests.
- Wire: additive HostMsg.relayMembership(WireRelayMembership) pushed after every hello
(SyncHost.register -> AppStore mint) so devices paired before the relay adopt it and the
~90-day token refreshes on each connect; the pairing QR also carries a bootstrap
membership so first contact can ride the relay. Old clients ignore the unknown tag.
- AppStore: .relay joins the listener composite behind the Connection-methods checkbox
(failure degrades to a status row), advertises relayRoomID in PeerAddresses, mints the
QR bootstrap in beginPairing.
- Desktop UI: the Nucleic Private Relay toggle is enabled (was "coming soon"); the
LAN-only banner offers it alongside Tailnet.
- iOS: relay is the last dial candidate in HostConnection pair + reconnect (10s handshake
watchdog); PairedHost persists relayRoomID/relayMembershipToken/relayURL; the
relayMembership push updates the registry in place; Settings shows Relay in Transports.
Tests: RelayTransportTests, RelayListenerTests, SyncHostTests relay push + QR bootstrap.
Full suite green (783 core + 113 protocol + 2 new); iOS simulator build succeeds. Live
smoke test against the deployed Worker passed end-to-end (PoP enroll, both token tiers,
two-socket frame round-trip through the Room DO with correct envelope tags).
Known limits: host revoke-on-unpair not wired (endpoint is admin-only); PeerClient
(Mac<->Mac) doesn't dial the relay yet.
Co-Authored-By: Claude Fable 5 <[email protected]>
Completes Phase 5 of the multi-device mesh / session-transfer program (docs/MESH_TRANSFER.md)
except the two-Mac memory-carry spike. All additive + capability-gated; SyncProtocol stays v1.
- Moved-session visibility: additive SessionSummary.movedTo (MovedDestination), decode-defaulted.
A moved session no longer silently vanishes — the source keeps a read-only "Moved to <Mac>"
tombstone under Archived (name resolved live from paired Macs), surfaced on relaunch without
rebuilding a runnable controller, and sent on the wire so phones see it too.
- Relaunch recovery driven from launch (+ on every peer reconnect, single-flight):
AppStore.recoverInterruptedTransfers clears abandoned pre-tombstone locks, discards orphaned
inbound staging, and re-drives a tombstoned commit via SessionTransferCoordinator.recoverTombstoned
(bounded, idempotent; a dest that lost staging leaves the lock, never revives the source).
- Bulk "Hand off active sessions…": transferableSessions + moveSessionsToPeer (sequential, rollup
error) behind a "Hand off…" button → HandoffSheet checklist in RemoteAccessView.
- Arrived-from provenance (mirror of moved-to): GRDB v24 arrived_from_device_id/arrived_at; the
importer stamps them at staging; additive SessionSummary.arrivedFrom (ArrivedFrom); a subtle
"Arrived from <Mac>" marker on the sidebar (live name) + iOS row (host-baked name).
- Stranded-arrival "Activate anyway": the importer persists the staged Session to the staging dir
at .ready, so a destination that relaunches before commit can recoverableInboundTransfers() and
activateRecoveredTransfer()/clearInboundStaging(). AppStore surfaces pendingArrivedTransfers with
activate/discard, shown in a new "Interrupted arrivals" section. (A .ready lock with no manifest
is now cleared as unrecoverable.)
Tests: +6 core, +2 protocol across WireMessageTests, SessionTransferTests, AppStoreTests,
AppStoreSyncBridgeTests. Full package builds; Swift suites green. iOS NucleicRemote edits reviewed
but not compiled here (separate Xcode target).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A 4-dimension × 2-refuter review of the P5 surface confirmed a critical invariant
violation and several majors; all fixed:
- CRITICAL "never runs in two places": a DB tombstone alone didn't make the source
session inert — sendInput/sessionSummaries/snapshot never checked archived/moved and
the live controller stayed addressable, so during the post-tombstone commit round-trip
a connected peer could run the session while the destination also ran it (permanent in
the committedButUnconfirmed case). Fix: a `transferringSessions` set holds the session
inert (hidden from the sync list + snapshot, refuses sendInput) for the whole transfer;
loadSessions never rebuilds a controller for a movedToDeviceID session (relaunch
safety); any post-tombstone commit failure now surfaces as committedButUnconfirmed,
which tears the source down (inert, worktree preserved) and keeps the lock at
.tombstoned for recovery — never reviving or double-running it.
- MAJOR path traversal: the importer built filesystem paths from attacker-supplied
transferID / record.sessionID / branch. Now validated as safe path components (no
`..`/separators; branch must be `nucleic/<safe-slug>`) before any fs work or lock claim.
- MAJOR missing capability gate: moveSession now checks peerClient.canTransfer(to:)
(connected + advertised canReceiveSessionTransfer) before sending any verb.
- MAJOR controller leak: tearDownMovedSession now shuts the controller down + reaps its
sandbox container (like the archive/delete paths), not just dropping the reference.
- MINOR: resolveTransferProject's UUID fast-path no longer matches an archived project.
Tests: committedButUnconfirmed keeps source moved + lock held + worktree preserved;
path-traversal identifiers rejected before fs work; isSafePathComponent/Branch rules.
770 core + 105 protocol green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Wires the tested transfer engine into the live app on the receiving side:
- AppStore owns a SessionTransferImporter via a StandardTransferImportEnvironment
(project resolution by UUID then repo identity; native-transcript destination
computed from the sandbox/claude-home convention; activateTransferredSession
reconstructs the controller + broadcasts sessionUpdated so the sidebar and any
connected phones see the arrival).
- SyncHostBridge receiveTransfer{Offer,Chunk,Commit,Cancel} on AppStore forward to
the importer; capabilities advertises canReceiveSessionTransfer = true.
- The archived-worktree sweep skips a session with an active transfer lock, so a
mid-transfer worktree is never reclaimed/committed-WIP under the importer.
Tested through the real AppStore bridge: capability advertised, unknown-project
reject (projectNotFound), known-project accept with shared-base haveSHAs.
Remaining for a functionally-complete P5 (documented in docs/MESH_TRANSFER.md): the
outbound source driver (a TransferChannel over PeerClient's SyncClient +
AppStore.moveSession with transferability classification + quiesce) and the SwiftUI
(Move-to picker, hand-off flow, moved-row rendering). The engine's loopback test
already exercises the exact coordinator<->importer protocol the production source
path drives.
766 core + 105 protocol tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>