Files
Lawrence ChenandClaude Opus 4.8 58c2e40065 iOS: don't lose saved hosts/IPs on upgrade (paired-Mac backup + restore) (#6405)
* ios: failing test — paired-Mac store strands data on future schema version

Adds the paired-Mac backup/restore design doc and a red regression test:
when an older build opens a paired-macs.sqlite3 whose user_version was
bumped by a newer build, the store currently throws unknownSchemaVersion
and every read fails, surfacing as total loss of the user's saved hosts
even though the rows are still on disk. The fix follows in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: don't strand the paired-Mac store on a newer on-disk schema version

runMigrations threw unknownSchemaVersion when user_version exceeded this
build's, failing ensureReady and every read — so a user who upgraded (future
schema vN) and then ran an older build saw all saved hosts as gone, though the
rows were intact. Schema migrations are additive by contract, so older builds
can still read the columns/tables they know. Degrade gracefully: log and read
existing rows, never reset user_version (no destructive downgrade marker).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* presence: per-user pairedMacs backup collection (server)

Adds the first client-owned sync collection. The phone backs up its local
saved-host list (including manually typed host/IPs, which live only on-device
today) so it survives an app upgrade, bundle-id change, or reinstall.

- New POST /v1/sync/paired-macs route → DO RPC backupPairedMacs(teamId, userId,
  ops), mirroring the trusted heartbeat RPC rather than expanding the live WS
  inbound surface.
- Per-user privacy scoping by physical collection name pairedMacs:<userId>
  (userId is verified, never client input); outgoing frames are relabeled to the
  logical `pairedMacs` so the client never sees the suffix. Reuses the whole
  generic snapshot/delta/tombstone/GC machinery unchanged.
- Subscribe forwards the verified x-presence-user-id; the DO pins it on the WS
  attachment and serves/broadcasts pairedMacs scoped to that user.
- Per-user record cap, op bounds, route byte budget (mirrors heartbeat).
- bun tests: parse bounds, per-user isolation, cap, relabel, tombstone, no-op
  idempotency. Full suite 144 pass; typecheck + wrangler dry-run clean.

Additive and live-safe: new collection keys only, no class migration, old DO
instances ignore the new RPC/collection during rollout.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* presence: GET /v1/sync/paired-macs restore path

Adds the read side of the per-user backup: DO RPC listPairedMacs returns the
live (non-tombstone) saved-host records newest-first, served by GET on the same
authenticated, user-scoped route. The phone fetches this on sign-in to restore
saved hosts after a reinstall or bundle-id change. Decouples restore from the
WS sync client (which is built but not yet wired into the live app). bun test
for list ordering + per-user isolation; strict test typecheck clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* presence: shape-aware equality for pairedMacs (no rev churn on timestamp drift)

A backup upsert whose routes/name/active are unchanged but whose lastSeenAt
advanced (every route refresh, and every full reconcile push on sign-in) must
not re-mint a rev or broadcast a delta. Compare list-shape only, ignoring
timestamps, mirroring the device-list collection. Stored lastSeenAt then tracks
the last shape change (correct as-of-rev semantics for restore ordering).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: paired-Mac backup uploader + restore-on-sign-in

Wires the iOS side of saved-host durability behind the mobilePairedMacBackup
flag (DEBUG-on/Release-off, env/UserDefaults overridable):

- PairedMacBackupClient: HTTP client for /v1/sync/paired-macs (POST ops, GET
  restore), auth mirrors PresenceClient/DeviceRegistryService.
- BackingUpPairedMacStore: a MobilePairedMacStoring decorator so EVERY paired-Mac
  mutation flows through one seam — upsert/remove mirror to the DO best-effort
  (local stays authoritative); the sign-out wipe (removeAll) is NOT mirrored so
  the server backup survives for the next sign-in.
- PairedMacRestore: on the first signed-in read, merge the backup into the local
  store — LWW by lastSeenAt (never clobber a newer local edit), insert missing
  hosts, and honor the backup's active host only when local has none (fresh
  install), so restore never hijacks the device's current active selection.
- Composition root wraps the local store with the decorator when the flag is on
  and a presence URL resolves.

Restore goes over HTTP (GET) rather than the WS sync client, which is built but
not yet wired into the live app, so this feature is self-contained.

No new user-facing strings (silent background backup/restore). swift test: 7
new tests pass (decorator mirroring, restore LWW/active rules, flag).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: make PairedMacRestore an injectable struct (package conventions)

The iOS package-conventions lint forbids caseless enums with only static
members (namespace-enum/namespace-type). Convert PairedMacRestore to a struct
that takes the store + backup as injected dependencies with an instance run().

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: address review — restore memoization, retry, team scope, setActive mirror

Fixes from Cursor Bugbot / CodeRabbit / Greptile on the backup decorator:

- removeAll (sign-out wipe) now resets the restore memo, so a same-launch
  re-sign-in restores again instead of returning an empty list (this was the
  exact sign-out→sign-in path; it was silently broken).
- fetchAll returns nil on transport/auth failure (vs [] for genuinely empty), and
  restore is memoized only on a successful fetch — a transient first-launch
  failure now retries on the next read instead of stranding restore until restart.
- Restore is scoped per (account, team), not per account: the backup DO is
  per-team, so switching teams re-restores (teamIDProvider injected).
- Concurrent first reads share one in-flight restore Task, so a second read can't
  slip past the memo and observe a half-merged store.
- setActive now mirrors the affected account scope to the DO (accurate records
  read back from the local store), so "select a host without connecting, then
  reinstall" no longer restores a stale active host. markActive upserts mirror
  the scope too, preserving the single-active invariant in the backup.
- remove only mirrors a delete while signed in (no auth-failing noise for
  anonymous removals).
- Migration test asserts user_version is left untouched (no downgrade marker).

swift test: 11 backup + 5 migration tests pass; package-conventions lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* mac(dev): auto-publish this Mac's route to the user's pairedMacs backup

DEV-only convenience so a fresh dev iOS build never needs a manual host entry.
MacPairedMacBackupPublisher (DEBUG-on, env/UserDefaults overridable) registers
the iOS-pairing-listener default on (so an attach route exists without toggling
a setting), observes MobileHostService.statusUpdates(), and POSTs this Mac's
deviceId+displayName+routes (active) to /v1/sync/paired-macs whenever routes
change and the user is signed in. Routes are encoded via CmxAttachRoute so the
iOS restore decodes them identically. Best-effort and Release-noop, mirroring
PresenceHeartbeatClient. Bridges the dev gap where the registry (localhost) and
presence devices projection don't deliver the Mac's route to the dev iOS build.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* mac(dev): default iOS-pairing listener ON in DEBUG; drop runtime register

The dev self-publisher needs the pairing listener bound so an attach route
exists. Registering a UserDefaults fallback at runtime was clobbered by the
settings runtime registering the catalog default, so move the default to the
source: MobileCatalogSection.iOSPairingHost defaults true in DEBUG, false in
Release (an explicit user toggle still wins). Release behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* mac: wire MacPairedMacBackupPublisher.swift into cmux.xcodeproj

The new file was never added to the Xcode project, so it didn't compile, the
AppDelegate reference was an undefined symbol, and every macOS build failed
(reload-cloud kept the stale binary; CI would fail too). Add the four pbxproj
entries (PBXBuildFile + PBXFileReference + Cloud group + app-target Sources
phase), mirroring PresenceHeartbeatClient.swift. Verified: the dev Mac now
auto-publishes its route to the user's pairedMacs backup.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ci: refresh AppDelegate Swift file-length budget for the publisher wiring

