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]>
Completes Phase 4 of the multi-device mesh program (docs/MESH_TRANSFER.md):
a Mac now dials paired sibling Macs with the same platform-neutral SyncClient
the iPhone uses, so it is both a host to its phones and a .control-scope client
of its peers — the dependency session transfer (P5) was waiting on.
Wire (NucleicProtocol, version stays 1, additive + capability-gated):
- PeerAddresses {lanHint, tailnet, relayRoomID, updatedAt}; optional
Hello.addresses / Welcome.addresses; ClientMsg.addressUpdate gated on new
WireCapabilities.canUpdateAddresses; PairedDevice.addresses — all
decode-defaulted so shipped iPhones and pre-mesh stores load unchanged.
Core (NucleicCore):
- PeerClient: serial LAN→tailnet dial over an injectable PeerDialer, capped
backoff, live presence stream, listPeers only when advertised. MacPeerDialer
+ LANDialChannel add the outbound dial-side FrameChannel that didn't exist.
- Symmetric pairing into one PairedDeviceStore (one pasted link makes both Macs
dialable); accepting-Mac confirm (locked decision #4); SyncHost per-deviceID
connection dedup (keep-newest, 2s grace); AppStore lifecycle + meshPeers.
UI: "Paired Macs" section (presence/transport/revoke), paste-link pairing sheet,
QR sheet doubles as copy-link + confirm dialog.
Hardening (adversarial review, 11 defects fixed incl. two security holes):
- Mac-pairing confirm enforced on the reconnect promotion path (a phone can't
reconnect claiming deviceKind=mac to skip the confirm).
- Existing-device hello branch requires the authenticated static key to match
the pin (a pairing party can't claim another device's deviceID).
- startPeerClient guarded against a racing stopSyncServer; confirm timer stored
and cancelled on resolve; pair() treats pre-welcome wireError as terminal and
classifies decline vs unreachable; handshake deadlines; setPresence won't
resurrect an unpaired peer; meshPeersChanged no-ops once the server is down;
pairing UI cancels in-flight pair() on dismiss; LANDialChannel cancels on
.failed + TCP keepalive.
Tests: 871 green (742 core + 98 protocol + 31 new) — wire codec/backcompat,
dedup, address exchange, addressUpdate, mac-confirm decline/phone-skip,
key-mismatch + promotion rejects, PeerClient pair-via-link/reconnect/listPeers
gating/unpair loopback.
Co-Authored-By: Claude Fable 5 <[email protected]>
The admin bearer must not ship in a distributed build, so hosts no
longer use it at all. A Mac self-enrolls with the relay on first use
(POST /v1/host/enroll) and receives its own scoped credential (only
the SHA-256 is stored server-side; the credential lives in the login
Keychain). Register and notify are now authorized per host: a host
can wake only devices it registered itself (admin remains an
operator-only override); room-registered records are owner-tagged
with the DO id and refused out-of-band. Each device record's APNS
environment now wins over the global APNS_ENV secret, so mixed
sandbox/production fleets work.
Settings ▸ Remote's section is now just "Relay" with a single
"Push notifications" toggle that takes effect immediately; the relay
URL and APNS-environment overrides appear only in local dev builds.
PushRelayConfig reduces to enabled + baseURL (built-in production
default) + apnsEnv (production default). PUSH_SETUP.md §3/§4 updated;
stale dotted-bundle-id comments in env.ts/wrangler.jsonc fixed.
Co-Authored-By: Claude Fable 5 <[email protected]>
An app can't hold a secret the machine's owner can't extract (root +
debugger defeats bundling, Keychain, and obfuscation; the Secure
Enclave can't import external keys), and a leaked .p8 can push to
every user of the topic. So the push path is relay-only again: the
key lives exclusively in the relay's Worker secrets and the Mac only
asks the relay to send. Settings and PUSH_SETUP.md revert to the
relay-only form, with the rationale recorded in §4.
Co-Authored-By: Claude Fable 5 <[email protected]>
Answering "can push be local when on LAN": a backgrounded iOS app can
only be woken through Apple's push service, so a LAN-only wake path
doesn't exist — but the *sender* can be this Mac. DirectAPNSSender
holds the APNS .p8 (path + Key ID + Team ID, runtime-configured like
the relay: env vars first, then the Settings-written defaults keys)
and posts the same content-free approval.pending tickle straight to
api.push.apple.com (ES256 provider JWT via CryptoKit, 40-min cache,
per-device throttle) — no Cloudflare dependency for push.
ApprovalPushing unifies the two senders; SyncHost wakes non-connected
paired devices through whichever is configured (direct wins). The
Settings ▸ Remote push section gains a sender picker with the direct
fields (.p8 chooser, Key ID, Team ID). PUSH_SETUP.md §4 rewritten as
Option A (this Mac) / Option B (relay). JWT signing verified against
the public key in tests; payload asserted byte-compatible with the
worker's apns.ts tickle.
Co-Authored-By: Claude Fable 5 <[email protected]>
The relay URL / admin secret / APNS environment are runtime host
settings (PushRelayConfig.resolve reads env then UserDefaults when
the sync server starts), not build settings — and a Finder-launched
app never sees shell env. Add an "Approval push (relay)" section to
Settings ▸ Remote writing the same defaults keys, with an Apply
button that restarts remote access so changes take effect. Update
PUSH_SETUP.md §4 to point at the UI and keep the env-var form for
scripted runs.
Co-Authored-By: Claude Fable 5 <[email protected]>