The one-line MacPairedMacBackupPublisher.shared.configure(auth:) call (+ its
comment) at the composition root grew AppDelegate.swift by 4 lines, tripping the
file-length budget guard. Accept the minor known debt: the wiring belongs next
to the other client configures. 17593 -> 17597.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: surface restored saved Macs on the disconnected screen

Restoring saved Macs into the local store wasn't visible: the disconnected
screen only auto-reconnected and otherwise jumped straight to "add device", so
a restored Mac (e.g. on a fresh dev build, or when auto-reconnect can't reach
it) never showed. Now the disconnected screen loads saved Macs (which also
triggers the backup restore) and lists them for one-tap reconnect, only
auto-presenting the pairing sheet when there are none to pick. en+ja localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: tapping a saved Mac dialed the phone's own loopback instead of Tailscale

A restored/published Mac advertises both a debug_loopback route (127.0.0.1,
priority 0) and a tailscale route. DEBUG builds keep .debugLoopback in
supportedRouteKinds even on a physical device (for the on-device XCUITest mock
host), so firstReconnectHostPortRoute, which picks the lowest-priority supported
route, chose 127.0.0.1 — the phone's own loopback — and the connect silently
failed without ever trying Tailscale. That made tapping a saved/restored Mac
(switchToMac) and stored-Mac reconnect not connect on a device.

Fix in route selection, not supportedKinds (XCUITests still need loopback): add
preferNonLoopback (true on physical devices, false on the simulator where
127.0.0.1 IS the Mac). When set, a real route always wins over a .debugLoopback
route regardless of priority; loopback is used only when it's the sole supported
route. Tests cover device-prefers-tailscale, device-loopback-only fallback, and
simulator-keeps-loopback.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P1): tag workspaces with their Mac (macDeviceID)

Foundation for the aggregated multi-Mac workspace list + machine filtering.
Adds macDeviceID to MobileWorkspacePreview (additive, defaulted) and stamps it
from the connected Mac's ticket where the workspace list is built. Invisible
today (single Mac), but every workspace now records which Mac it's from, which
P3 (aggregation) and P4 (group/filter by machine) build on. Design in
plans/feat-ios-multi-mac-workspaces/DESIGN.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P4a): compound workspace filter (read-state × machine)

Replaces the single-dimension All/Unread filter enum with a composable struct:
readState (all/unread) × machines (Set<macDeviceID>, empty = all), passing both
only when a row satisfies both. Expresses "unread on Mac X and Mac Y" directly.
The filter menu gains a machine multi-select section that appears once more than
one machine is present (single-Mac users see the unchanged All/Unread control);
the list views compile unchanged since .all/.matches/.isActive/.emptyStateText
are preserved on the struct. en+ja localized. 6 model tests incl. the compound
case. Machine names are wired in once aggregation (P3) provides multiple Macs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P4b): machine-list derivation + prune for the filter

Pure, tested helpers the filter UI and aggregation need: machineIDs(in:) gives
the distinct machines present in a workspace list (first-appearance order, skips
unknown-machine rows) to populate the filter's machine multi-select, and
pruneMachines(notIn:) drops selections for machines that vanished so a stale
machine filter never silently hides everything. Full model suite 52 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P2): per-Mac connection pool foundation

Introduces MacConnection {macDeviceID, ticket, route, client, generation} and a
connections:[macDeviceID:MacConnection] pool + foregroundMacDeviceID on the
composite. The foreground attach now records its entry in the pool and teardown
clears it. Additive and behavior-preserving (single-Mac == a pool of one);
anonymous (empty-id) tickets are not pooled. This is the structure P3 builds on
to open read-only connections to the user's other Macs and aggregate their
workspaces. Compiles; route + backup tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P3a): read-only secondary-Mac workspace fetch

fetchSecondaryWorkspaceList(for:) opens a short-lived client to another paired
Mac (reusing the manualHostTicket + workspace.list path, loopback-deprioritized
on device) and returns its workspaces tagged with that Mac's macDeviceID, never
touching the foreground connection. refreshSecondaryMacWorkspaces() populates
secondaryWorkspacesByMac for every signed-in non-foreground Mac. Additive: not
yet merged into the published list, so the single-Mac flow is untouched. Compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P3b): merge other Macs' workspaces into the list (flag-gated)

Foreground connect now kicks a background refreshSecondaryMacWorkspaces(), and
publishAggregatedWorkspaces() merges the other Macs' rows after the foreground
Mac's (de-duped by id, per-Mac order preserved). Gated by multiMacAggregation
(env/UserDefaults, DEBUG on / Release off) and a no-op when there are no
secondaries, so the single-Mac list is byte-for-byte unchanged. Cleared on
teardown. Compiles; route/backup tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P4): surface the machine multi-select in the filter

WorkspaceListView derives the machines present in the (aggregated) workspace
list and passes them to the filter menu, so the read-state × machine compound
filter's machine section appears once more than one Mac has workspaces. Names
come from the device tree; single-Mac shows the unchanged All/Unread control.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios(multi-mac P5): cross-Mac open switches the foreground connection

openWorkspace now detects when the tapped workspace belongs to a Mac other than
the current foreground connection (aggregated list) and switches the foreground
to that Mac before selecting, so the terminal attaches to the right Mac. Gated
by multiMacAggregation; no-op for single-Mac. Compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ci: refresh MobileShellComposite file-length budget for multi-Mac code

The P2-P5 multi-Mac connection pool + aggregation + cross-Mac open added ~196
lines to MobileShellComposite.swift. The methods call private connect/ticket
helpers so they can't move to a separate-file extension; accept the known debt.
5566 -> 5762.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: refresh paired-Mac routes from backup before multi-Mac aggregation

The aggregated multi-Mac workspace list only showed the foreground Mac's
workspaces because secondary Macs' stored routes went stale: refreshSecondary-
MacWorkspaces read the local paired-Mac store, but that store is only restored
from the backup once per launch (memoized scope). When a secondary Mac relaunched
on a new port and republished its route to the per-user backup, the iPhone never
re-read it, so the read-only workspace fetch dialed a dead port and that Mac
silently dropped out of the list.

Fix: add PairedMacBackupRefreshing.refreshFromBackup(stackUserID:) on
BackingUpPairedMacStore, which forces a backup re-fetch + LWW merge (bypassing
the once-per-launch memo, coalescing with any in-flight restore). refreshSecondary-
MacWorkspaces calls it before loadAll, so secondary routes are current before the
fetch. LWW by lastSeenAt means the live foreground route is never clobbered.

Principled: routes are kept fresh from the authoritative per-user backup at
aggregation time, instead of relying on a single sign-in-time restore.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: auto-connect first reachable Mac so home opens on the integrated list

The home fell back to the "Your Macs" picker whenever there was no Mac marked
active (or the active Mac's stored route was stale), forcing a manual tap before
any workspaces showed. Rework the launch auto-connect so the home comes up
connected to all Macs and shows one integrated list, without the picker:

- Refresh saved-Mac routes from the per-user backup before dialing (LWW), so a
  Mac that relaunched on a new port is still reachable instead of failing to the
  picker.
- Connect the explicitly-active Mac when reachable, otherwise the FIRST saved
  Mac with a usable route, instead of bailing when nothing is marked active. The
  other Macs are aggregated read-only (refreshSecondaryMacWorkspaces) into the
  same list, so the home is one integrated cross-Mac workspace list.

The picker now only appears as the genuinely-offline fallback (no saved Mac has
a usable route). Principled: auto-connect targets any reachable saved Mac with
fresh routes, rather than depending on a single persisted "active" selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: refresh routes from backup before manual Mac switch too

switchToMac dialed the in-memory snapshot's routes, so manually switching to a
Mac that had relaunched on a new port could fail on a stale route. Apply the
same backup-refresh used by auto-connect and aggregation: refresh the per-user
backup, re-read the target from the store, then dial its fresh route (falling
back to the snapshot if the re-read yields nothing). Completes route-freshness
across every saved-Mac connect path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: prefer IP-literal routes over MagicDNS hostnames for Mac connect/aggregation

The multi-Mac aggregated list showed only the foreground Mac because the
read-only secondary fetch to another Mac timed out. Root cause (confirmed on
device via console diagnostics): a Mac can advertise three attach routes —
debug_loopback, a MagicDNS hostname (e.g. <node>.<tailnet>.ts.net), and the raw
tailscale IP. firstReconnectHostPortRoute picked the first non-loopback route,
which was the MagicDNS hostname. MagicDNS doesn't resolve on every client (the
phone here), so the attach-ticket request to the hostname timed out and that Mac
was silently dropped from the aggregated list. A Mac that only advertises an IP
route (no hostname) connected fine, which is why one Mac showed and the other
didn't.

Fix: among non-loopback routes, prefer one whose host is a numeric IP literal
(IPv4/IPv6) over a hostname, since an IP is dialable without DNS. Falls back to a
hostname route when no IP route exists, and loopback only as last resort.
firstReconnectHostPortRoute is the shared selector for reconnect, manual switch,
and secondary aggregation, so this fixes tap-to-connect to hostname-route Macs
too. Added isIPLiteralHost + 3 route-selection tests incl. the exact repro.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: re-aggregate other Macs on pull-to-refresh and app foreground

The aggregated multi-Mac list fetched each other Mac's workspaces once, on
foreground attach. Workspaces created on a secondary Mac afterwards never
appeared, because the read-only secondary list is a snapshot, not a live
subscription (only the foreground Mac streams workspace.updated).

Re-run refreshSecondaryMacWorkspaces from the two natural refresh points:
- refreshWorkspaces() (pull-to-refresh) now re-aggregates after reloading the
  foreground list.
- resumeForegroundRefresh() (app returns to foreground) re-aggregates when
  connected, so switching back to the app surfaces newly-created remote
  workspaces without a manual pull.

Both gated on multiMacAggregationEnabled + an active foreground connection, so
single-Mac behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: add transport-agnostic per-Mac workspace state + pure derivation

Foundation for deriving the aggregated multi-Mac workspace list from a single
source of truth instead of imperatively merging a live foreground list with
stale secondary snapshots.

MacWorkspaceState is the phone's view of ONE Mac's workspaces (workspaces +
groups + liveness), keyed by macDeviceID, carrying NO transport/connection
detail. MobileWorkspaceAggregation derives the flat ordered de-duplicated list
(foreground first, then by display name) and the group sections as pure
functions of [macID: MacWorkspaceState]. Same model + derivation whether each
entry is fed by N direct phone->Mac connections (now) or one phone->Durable
Object stream delivering per-Mac deltas (planned), so that migration is a
transport swap, not a data-model change. 6 derivation tests.

Not yet wired into the composite (next commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: derive the workspace list from per-Mac state (slice 2)

Wire the transport-agnostic data structure in: workspacesByMac is now the only
stored workspace state, and `workspaces`/`workspaceGroups` are materialized
derivations (private(set), assigned only by recomputeDerivedWorkspaceState).
The foreground sync stream, secondary fetch, optimistic create-workspace/
terminal, preview, and reset all write per-Mac entries; the derived list
recomputes via didSet. Anonymous/manual-ticket foreground uses a sentinel key.

Deletes the two-sources-of-truth machinery: publishAggregatedWorkspaces (the
re-merge band-aid) and secondaryWorkspacesByMac (the snapshot store). The
foreground-update-overwrites-then-re-merges race is gone by construction: each
Mac owns its entry, the aggregate is a pure function of them. clearRemoteConnection-
Context keeps the offline foreground entry and drops only secondaries.

Tests: 56 model+composite tests green (incl. new derivation + create/terminal/
preview paths). Test seam setWorkspacesForTesting replaces direct workspaces
assignment. The 6 remaining failures are the pre-existing flaky render-grid
timing tests, unchanged by this commit.

Next (slice 3): per-Mac live workspace subscriptions feed workspacesByMac so
remote-created workspaces appear with no refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: live per-Mac workspace subscriptions (slice 3)

Each non-foreground Mac now holds a persistent read-only connection with its own
live workspace.updated subscription that re-fetches its list on each change and
writes its workspacesByMac entry, so a workspace created on another Mac appears
with no pull-to-refresh. The derived list recomputes automatically.

SecondaryMacSubscription holds the client + a fresh per-connection stream id +
the consumer Task. refreshSecondaryMacWorkspaces is now an idempotent reconciler:
establish a subscription for each newly-present secondary Mac, drop ones that
disappeared or became the foreground. Fully best-effort and additive: any
failure (no route, ticket/connect error, stream end) tears that entry down and
the pull-to-refresh / foreground re-aggregate path remains the fallback, so a
secondary subscription can never crash or block the foreground. Subscriptions are
torn down on disconnect/sign-out (teardownSecondaryMacSubscriptions in
clearRemoteConnectionContext).

This is the N-persistent-connections model approved for now; the same per-Mac
entries would later be fed by one phone->Durable Object stream (transport swap,
no data-model change). 62 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: never show the Your Macs picker when Macs are saved

The auto-connect made the home connect, but the root still fell back to the
DisconnectedWorkspaceShellView picker whenever the foreground was not yet
connected (initial connect window, or a failed/slow reconnect). Eliminate that:
show the integrated workspace list whenever there are saved Macs, auto-connecting
in the background, and only show the add-device flow when there are NO saved Macs
at all. The list renders whatever has aggregated (foreground + live secondary
subscriptions) and its toolbar carries settings/devices/sign-out, so nothing is
lost by dropping the picker. Opening a workspace attaches its Mac on demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: auto-connect falls through to the next Mac when one is offline

reconnectActiveMacIfAvailable picked a single target (active Mac, else first
with a route) and connected once; if that Mac had a stored route but was
actually down, the connect failed and the home showed "Mac offline" without
trying any other reachable Mac. Build an ordered candidate list (active first,
then every other Mac with a usable route) and try each via connectManualHost
until one connects, so a single offline Mac never blocks the others. The
restoring-gate deadline still caps the UI; the loop keeps trying in the
background.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios/worker: fix autoreview findings (active-mac deactivation, stale secondary refresh, backup body cap)

P1: PairedMacRestore deactivated the currently-active Mac when refreshFromBackup
brought a fresher record for it (route refresh before reconnect/aggregation),
losing the user's selection. Preserve the existing local active flag when
updating an existing record; only honor the backup's active for records missing
locally on a fresh install. Regression test added.

P2: refreshSecondaryMacWorkspaces (foreground/pull) skipped Macs that already had
a subscription, so a suspended/never-pushing secondary stream left a stale
snapshot forever. Explicit refresh now reseeds existing secondary clients (and
recreates dead ones), so a pull/foreground always updates the aggregate.

P2: the paired-Mac backup POST reused the 16 KiB heartbeat cap while accepting up
to 200 ops x 2 KiB routes, so legitimate backups 413'd and the best-effort client
silently dropped them, staleing the server backup. readBoundedJson now takes a
maxBytes; the backup route uses MAX_PAIRED_MAC_BACKUP_BYTES sized to the declared
limits.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: fix autoreview round 2 (sign-out restore race, stale machine filter blanks list)

P1: BackingUpPairedMacStore.removeAll (sign-out wipe) cleared the inFlight map but
did not cancel the restore tasks, so a backup fetch suspended across the wipe
could resume and re-upsert the previous account's Macs into the emptied local
store (privacy boundary). removeAll now cancels in-flight restores, and
PairedMacRestore.run checks Task.isCancelled after its fetch and skips all writes.
Regression test added.

P2: the machine filter was never pruned, so when a filtered Mac left the
aggregated list (a secondary disconnected, or fewer than two machines so the
filter menu's machine section hid) the stale machine id rejected every row and
stranded the user on a blank list with no visible control to clear it. This is
the likely "blank black screen" after reconnect churn. WorkspaceListView now
prunes filter.machines whenever the present machine set changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: per-machine avatar color + fix autoreview round 3 (wrong foreground key, secondary refetch storm, offline dead-end)

Feature: workspaces from the same Mac now share one avatar color in the
aggregated list (MachineAvatarPalette, keyed to macDeviceID with a workspace-id
fallback and djb2 spread); the symbol still encodes terminal count. Unit-tested.

P1: applyRemoteWorkspaceList wrote the foreground Mac's workspaces under the
PREVIOUS foreground key because foregroundMacDeviceID was assigned after the
apply. On a Mac A->B switch this stored B's list under A's key and the derived
list went stale/empty once the id flipped. Set foregroundMacDeviceID before
applying.

P1: every secondary workspace.updated push awaited a full workspace.list with no
coalescing, so a title/progress churn stream queued repeated full scans and
MainActor aggregate updates. Added a per-Mac leading+trailing coalesced refresh
(SecondaryMacSubscription.refreshTask/refreshPending) — bounded, no cancel/restart
starvation.

P2: an offline returning user whose auto-reconnect failed fell through to a
workspace list whose only affordance (pull-to-refresh) no-ops while disconnected,
with no reconnect control — a dead end. Added store.reconnectOrRefresh (reconnect
when offline, refresh when connected), wired pull-to-refresh to it, and added a
Reconnect button to the offline status row (localized en/ja). Keeps the
integrated list as the only surface — no picker screen.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios/worker: fix autoreview round 4 (sign-out aggregation race, backup freshness on republish)

P1: refreshSecondaryMacWorkspaces captured the account then awaited backup
refresh, store load, client creation, and per-Mac fetches before mutating
secondaryMacSubscriptions/workspacesByMac, while callers launched it in untracked
Tasks. An in-flight pass could resume after sign-out/account switch and write the
previous user's Macs/workspaces into the new UI. Added an isAggregationScopeValid
guard (signed-in + same account + not cancelled) re-checked after every await
before any mutation/connection, routed the pass through a tracked
secondaryAggregationTask, and cancel it (plus tear down live secondary
subscriptions) on sign-out and full reset.

P1: a same-shape backup republish (Mac re-confirming its current live route)
no-op'd without advancing the stored lastSeenAt, so the iOS LWW restore skipped
the backup and kept dialing a stale local route. upsertRecord gained an opt-in
freshnessOf; the paired-Mac path now refreshes lastSeenAt in place (same rev, no
delta/broadcast) so restore sees the republish as fresh. Test extended.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios/worker: fix autoreview round 5 (unscoped aggregation read, restore-memo race, unbounded paired-Mac tombstones)

P1: refreshSecondaryMacWorkspaces allowed a nil/empty account, so loadAll(stackUserID: nil)
would read EVERY locally stored Mac across Stack accounts and could publish another
account's workspaces into the UI. Now requires a concrete signed-in user before any
load/connection (mirrors loadPairedMacs), keeping the post-await scope checks.

P1: per-user paired-Mac delete tombstones were never garbage-collected — the alarm only
GC'd the devices collection — so an authenticated client churning create/delete grew
synced:/synctomb: storage without bound (the live-record cap resets on delete). Added
listTombstonedCollections; the alarm now GCs every per-user pairedMacs:<userId> collection
that holds tombstones and folds each next-GC deadline into its schedule.

P2: a restore suspended at `await task.value` across a sign-out wipe could resume and
re-insert restoredScopes (or clobber a post-wipe inFlight entry), making a same-launch
re-sign-in skip the backup restore and show an empty list. Added a resetGeneration bumped
by removeAll; both restore paths bail if it changed across the await.

Tests: paired-Mac tombstone discovery+GC.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: fix autoreview round 6 (restore cancellation per-await, switchToMac stale cache)

P1: PairedMacRestore checked Task.isCancelled only once after fetchAll, so a
sign-out wipe landing during loadAll or any later upsert let the loop reinsert the
previous account's Macs into the wiped store. Now re-checks after the load and
before every write, bailing with completed: false.

P1: switchToMac hard-failed unless the target was in the in-memory pairedMacs
cache, but the multi-Mac aggregation reads Macs straight from the store, so
tapping a freshly-restored secondary Mac's workspace no-op'd and stranded the user
on a workspace whose Mac never connected. switchToMac now resolves the target from
the store (after the backup refresh), falling back to the cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: redesign devices screen as Computers management view (no connect step)

Since workspaces from every Mac now appear together automatically, the device
tree's "connect to a device" step is obsolete. Replace it with a Computers
screen that manages the Macs signed in to the account:
- One row per computer: machine-colored avatar (same color its workspaces use in
  the list, via the new shared MachineAvatarColors), name, online/last-seen status
  from durable-object presence, and workspace count.
- Remove a computer via swipe or context menu (confirmed) -> forgetMac.
- Add a computer via a toolbar + that opens the existing pairing flow (showAddDevice
  plumbed root -> shell -> list -> screen).
- Drop the instance/tag/workspace expansion tree and Connect affordances; delete the
  now-dead DeviceTreeExpansionStore (+ tests) and the unused tree row snapshots,
  keeping only DeviceTreePresence.
- New mobile.computers.* strings localized en + ja.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: fix autoreview round 7 (Release preview compile break, ineffective computer remove)

P1: rootContent referenced WorkspaceListLayoutPreviewView directly, but that type
is compiled only under `os(iOS) && DEBUG`, so a Release/iOS archive failed to
type-check the branch ("cannot find ... in scope"). Added a DEBUG-wrapped
workspaceListLayoutPreview helper (mirroring terminalLayoutPreview) so Release
never names the gated type.

P2: the Computers list was built from deviceTreeDevices (prefers the team
registry), but Remove calls forgetMac, which only deletes the local paired-Mac
backup row — so a registry-backed computer reappeared on the next registry load
and Remove looked broken. Build the list from pairedMacs instead: this feature's
source of truth, the same set that feeds the workspace aggregation and the exact
rows forgetMac removes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: track CMUXMobileRootView in swift file-length budget (preview helper)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios/worker: fix autoreview round 8 (restore not cancelled on sign-out, idle-team tombstone GC, stale secondary rows)

P1: signOut() never cancelled in-flight paired-Mac restores (it does not call
removeAll), so the cancellation guards could not fire on the normal sign-out path;
a restore suspended at its backup fetch could resume — possibly authorized with the
next account's live token — and write rows for the previous account. Added
PairedMacBackupRefreshing.cancelInFlightRestores (cancel tasks + bump reset
generation, without wiping the per-user rows) and call it from signOut.

P1: backupPairedMacs created delete tombstones but never scheduled an alarm, so an
idle team (no presence instances/subscribers) would never wake to GC them and a
create/delete churn grew DO storage unbounded. It now schedules the next
tombstone-GC deadline for the user's collection after applying ops.

P2: when a secondary Mac's event stream ended, the subscription was removed but its
workspacesByMac entry stayed marked connected, leaving dead rows in the aggregate
that taps routed into. The stream-end teardown now downgrades that Mac's state to
unavailable so the rows show offline until a refresh re-establishes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: fix autoreview round 9 (forget leaves secondary subscription, failed switch still opens workspace)

P1: forgetMac removed the store row but, for a SECONDARY Mac, left its live
read-only subscription and workspacesByMac entry intact, so the Computers screen's
Remove left the forgotten Mac's workspaces in the list (still updating, tappable)
until a later aggregation pass. forgetMac now cancels secondaryMacSubscriptions and
clears workspacesByMac for that Mac.

P2: openWorkspace awaited switchToMac for a cross-Mac workspace but selected the
workspace even when the switch failed (no route / failed connect / fell back to the
previous Mac), focusing a workspace whose Mac is not the live connection so terminal
input targeted the wrong client. switchToMac now returns whether the foreground
connection targets that Mac; openWorkspace bails (leaving the user on the list, with
the Reconnect affordance) when it does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: pop the compact stack when a cross-Mac workspace open fails (autoreview round 10 P1)

The tap selects a workspace and pushes its detail synchronously, and openWorkspace
runs from that detail's task — so an early return on a failed switchToMac left the
user inside a workspace whose Mac never became the foreground connection (terminal
input would route to the wrong live client). On switch failure, roll the selection
back (selectedWorkspaceID = nil) so the compact stack pops to the list, where the
offline row's Reconnect / next aggregation pass recovers the Mac.

Known follow-ups (autoreview round 10, narrow edges not on the dogfood path):
- sign-out-during-restore cancellation is fire-and-forget; the residual race needs
  the restore fetch bound to the captured account/team in the backup client.
- an empty-macDeviceID QR connect keeps the foreground under the anonymous key and
  does not migrate workspacesByMac/foregroundMacDeviceID when the real id is adopted.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: color computers by distinct position, not a colliding hash (fix two Macs both yellow)

The avatar color hashed macDeviceID into 8 slots, so two Macs collided on one color
~1/8 of the time — Lawrence's two real device ids both hashed to slot 2 (yellow).
Assign a DISTINCT color index per Mac by sorted device id in the aggregation
(MobileWorkspaceAggregation.machineColorIndex), stamp it onto each derived workspace
(machineColorIndex), and color the Computers rows from the same store map. Different
Macs are now guaranteed distinct up to the palette size; the id hash remains only as
a fallback outside the aggregated list. Tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: promote the live secondary connection on cross-Mac open instead of re-dialing (root-cause fix for "Mac offline")

Architectural fix. Tapping a secondary Mac's workspace ran openWorkspace -> switchToMac
-> connectManualHost -> connect(), which threw away the already-live, authorized
read-only client in secondaryMacSubscriptions and re-dialed the foreground from
scratch. That re-dial pipeline has several independent failure points — route
re-derivation via refreshFromBackup LWW, the offline preflight, and connect()'s
connectionGeneration supersession race — any of which strands the user as "Mac
offline" even though a working client to that exact Mac exists.

switchToMac now first tries promoteSecondaryToForeground: probe the live secondary
client, and on success take ownership of it as the foreground connection (reuse the
client/route/ticket, start terminal polling, re-aggregate the demoted Mac) with no
re-dial. Falls back to the existing re-dial only when no live connection exists. This
makes "offline on a reachable, already-aggregated Mac" unrepresentable.

First cut of the larger unification (one MacConnection per Mac, foreground as a
selector); the write-only `connections` pool and the duplicate connect path collapse
in the follow-up. Orthogonal dev-only gap remains: a secondary on an ephemeral port
the phone can't refresh (no dev registry) has no live connection to promote.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: Computers screen — drive the dot from the phone's real connection, show presence + route as diagnostics, refresh live while open

The connection dot mixed two sources: the phone's live RPC status for the
foreground Mac, but the Durable Object presence worker (the Mac's own heartbeat,
not the phone's connection) for every other Mac. So a Mac the phone is actively
connected to as a SECONDARY showed not-green because presence (unreliable on dev)
didn't report it — exactly the "MacBook Pro not green" case.

Now the dot is driven by the phone's own per-Mac connection
(store.macConnectionStatuses, derived from each MacWorkspaceState.status:
green=connected foreground/secondary, orange=reconnecting, grey=not connected),
which updates reactively as subscriptions connect/drop. Presence and the dialable
route (host:port) move to a separate diagnostic line, so a mismatch — "online via
presence but the phone can't connect" — is a visible tailscale/route signal, and
the user can see the exact endpoint. While the sheet is open it re-aggregates every
4s so a dropped Mac reconnects quickly. New strings localized en/ja.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: tap a computer for a comprehensive detail/debug sheet

Tapping a row on the Computers screen now pushes MacComputerDetailView with the
full per-Mac picture, separated so a connection problem is diagnosable:
- Connection: the PHONE's live status to this Mac + workspace count + whether it
  is the active foreground.
- Presence (from the Durable Object presence worker): online/offline + last seen,
  or "unknown", with a footer explaining that presence is the Mac's heartbeat, not
  the phone's connection, and that online-but-not-connected = a Tailscale/route
  problem.
- Routes the phone can dial: every saved route (kind + host:port), selectable.
- Identity: device id, paired-since, route-updated.
- Actions: Reconnect, Remove.

Rows are NavigationLinks into the sheet; strings localized en/ja.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: per-Mac custom name, color, and icon — synced across the user's devices

Users can rename a computer and give it a custom color (8 swatches or any color)
and icon (curated SF Symbols or any emoji) from the computer's detail sheet. The
override wins over the Mac-reported name and the automatic color/icon everywhere:
the workspace list avatars, the Computers screen, and the detail sheet.

Persistence + sync reuse the existing per-user Durable Object paired-Mac backup:
- MobilePairedMac + customName/customColor/customIcon; SQLite store v2 migration
  (additive nullable columns) + setCustomization (preserves the Mac's reported
  name/routes/active, bumps lastSeenAt for LWW).
- PairedMacBackupRecord (Swift + worker) carries the fields; parse + bounds +
  pairedMacShapeEqual treat them as shape so a change mints a rev and broadcasts.
- BackingUpPairedMacStore uploads the COMPLETE current record on every write (so a
  route refresh never clobbers a customization) and mirrors setCustomization.
- PairedMacRestore applies the fields (LWW) so an edit on device A appears on B.
- store.updateMacCustomization persists + uploads + re-derives; the aggregation
  stamps custom color/icon onto each workspace preview.

Color is "palette:<n>" or "#RRGGBB"; icon is an SF Symbol name or an emoji
(classified by non-ASCII). Strings localized en/ja. Tests: worker customization
sync + shape; restore-applies + setCustomization-preserves.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ios: show a Reconnecting/Reconnect overlay on the terminal when disconnected (fix recurring "black screen")

Recurring report: the phone drops its connection (dev route staleness with no
registry to refresh) and the workspace detail keeps showing the now-dead terminal
surface — an unrendered black screen with only a tiny status pill. The connection
is fine to re-establish, but nothing tells the user that or offers an action.

WorkspaceDetailView now overlays the terminal with TerminalDisconnectedOverlay
whenever macConnectionStatus != .connected: a spinner for .reconnecting, and an
offline icon + host + a Reconnect button (-> store.reconnectOrRefresh) for
.unavailable. So a dropped connection reads as "Reconnecting…" with a clear
action instead of a black void. Localized (reuses mobile.workspace.reconnect).

Note: the underlying dev route-refresh gap (a secondary Mac on an ephemeral port
the phone can't relearn without the registry) still requires a re-pair on dev;
this makes that state visible + recoverable instead of silent.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* workers/presence: add wrangler.dev.toml for safe cmux-presence-dev deploys

Deploying the dev instance with `wrangler deploy --name cmux-presence-dev`
inherits the production presence.cmux.dev custom domain from wrangler.toml (--name
only overrides the worker name), STEALING the prod domain from cmux-presence and
breaking prod auth (the dev worker uses the dev Stack project). Add a dedicated
wrangler.dev.toml (workers_dev = true, no custom domain) so the dev instance stays
on its *.workers.dev URL, and point the README at it with a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* workers/presence: encode a per-developer isolated-worker pattern for concurrent dev

Problem: cmux-presence-dev is a SINGLE shared worker — last deploy wins, and an
unmerged feature (the paired-Mac backup lives only on its branch) exists only on
whoever deployed last, so two people working on the worker clobber each other.

Pattern: each developer deploys their own cmux-presence-dev-<slug> via
scripts/deploy-dev.sh. Each named worker has its OWN Durable Object namespace, so
presence + paired-Mac-backup state is fully isolated per dev — any number of
people dogfood worker changes at once without collision. Builds point at it via
CMUX_PRESENCE_BASE_URL; the shared cmux-presence-dev stays the integration
baseline (the script refuses reserved/prod names).

To make a tapped iOS DEVICE build honor the override (it sees no shell env), the
resolver now also reads an Info.plist key CMUXPresenceBaseURL — precedence env →
UserDefaults → Info.plist → Debug default (tested). README documents the full
pattern + guardrails; the remaining wiring (reload baking CMUXPresenceBaseURL into
the tagged Info.plist next to CMUXDevTag) is flagged as a TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* ci: track PresenceServiceConfiguration in swift file-length budget

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Harden paired-Mac v2 migration + bound Computers-screen polling

Autoreview findings on the multi-Mac PR:

[P1] v2 SQLite migration was neither idempotent nor atomic: it ran the
three ADD COLUMN statements then bumped user_version separately, so a
kill / disk-full / SQLite error after a partial apply stranded the DB at
v1 with some v2 columns present. The next launch re-ran ADD COLUMN
custom_name and failed with a duplicate-column error, bricking the
paired-Mac store. Now each migration step runs inside one transaction
(SQLite DDL + PRAGMA user_version are both transactional, so a partial
apply rolls back and retries cleanly), and migrateToV2 only adds columns
missing from PRAGMA table_info, which also recovers any dogfood device
already left half-migrated by the earlier build. Adds a regression test
that seeds a partially-applied v2 schema and asserts recovery.

[P2] The Computers sheet polled store.reconnectOrRefresh() every 4s while
open, which pulled the DO backup over the network and, when disconnected,
re-dialed offline Macs on a fixed timer (battery/network fan-out). The
online dots (presence) and secondary workspace lists are already
push-driven, so the timer now calls a bounded refreshComputersScreen()
(local row reload + coalesced foreground refresh only) on a gentler 10s
cadence and leaves offline-Mac dialing to presence-push recovery and the
explicit pull-to-refresh / per-Mac Reconnect button.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Route multi-Mac workspace mutations + reconnect to the owning Mac

Round 2 autoreview findings on the multi-Mac aggregation:

[P1] promoteSecondaryToForeground reused a live secondary connection as the
new foreground but never started its terminal event stream: it called
cancelRemoteOperationTasks() (which does NOT clear terminalEventListenerTask/
ID) and then startTerminalRefreshPolling(), which no-ops while a listener
task is still installed. The promoted client got no terminal/workspace/
notification push events, so output stalled until another path restarted the
stream. Now stop+start the listener (the existing == listenerID defer guard
makes the old listener's async teardown safe).

[P2] Aggregated workspace rows can belong to a secondary Mac, but rename/pin/
unread/close all sent to the single foreground remoteClient — wrong Mac, and
with a colliding id could mutate a foreground workspace. sendWorkspaceMutation
now resolves the workspace's owning Mac (workspaceMutationTarget) and routes to
that Mac's client: foreground -> remoteClient + refreshWorkspaces(); a live
secondary -> its client + scheduleSecondaryRefresh(); a known offline owner ->
no send + snap back (never misroute to foreground). A failed secondary write
no longer marks the foreground connection unavailable.

[P2] The per-computer detail Reconnect button called reconnectOrRefresh()
(foreground/active Mac) and ignored the computer being viewed. It now calls
switchToMac(macDeviceID:), which promotes a live secondary to this Mac or
re-dials it specifically.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Fix foreground-Mac ownership: stale rows, switch fast path, test double

Round 3 autoreview findings:

[P1] Adding setCustomization to MobilePairedMacStoring broke the CmuxSyncStore
test target: FakePairedStore conformed with only the old methods. Added a no-op
setCustomization to the fake.

[P1] On a foreground Mac change (connect A->B, promotion, or a real connect
after an anonymous/sign-out session) the previous foreground/anonymous entry was
left in workspacesByMac. recomputeDerivedWorkspaceState derives over every
entry, so stale rows kept showing and could route actions/opens through stale
ownership (regressing the old workspaces = remoteWorkspaces full replacement).
Added dropStalePreviousForeground(): on the foreground flip it removes only the
old foreground key (never a live/offline secondary, which aggregation re-adds),
wired into both the connect path and promoteSecondaryToForeground.

[P1] switchToMac's already-foreground fast path trusted the persisted isActive
flag, which lags the live connection (promoteSecondaryToForeground writes it via
an unawaited Task; stale during reconnect/switch races). It could return success
without switching and leave input/mutations on the wrong Mac. Now gates on the
live foregroundMacDeviceID == macDeviceID identity.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Fix offline-Mac dot + bake iOS presence override; doc team-scope limit

Round 4 autoreview findings:

[P2] clearRemoteConnectionContext set the global status unavailable but left the
retained offline foreground entry in workspacesByMac with status .connected.
macConnectionStatuses (the Computers screen's per-Mac dots) derives from those
per-Mac states, so a just-disconnected Mac kept showing a green connected dot.
Now downgrade the retained entry to .unavailable.

[P2] The CMUXPresenceBaseURL Info.plist override was read by
PresenceServiceConfiguration but never baked, so a tapped dev device build
ignored a per-developer isolated worker. Wired the bake end to end: added the
CMUX_PRESENCE_BASE_URL build setting to ios/Config/Shared.xcconfig (empty
default) + the CMUXPresenceBaseURL key in ios/Config/Info.plist, and
ios/scripts/reload.sh now passes $CMUX_PRESENCE_BASE_URL at both xcodebuild
sites (next to CMUX_DEV_TAG). Release/TestFlight stay empty -> unaffected.
Updated the worker README (no longer a TODO).

[P2] Documented the per-(account, team) backup vs account-scoped local rows
scope gap inline at mirrorAccountScope. Solo/single-team users are unaffected;
proper multi-team isolation needs a team_id store column (v3 migration), tracked
as a follow-up rather than expanding this upgrade-safety PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Bound Computers timer, key manual reconnects by real id, stop backup clobber

Round 5 autoreview findings:

[P1] refreshComputersScreen() (the open-sheet 10s timer) delegated to
refreshWorkspaces(), which fans out refreshSecondaryMacWorkspaces() to every
saved Mac and re-establishes/re-dials missing (offline) subscriptions — the
reconnect storm the screen is meant to avoid (my earlier bounding fix was
incomplete). It now does a foreground-only reload (riding any in-flight
pull-to-refresh) and never initiates the secondary fan-out; recovery stays on
presence-push + explicit pull/Reconnect.

[P1] A Mac without mobile.attach_ticket.create connects via a synthetic
manual-<host>:<port> ticket, and connect() keyed foreground state by
ticket.macDeviceID. So a switch/reconnect to such a Mac stamped foreground
workspaces with the synthetic id; filters, Computers rows, mutation routing, and
aggregation no longer recognized the real Mac as foreground (and could open a
duplicate secondary). connect()/connectManualHost now take the real
pairedMacDeviceID hint (threaded from switchToMac, reconnect, device-row paths)
and key foreground state + the connection pool under it.

[P2] The Mac route-publisher omits customName/color/icon, but the worker treated
absent fields as part of the record shape, so every Mac heartbeat minted a rev
that wiped the user's iOS-set customizations and the next restore cleared them.
Fix: iOS uploads now ALWAYS emit the three custom keys (null = reset-to-Auto,
authoritative) via a custom encoder; the worker preserves stored customizations
for any key an upload OMITS (the Mac), while a present key (iOS) still sets/clears
it. Tests on both sides. (Dev worker needs redeploy for dogfood.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Stamp foreground rows with real Mac id; migrate test off private workspaces

Round 6 autoreview findings:

[P1] remoteWorkspacesPreservingSnapshots stamps each foreground workspace with
activeTicket?.macDeviceID (the synthetic manual-<host>:<port> id for an
attach-ticket-less Mac), and setForegroundWorkspaceState only restamped nil ids
— so round 5's real-id foreground KEY did not reach the rows. The same machine
then looked like a different Mac (wrong counts/customizations; openWorkspace
tried to switch to a nonexistent Mac). setForegroundWorkspaceState now stamps
ALL foreground rows with the resolved foregroundMacDeviceID.

[P1] The aggregation refactor made  public private(set), but the iOS
cmuxFeatureTests still assigned store.workspaces directly (7 sites), breaking the
feature test target compile. Migrated them to the existing setWorkspacesForTesting
DEBUG seam (reachable via @testable import), which writes the foreground per-Mac
state so the derived list recomputes identically.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Drain in-flight restores before sign-out wipe (privacy race)

Round 7 autoreview finding:

[P1] removeAll() (sign-out wipe) cleared the local store BEFORE cancelling
in-flight restores. A restore can pass its Task.isCancelled check, suspend
inside inner.upsert, then the wipe runs and only afterwards cancels — but
cancellation does not withdraw the already-queued upsert, so the previous
account's Mac could be written back into the just-emptied store after sign-out
(privacy boundary). removeAll now cancels AND DRAINS (awaits) the in-flight
restores before wiping, so every pending write completes first and the wipe is
final. Adds a deterministic regression test (GatedUpsertStore) that suspends a
restore inside upsert across the wipe and asserts the store ends empty; it fails
under the old wipe-then-cancel ordering.

(The QuickLook finding the reviewer raised is out-of-scope: it comes from the
origin/main merge, not this PR's diff, and the helper flagged it as ignored.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Team-safe backup mirror, non-loopback secondary dial, QR identity re-key

Round 8 autoreview findings:

[P1] mirrorAccountScope uploaded the WHOLE account's local rows into whichever
team the backup client targets, so a multi-team user activating a host could copy
other-team hosts into the selected team's per-team DO. Removed the whole-account
mirror: upsert(markActive)/setActive now upload only the two records whose active
flag actually changes (the newly-active host + the previously-active one, now
cleared), preserving the backup's single-active invariant without dumping the
account. (Local rows still carry no team id; a full team-scoped store is a
separate v3-migration follow-up, but the leak vector is gone.)

[P1] makeSecondaryClient proved a non-loopback route to fetch the attach ticket
but then dialed supportedRoutes.first, which on a physical phone can be a
higher-priority debugLoopback (127.0.0.1) — every secondary subscription dialed
the phone itself, so the Mac was unreachable and dropped from aggregation. Now
dials the proven route (exact host/port match, else any non-loopback, else first).

[P2] A compact/anonymous QR pairing connects with an empty macDeviceID, so
foreground state lands under the anonymous key with foregroundMacDeviceID nil.
applyHostReportedIdentity adopted the real id into activeTicket but never updated
the aggregate key, so the Computers screen showed the Mac as not-connected and
aggregation (which excludes foregroundMacDeviceID) could open a DUPLICATE
secondary to the same Mac. Added adoptForegroundMacIdentity to move/restamp the
foreground per-Mac state and connection-pool entry to the reported id.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Stack teams: team-scoped paired-Mac data + lazy re-scope + nav drawer

Implements full Stack-team support on iOS (the team-scope gap autoreview kept
flagging is now closed by real per-team scoping rather than a documented caveat).

A) Team-scoped local paired-Mac data
- v3 SQLite migration adds a nullable team_id column (idempotent, mirrors v2);
  legacy/pre-v3 rows have NULL team and stay visible under EVERY team
  (loadAll filter is ) so an upgrade never
  hides existing hosts.
- MobilePairedMac gains teamID; protocol upsert/loadAll/activeMac gain a teamID
  param with convenience overloads (teamID:nil) so existing call sites compile
  unchanged. markActive/setActive clear the active flag per (user, team) so
  activating in team A never deactivates team B.
- BackingUpPairedMacStore injects the current team (teamIDProvider) into inner
  upsert/loadAll/activeMac; PairedMacRestore stamps restored rows with the team
  whose DO they came from. Multi-team users now only see/dial the active team's
  Macs. Tests: v2→v3 migration legacy visibility, per-team isolation, decorator
  injection.

B) Lazy re-scope on team switch (keep the live terminal)
- MobileShellComposite.currentTeamDidChange() re-subscribes presence, tears down
  secondary aggregation, invalidates the restore memo, and clears the
  pairedMacs/registryDevices caches — but never touches the foreground
  connection, so switching teams does NOT drop the live terminal. Rebuild is
  lazy (next foreground / Computers .task / pull). CMUXMobileRootView observes
  selectedTeamID (single mutation path). Test: foreground workspaces survive.

C) Left-edge-swipe nav drawer
- New MobileNavDrawerView (account header, Stack team list with current checked,
  Settings, Sign out) + EdgeSwipeDrawerContainer (leading-edge drag + scrim;
  toolbar button is the primary/accessible entry). Mounted in WorkspaceShellView
  over both layouts; WorkspaceListView gains a leading drawer button. Tapping a
  team only writes AuthCoordinator.selectedTeamID (the root re-scopes). en+ja
  localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Fix sign-out foreground reset, cumulative backup cap, iOS drawer type ref

Autoreview round on the teams feature:

[P1] signOut seeded the anonymous preview workspacesByMac entry but left
foregroundMacDeviceID at the old real Mac id. The next connect() then captured
that stale id as previousForegroundKey, so dropStalePreviousForeground (the
round-6 stale-row fix) dropped the WRONG key and the preview rows survived
alongside the newly-connected Mac. signOut now clears foregroundMacDeviceID and
the foreground connection pool before seeding the anonymous entry, so foregroundMacKey
matches the seeded key and the next connect drops the anonymous preview correctly.

[P1] The new /v1/sync/paired-macs write path capped only LIVE records, so
create→delete→repeat churn with fresh ids grew the DO unbounded across the
tombstone GC window. Added MAX_PAIRED_MAC_RECORDS_PER_USER (5× live): a brand-new
id is refused at the cumulative (live + retained-tombstone) cap; reviving a
tombstoned id reuses its slot. Test churns to the cap and asserts new ids are
refused while a revive is allowed.

Also fixed the iOS archive compile error: MobileNavDrawerView named CMUXAuthTeam
(from CMUXAuthCore, not a direct dep of CmuxMobileShellUI). Pass the team's
id/displayName fields instead of the type, which also keeps the @Observable off
the drawer's row closures.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Fix iOS drawer compile: .rect arg order + gate drawer to iOS

- EdgeSwipeDrawerContainer: UnevenRoundedRectangle .rect() wants
  bottomTrailingRadius before topTrailingRadius.
- MobileNavDrawerView uses .listStyle(.insetGrouped) (iOS-only) and is only used
  on iOS, so gate the whole file behind #if os(iOS) (the package also compiles
  for macOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Fix WorkspaceListView call: openDrawer must match declaration order

openDrawer is declared right after store, so pass it there in both call sites
(Swift requires call arguments in declaration order).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Drawer edge swipe: use native UIScreenEdgePanGestureRecognizer

The SwiftUI DragGesture edge-strip fought the workspace list's scroll + row
swipe actions (SwiftUI gestures don't coordinate with UIScrollView) and felt
broken. Replace it with UIKit's UIScreenEdgePanGestureRecognizer — the same
system recognizer behind the interactive back gesture — installed on the hosting
view via a representable. It has screen-edge priority and coordinates with the
scroll view automatically, and now drives the drawer INTERACTIVELY (the panel
tracks the finger; commit on release by threshold/velocity).

Gated to the compact root list only (isEdgeSwipeEnabled): a pushed detail uses
the left edge for the system back swipe and the split layout has its own sidebar
gesture, so the edge swipe would conflict there. The ☰ toolbar button opens the
drawer in every state regardless (primary, accessible entry).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Replace team drawer with a native inline team picker in Settings

Per dogfood feedback, the left-edge swipe drawer felt wrong (Apple also
discourages hamburger drawers). Removed it entirely (EdgeSwipeDrawerContainer +
MobileNavDrawerView deleted; WorkspaceShellView/WorkspaceListView reverted to the
plain layout + the existing top-left Settings button) and put the team picker
where it belongs: an INLINE Picker in the Settings sheet's account area — each
Stack team is a row with a checkmark on the current one, one tap to switch. The
team-scoped data + lazy re-scope (selectedTeamID observed by the root) are
unchanged; only the entry point moved from a custom drawer to native Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Computers screen: show each Mac's build channel (DEV+tag / Nightly / Stable)

The Computers screen now labels which build each Mac runs, to debug 'which build
is this host'. Full vertical:

- Mac heartbeat (PresenceHeartbeatClient) now sends the app's bundleId alongside
  the existing CMUX_TAG.
- Presence worker (validate/core/do.ts) parses, stores, and echoes bundleId on
  the instance (optional, bounded; a change re-syncs the device row).
- iOS PresenceInstance decodes bundleId; PresenceMap.deviceSummary derives a
  build label via the new MacBuildChannel helper (a non-default tag => 'DEV ·
  <tag>'; else the bundle-id suffix => Nightly/RC/Staging/Stable).
- Computers UI: a small tinted pill next to each Mac's name (MacComputerRow) and
  a 'Build' row in the detail's Presence section (MacComputerDetailView).

Tests: MacBuildChannel label derivation, worker bundleId carry, the Mac
heartbeat body emits bundleId. en+ja localization for the new strings.

Needs a dev-worker redeploy + mac & iOS rebuild for the value to flow on dogfood.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Build-channel label: component-based parse + handle future RC

Align MacBuildChannel with the canonical SocketPathMarkerFiles.variant mapping:
the channel is the component right AFTER com.cmuxterm.app (a tagged channel build
appends a further .slug, e.g. com.cmuxterm.app.nightly.my-feature), so match the
component, not a naive suffix. Adds 'rc' -> 'RC' so a future release-candidate
desktop build (com.cmuxterm.app.rc) is labeled correctly the moment it ships,
plus debug/dev -> DEV and an unknown future component -> no guess. Tests cover
RC, slugged channel bundles, and the dev-tag-wins case.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Computers: don't show contradictory 'presence unknown' when connected

A Mac the phone is actively connected to (green, with workspaces) was still
showing 'Presence: unknown' on its row — contradictory and confusing, since the
live connection already proves the Mac is up. Presence is a SEPARATE signal (the
Mac's heartbeat to the presence worker), and a dev phone watching the dev worker
won't see a Mac that heartbeats to prod — so 'unknown' is common and meaningless
next to 'Connected'.

Row: when connected and the presence worker has no record, drop the 'Presence:
unknown' and show just the route (real presence data still shows). Detail's
'Presence (from server)' section: when connected, say 'no heartbeat (connected
directly)' instead of a bare 'unknown'. en+ja localized.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Computers detail: a connected Mac reads 'Online' (connection is the truth)

Follow-up to the presence-unknown fix: the root cause is that presence heartbeat
is currently a DEV-only feature — stable cmux Macs don't announce presence
(Release default OFF, no prod presence URL shipped), so a Mac you're connected to
genuinely has no server heartbeat. Showing 'no heartbeat' for a Mac you're
actively using reads as broken.

Now, when the phone is connected, the detail's Presence section leads with
'Reports: Online' (the live connection proves it) plus a 'Source: this phone's
connection (no server heartbeat)' clarifier, and the footer explains presence is
a dev-only signal today. The row already shows just the route when connected. en+ja.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Make presence production-ready: prod URL + follow the mobile toggle

Presence was dev-only (Release default OFF, no prod URL). Make it ship on stable,
gated on the mobile feature per the desired model: default OFF, ON when the user
enables mobile.

- PresenceSettings.isEnabled: an explicit override still wins, but with no stored
  value presence now FOLLOWS MobileHostService.isListeningEnabled (the iOS-pairing
  master switch). Default (mobile off) => off for privacy; turning on mobile
  pairing turns on presence automatically. Replaces the old DEBUG-on/Release-off.
- Mac resolvedServiceURL: Release now defaults to the production worker
  (presence.cmux.dev) instead of nil, so a stable Mac with mobile on heartbeats to
  prod. Debug still uses the dev worker.
- iOS PresenceServiceConfiguration: Release now defaults to the production worker
  too, so a stable iOS app subscribes to the same service stable Macs report to
  (env/UserDefaults/Info.plist overrides unchanged).

On merge, CI (presence.yml) deploys the updated worker (bundleId + customization
merge + tombstone cap) to prod, completing the production path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* Address iOS policy review cleanup

* Fix paired Mac team scoping and aggregation guards

* Satisfy paired Mac autoreview policy gate

* Fix paired Mac team scope ownership

* Fix quit confirmation reentrancy

* Fix scoped backup and workspace action gates

* Fix paired Mac legacy claim and selection remap

* Fix team active legacy scope

* Fix anonymous aggregation and backup actives

* Fix visible legacy Mac customization scope

* Fix legacy Mac active clearing scope

* Make paired Mac backup decode tolerant

* Fix stale route writes across team switches

* Fix notification deeplink scope and backup URL joining

* Provision secrets for isolated presence workers

* Propagate paired Mac backup tombstones

* Keep stale team loads from clearing current lists

* Fix foreground suppression and secondary downgrades

* Fix paired Mac backup review findings

* Fix paired Mac scope and dismiss flush races

* Satisfy iOS package convention lint

* Fix visual line copy mode Ghostty API usage

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-23 03:09:14 -07:00
..