Fix find focus and align search overlay - #1
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3d022db148
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| _ = selectedSurface?.performBindingAction("search:next") | ||
| } | ||
|
|
||
| func findPrevious() { | ||
| _ = selectedSurface?.performBindingAction("search:previous") |
There was a problem hiding this comment.
Use navigate_search actions for Find Next/Previous
The menu handlers call performBindingAction("search:next")/"search:previous", but elsewhere search: is used to set the query text (see performBindingAction("search:\(needle)") in TerminalSurface.searchState), while navigation uses navigate_search:* in the overlay. This means the menu items likely overwrite the search needle with the literal strings “next”/“previous” instead of advancing results. To keep behavior consistent with the overlay and key handling, these actions should use navigate_search:next/navigate_search:previous.
Useful? React with 👍 / 👎.
Fix find focus and align search overlay
The Ghostty upgrade made paste_from_clipboard a performable binding. In performKeyEquivalent, the !isPerformable guard prevented the menu from handling Cmd+V, so keyDown was called directly. Inside keyDown, interpretKeyEvents triggered paste: (clipboard request manaflow-ai#1) and then ghostty_surface_key fired the same binding (clipboard request manaflow-ai#2), causing a double-paste race that corrupted the output. Remove the !isPerformable exclusion so performable bindings like paste also try the Edit menu first, restoring the single-request flow.
The Ghostty upgrade made paste_from_clipboard a performable binding. In performKeyEquivalent, the !isPerformable guard prevented the menu from handling Cmd+V, so keyDown was called directly. Inside keyDown, interpretKeyEvents triggered paste: (clipboard request manaflow-ai#1) and then ghostty_surface_key fired the same binding (clipboard request manaflow-ai#2), causing a double-paste race that corrupted the output. Remove the !isPerformable exclusion so performable bindings like paste also try the Edit menu first, restoring the single-request flow.
… freeze Before this commit the quit path saved the session snapshot twice, both synchronously with scrollback included: applicationShouldTerminate → isTerminatingApp = true → save manaflow-ai#1 (user clicks Quit) → reply(toApplicationShouldTerminate: true) applicationWillTerminate → save manaflow-ai#2 shouldWriteSessionSnapshotSynchronously returns true whenever isTerminatingApp is set, so both writes landed on the main thread. With many terminals' worth of scrollback each save can take several seconds; the second one ran *after* the dialog had already dismissed, so from the user's perspective the app froze for up to 10 s after clicking 結束 and looked like the button 'did nothing'. Drop the save from applicationShouldTerminate and rely on applicationWillTerminate's save as the single source of truth. The quit-later dispatch path (Cmd+Q through macOS's default handling) still returns .terminateLater correctly; AppKit's contract guarantees applicationWillTerminate fires before process exit for both .terminateNow and .terminateLater+reply(true), so no data is lost. Side benefit: when the user cancels the Quit dialog, we no longer write a throwaway snapshot on every cancel either — the autosave timer keeps state fresh at its own cadence.
…low-ai#1, manaflow-ai#4, manaflow-ai#8, manaflow-ai#9, manaflow-ai#10, manaflow-ai#12, manaflow-ai#14) Several linked defects in the mirror sizing transaction: - manaflow-ai#1: applyAssignedGrids re-pinned a stale grid during a WINDOW live-resize (or interactive geometry drag), painting past the shrinking pane. The divider-drag early return does not cover a window resize, so gate the stale re-pin on the same suppression the view path uses. - manaflow-ai#8: under zoom the visible tree is the single zoomed leaf, so hidden but live base panes were unpinned and rendered on a stale grid. Pin each pane from the visible tree or the base tree; clear only panes in neither. - manaflow-ai#9: the pin-grow repaint went through the attach-only redraw kick (armed only at .enter), so late-granted cells stayed blank mid-session. Extract the shrink/restore SIGWINCH body into forceRedrawKick(windowIds:) and call it directly on a pin grow. - manaflow-ai#10: the stale-repin else-if and gridParityMismatch tested only the under direction, so an over-render (rendered > assigned) was an invisible no-op. Compare with != on both axes; reapplyAssignedGrid clamps either way. - manaflow-ai#12: gridParityMismatch read only the ledger, which goes stale because a same-size re-apply returns early before reporting. Read the surface's live grid first, falling back to the ledger. - manaflow-ai#4: the parked-container consumer clamped an oversized parked reading to the bound and banked it, overwriting a correct size. Reject it (as the sibling oversized consumer does) and keep the last good container. - manaflow-ai#14: rearmIfOutputMissedPlan gated on the plain isVisibleForSizing, which goes stale-true when a hidden tab's view is dismantled; gate on isEffectivelyVisibleForSizing so an offscreen mirror cannot spin re-arms. Tests: parkedHiddenReadingOverTheBoundIsRejectedNotClamped (manaflow-ai#4), gridParityFlagsAnOverRenderedPane (manaflow-ai#10, manaflow-ai#12), and the reworked grid-lag test now pins that an offscreen mirror does not re-arm (manaflow-ai#14).
… transition Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd first and, on errSecDuplicateItem, adopts the value already stored, so two launches racing to mint an id converge on one instead of overwriting each other and registering two device rows against the broker. The UserDefaults mirror is reconciled to the winning id; Keychain stays authoritative and survives app reinstalls so the broker binding is not orphaned. Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires !sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token rotation cannot hand back a half-swapped session that would drive a redundant re-register. Adds convergence coverage in DeviceRegistryRouteSelectionTests (createOrAdopt adopts the concurrent winner rather than minting a second id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(iroh): expose reconnect outage gaps * fix(iroh): keep reconnects alive through outages * fix(ios): signal reconnect deadlines without sleeping * test(iroh): cover close attribution diagnostics * feat(iroh): attribute connection closes and path events * iroh: re-key binding slot to (user, device, tag), newest-auth-wins The active-binding slot was keyed on app_instance_id with a unique index, so a reinstall, sign-out/in, or key rotation produced a fresh app instance that collided with its own past self and got a 409 binding_replacement_requires_revocation. That stranded the App Store review Mac behind a stale non-revoked binding for 17h with no client-side recovery. Re-key the slot to (user_id, device_uuid, tag), partial-unique where revoked_at is null. A registration for an existing slot now overwrites it in place (newest authenticated registration wins) and preserves the binding row id so existing pair grants keep resolving. No generation gate: a reinstall resets identity_generation to 1, and gating on it would reintroduce the wedge. The endpoint id stays globally unique, re-checked excluding self so a slot can rotate its own key. Drop the per-device (8) and per-account (32) binding caps, the stale-binding recycler, and the bindingQuota plumbing; the challenge-issuance quota is kept. Advisory locks move from iroh:app:<appInstance> to iroh:slot:<user>:<device>:<tag> so same-slot registrations serialize. Migration collapses any duplicate active (user, device, tag) rows (keep most recently seen, soft-revoke the rest, revoke their pair grants, bump LAN discovery generation), drops active_app_instance_unique, and adds active_slot_unique. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: stable Keychain device id + Forget computer (iroh re-key client) Client complement to the broker binding re-key (#8883), which changes the iroh binding slot from unique(app_instance_id) to unique(user_id, device_uuid, tag) and replaces the 409 binding_replacement_requires_revocation with a newest-authenticated-wins in-place UPDATE. Two changes make the phone cooperate with that slot: 1. Stable device id across reinstall. The iOS device-registry id moves from UserDefaults (erased on delete/reinstall) to a device-only Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1, AfterFirstUnlockThisDeviceOnly). A returning phone now presents the same device_uuid and overwrites its own binding in place instead of stranding a fresh one. Keychain is authoritative; a pre-Keychain UserDefaults id is migrated on first read, and the generated id is mirrored back to UserDefaults for downgrade safety. This service is distinct from the iroh endpoint-identity store that sign-out/reinstall wipes, so forgetting the endpoint identity does not churn the slot key. 2. Forget a hidden computer. The per-phone Hidden Computers list gains a destructive Forget action (swipe + context menu, both gated behind a confirmation dialog, mirroring MacComputerRow's Hide) that revokes the Mac's account binding through the user-ownership-scoped broker endpoint. It resolves the binding id at action time via a fresh broker.discover() (so an offline Mac's binding is still listed and revocable), matches by canonical device id plus exact tag when known, revokes each match, then clears the local hidden marker and paired-Mac row. A still-online Mac re-registers and reappears on its next connect. Failure keeps the row and surfaces a toast. New narrow capability MobileIrohMacForgetting keeps the shell store's dependency minimal; en+ja localization added for the Forget copy. * iroh: mint new binding id on endpoint rotation, add active-binding sanity cap Address the two P1 review findings on the re-key branch. Finding 1 (ABA wedge): register reused the same binding id when an existing slot re-registered with a rotated endpoint key. A peer host that had denied the OLD endpoint tuple keeps the denial keyed on binding id, so the rotated device was permanently denied behind its own past self. Now a same-endpoint registration is treated as a heartbeat and updates in place (stable id, no ABA), while a rotated endpoint on an existing slot soft-revokes the old row (revokedReason "slot_reincarnated", cleared ports/path hints) and inserts a NEW binding id, carrying live pair grants (initiator + acceptor) onto the new id so pairings follow the device without a re-pair. No lanDiscoveryGeneration bump: a device rotating its own key is not an account-wide trust revocation. Finding 2 (unbounded growth): under unique(user, device, tag) a stuck client spamming fresh tuples could grow the active row set without bound. Add IROH_ACTIVE_BINDING_SANITY_CAP (512) enforced only on the genuinely-new-slot path, evicting the oldest-seen bindings (LRU by lastSeenAt) with reason "active_binding_cap_evicted". No-op for every normal account (a handful of bindings; heavy multi-tag dev at most low hundreds). Tests: reinstall now asserts new-id semantics + retired-row reason; added grant-carry and cap-eviction coverage. 33 DB-behavior tests and 26 route-layer tests pass against isolated Postgres; typecheck clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: fail closed on unreadable device id, alert on Forget failure, pin account Address the four P1 review findings on the iroh re-key iOS client branch. Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an optional, collapsing "no id yet" and "Keychain locked before first unlock" into nil. A background launch before first unlock therefore looked like a fresh install and minted a NEW id, stranding the phone's existing (user, device, tag) binding. read() now returns DeviceIdentityReadResult (.found/.absent/ .unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses the legacy UserDefaults mirror if readable, else a per-process ephemeral id that is never persisted, so the durable id is adopted once the store unlocks. A .found id is re-mirrored to UserDefaults (only when it differs) for downgrade safety; a present-but-blank/corrupt item is treated as .absent and re-minted. Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow can't revoke a binding under the wrong account (MobileIrohForgetError. accountChanged). Finding 3 (Forget ordering): MobileShellComposite forget removes the row before clearing the hidden marker and returns Bool so a failed broker revoke surfaces instead of silently dropping the row. Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a toast) on Forget failure, so the error surfaces even with the Toasts beta flag off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok localized en+ja. CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition transitively need GhosttyKit, so they compile only in the fleet iOS build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Recover the Mac iroh host runtime from terminal failure without relaunch A non-transient broker rejection (401/403/404/409, invalid response) tears CmxIrohHostRuntime down into a terminal .failed phase. That fail-closed teardown is deliberate, but nothing ever rebuilt the runtime: MobileHostIrohRuntime.retryIfNeeded() only re-synced LAN publication while it held a runtime reference, and no timer retried a failed activation. A Mac whose registration was rejected once stayed unregistered until sign-out/sign-in, a Settings-triggered restart, or an app relaunch (the 17-hour App Store review 409 wedge). Recovery is now owned by the macOS composition root, level-triggered through the existing reconcile path: - Every failed activation and every runtime self-teardown into .failed (reported through the existing handleDeactivation callback, filtered by lifecycle revision so deliberate stops are ignored) arms one pending rebuild with bounded exponential backoff (30s doubling to a 1h cap, jittered, via CmxIrohRetrySchedule and an injected clock). - retryIfNeeded() now rebuilds a .failed runtime immediately on any external wake signal (network path change, app-level retry) and resets the backoff ladder, instead of only re-syncing LAN state. - Each reconcile cancels the pending attempt and re-derives recovery from its own outcome: success resets the ladder, failure re-arms it, sign-out/deactivation ends it. The new package test pins the contract this depends on: a rejected registration refresh fails closed (endpoint torn down, deactivation notified) and the same runtime accepts start() again once the broker allows registration. The two-commit red/green structure does not apply because the wedge lives in app-target singleton wiring that has no practical automated harness; the package test guards the enabling semantics instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: harden binding re-key against ABA wedge, LAN staleness, and cap churn Address review findings on the slot re-key path: - Heartbeat-in-place now requires every signed grant-identity field (endpoint id, platform, identity generation) to be unchanged, not just the endpoint id. Overwriting platform/generation on a live binding id would let a still-valid grant signed against the old value mismatch the current binding, so a host records this id in its permanent denial set — the exact ABA wedge the fresh-id path exists to prevent. Any divergence now falls through to reincarnation and mints a fresh id. - Reincarnation retires the old slot through revokeActiveBindings instead of a bespoke soft-revoke. That rotates lanDiscoveryGeneration (so a displaced install can no longer derive future LAN rendezvous aliases) and marks the retired binding's pair grants revoked. - Drop the pair-grant foreign-key carry-over. iroh_pair_grant_issuances is an audit-only ledger of compact JWS tokens already returned to clients; reassigning the FK cannot rewrite a held token, and re-keying forces a re-pair anyway because the token names the dead endpoint. Carrying the FK only made the JTI audit point at a binding it was never signed for. - Sanity cap now rejects a genuinely-new slot at the cap (IrohQuotaExceededError code active_binding_limit) instead of evicting the oldest-seen binding, so a stuck client spamming fresh device/tag tuples can no longer shed the account's real, older hosts and phones. Update iroh-db-behavior and iroh-trust-broker tests to the corrected contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: harden iroh re-key client per review (device-id, session snapshot) Address the P1 findings from review of the iroh re-key client changes. Finding 1 (composition-half): re-resolve the durable device id at each activation via DeviceRegistryService.durableDeviceID(defaults:) instead of capturing it once at root init. A value captured while the durable identity store was unavailable (Keychain locked before first unlock, or a persistent write failure) is an ephemeral throwaway id; registering a binding under it would orphan the retained (user, device, tag) binding. When the durable id is nil, activation now defers (throws .inactive) and retries on the next reconcile once the store becomes readable. The injected resolver is @mainactor () -> String? so it can capture UserDefaults, which is not Sendable under Swift 6. Finding 2: forgetComputer now pins the revoke to one atomic AuthenticatedSessionSnapshot (session generation + account id + both tokens) captured from a single auth-session generation, and the caller passes the row's captured expectedAccountID. Reading the observed identity and the live tokens separately let a lagging observed id authorize a revoke that then ran with a different account's freshly-stored tokens. The broker token source and every mid-flight re-check now require BOTH the generation and the account id to be unchanged, so a sign-out/sign-in (even as the same user) aborts safely. Finding 4: clear the captured scope's durable row and hidden marker unconditionally after a successful revoke. removeStoredPairedMacRow targets the CAPTURED scope, so it cannot touch another account's data; skipping it on a mid-flight scope flip reported success while the row survived, so returning to the old scope showed the supposedly forgotten computer. Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds and the retained binding survives when the durable id is unavailable; forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured account is forwarded and the row is removed on a mid-revoke scope flip; DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget of team-less Mac deletes wrong team on mid-revoke switch The forget-hidden-computer flow snapshots its owner scope before the async iroh revoke, then deletes the stored row. When the captured scope is team-less (no team selected) and the user switches into a team while the revoke is in flight, local cleanup goes through the team-scoping decorator's plain remove, which substitutes a nil teamID with the now-current team. It deletes that team's row and leaves the forgotten team-less computer behind, so it reappears on returning to no-team. This commit adds only the failing regression test (drives forgetHiddenComputer through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured scope, not the live team Add removeExactScope to MobilePairedMacStoring: same shape as remove but it never substitutes a nil teamID with the currently-selected team. The team-scope decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore) override it to forward the captured teamID verbatim; the base SQLite store, MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward (none of them substitute, so plain remove and removeExactScope are equivalent there). forgetHiddenComputer captures its owner scope before the async iroh revoke, so removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team switch can no longer retarget a team-less forget onto the freshly-selected team. Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path after reloading, matching the hide path, so forgetting the last stored Mac drops the saved-Mac hint instead of leaving a dangling reference. Makes the prior commit's regression test pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: converge device identity under races, gate snapshot during token transition Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd first and, on errSecDuplicateItem, adopts the value already stored, so two launches racing to mint an id converge on one instead of overwriting each other and registering two device rows against the broker. The UserDefaults mirror is reconciled to the winning id; Keychain stays authoritative and survives app reinstalls so the broker binding is not orphaned. Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires !sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token rotation cannot hand back a half-swapped session that would drive a redundant re-register. Adds convergence coverage in DeviceRegistryRouteSelectionTests (createOrAdopt adopts the concurrent winner rather than minting a second id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iroh: reject over-cap registrations, gate stale challenges, document deviceUuid contract Review round 2 for the binding re-key. - Sanity cap: keep the reject-not-evict semantics (over-cap registrations throw IrohQuotaExceededError so a churning client can never shed real hosts) and hold the value at 512, well above any legitimate multi-tag developer's low-hundreds active-slot count. (An earlier draft lowered it to 256 citing an iOS 'maximumBindingCount' wire limit; no such constant exists — the only 256 in the client is MobileSyncFrameCodec's per-read frame cap on the terminal RPC transport, unrelated to iroh discovery responses. Dropped that false rationale.) - Challenge-freshness gate: reject a registration whose challenge was minted before the slot's current registeredAt. Registrations for one slot serialize under the slot advisory lock; without this, a delayed/replayed older challenge could land second and overwrite or reincarnate away the newer incarnation, an out-of-order wedge. A live heartbeat's own challenge is always newer, so it passes; registeredAt only advances on insert/reincarnation, so it is the right high-water mark. - schema: document that deviceUuid MUST be stable across reinstalls or a reinstall orphans the old active slot; the client owns this (iOS now derives it from a Keychain identity that survives reinstall), the DB cannot enforce it. - test: the mac->ios platform change on one slot reincarnates (revoke old id + mint new) instead of overwriting in place, so a still-valid grant signed against the old platform can't ABA-wedge into the host's permanent denial set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iroh: map active-slot unique violation to typed 409 databaseConflict only mapped the endpoint-unique index (23505 -> endpoint_already_bound); a violation on the new (user, device, tag) active-slot partial unique index fell through to a raw IrohDatabaseError (HTTP 500). The slot advisory lock serializes same-slot registrations so this is unreachable in practice, but map it defensively to a typed 409 (slot_registration_superseded) so a concurrent newest-wins race surfaces as a retryable conflict instead of a 500. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: correct forget-scope regression test to genuinely catch mid-revoke team flip The committed version of this test asserted contradictory post-conditions, so it did not actually prove removeExactScope deleted the right row. Rewrite it to load the base store once and partition rows by each row's own stamped teamID (loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also returns team-less rows, so the returned set must be filtered by teamID to prove which row was deleted). This version is red against the current visibleScope-based removeExactScope: it deletes the flipped team-b row and the team-less row survives, failing at the team-b assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured team scope, no visibleScope re-derivation removeExactScope forwarded through visibleScope/visibleMac, which call inner.loadAll(teamID:): a nil team returns every team's rows and a set team also returns team-less rows, ordered by lastSeenAt descending, so .first could resolve a DIFFERENT team's row than the scope captured before the async revoke and delete that row instead. When the user switches into a team mid-revoke, the team-less forget then deleted the freshly-selected team's row and left the forgotten team-less computer behind. Make removeExactScope a pure pass-through to inner.removeExactScope, honoring the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers below do not substitute the team. Turns the regression test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: break corrupt-Keychain mint deadlock; move in-memory device store to tests createOrAdopt, on errSecDuplicateItem, reads the item to converge racing callers on one id. But read() maps a present-but-undecodable item to .absent (so a fresh caller re-mints over garbage), which created a deadlock: a corrupt Keychain item made every SecItemAdd return errSecDuplicateItem while read() kept returning .absent, so the device could never mint a device-registry id and iroh activation stayed permanently disabled. On .absent after a duplicate, overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable still defers so a locked-before-first-unlock item is never clobbered. Also relocate the InMemoryDeviceIdentityStore test double out of the production target into the test target; nothing in production or the app referenced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: hidden-computer unhide spinner tracks its own task, not forget's The unhide Button's ProgressView keyed off forgetTask, so it never spun during an actual unhide and could spin during an unrelated forget. performUnhide sets actionTask; key the unhide spinner off actionTask. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iroh: add failing test for reversed heartbeat completion Two heartbeats for one live slot, minted older-then-newer, completing in reverse: the newer lands first and takes the slot, then the delayed older challenge lands second. Without a registration high-water mark that advances on the in-place heartbeat update, the older challenge passes the staleness gate and clobbers the newer incarnation's mutable fields (appInstanceId here) back to a stale value until the next heartbeat self-heals. This commit adds only the failing test; the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iroh: advance registration high-water mark on heartbeat; pin sanity cap to client wire limit Finding 3 (reversed heartbeat completion): the in-place heartbeat update left registeredAt frozen at the slot's original insert time, so two reversed heartbeats both cleared the staleness gate and the later-landing OLDER challenge clobbered the newer refresh. Stamp registeredAt to the applied challenge's createdAt on the heartbeat path too, making it a true monotonic high-water mark of the newest challenge that has landed (the gate already guarantees challenge.createdAt >= registeredAt, so it only moves forward). Turns the added reversed-completion regression test from red to green. Finding 1 (cap above client wire limit): lower IROH_ACTIVE_BINDING_SANITY_CAP from 512 to 256 to match the iOS discovery decoder's maximumBindingCount. The broker's discoverySnapshot returns every active binding uncapped, and the client rejects any snapshot carrying more than 256 bindings; admitting a 257th active slot would make the account's own discovery response undecodable on every device. The existing sanity-cap test references the constant symbolically, so it tracks the new value automatically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iroh: add failing test for reversed challenge completion on a fresh slot Covers the empty-slot ordering case the heartbeat test does not: two challenges minted older->newer for a slot that does not exist yet, the older landing first through the insert path. The genuinely newer registration, landing second, must refresh the slot rather than be rejected as superseded. Fails on current code because the insert stamps registeredAt with its own landing time instead of the challenge mint time, setting the high-water mark above the newer challenge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iroh: seed insert high-water mark from challenge mint time The staleness gate treats registeredAt as the mint time of the newest challenge that has landed, and the heartbeat path already stamps challenge.createdAt. The insert/reincarnation path still stamped the register-request landing time, so an older challenge that created the slot could set the high-water mark above a newer outstanding challenge's mint time and get it wrongly rejected as challenge_superseded, stranding the older registration. Stamp challenge.createdAt on insert too, making registeredAt an ordering-consistent high-water mark on every write path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget deleting wrong paired-Mac scope Two regression tests, RED before the fix (commit adds tests only): - Finding 2 (release-reachable): a team-less pairing shown under a selected team (legacy visibility) is forgotten; the forget captures the LIVE display scope and deletes with it, so removeExactScope(teamID: "team-a") misses the team-less row, the hidden marker is cleared, and the row resurfaces as a normal computer on returning to no-team. - Finding 3 (dev/tagged builds): removeExactScope falls back to the protocol-default remove through MobileMacCompatiblePairedMacStore over IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes the co-located team-less build-scope fallback row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes each pairing's own captured scope, not the live display scope The forget flow captured the live display scope and deleted with it, so a team-less paired-Mac row shown under a selected team (fetchAllMacs legacy visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's own stackUserID/teamID through MobileHiddenComputer and delete with the row's own scope. Keep exact-scope removal exact through both store decorators: add removeExactScope overrides to MobileMacCompatiblePairedMacStore and IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol default remove, which over-deleted the team-less build-scope fallback via scopedTeamID(nil) on dev/tagged builds (Finding 3). The pre-existing flip regression test seeded team-less then team-b for the same device+instanceTag, but base upsert claims the team-less row into team-b (moveMacRowScope), collapsing both into one team-b row, so the old assertions passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed (team row first, which a later team-less upsert never claims) so two genuinely independent rows exist, and forget the team-less one explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget backup-team routing, revoke pinning, broker credential pairing Three autoreview findings on the forget/revoke path, each with a failing regression test. This commit adds only the tests plus the inert API surface they reference; the behavior fixes land in the next commit so CI goes red then green. A. removeExactScope reuses the nil local team for the backup tombstone, so a team-less row forgotten under a selected team routes its backup delete to whatever team is selected at flush time (can wipe the wrong team's backup). New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg, so behavior is unchanged until BackingUp overrides it next commit). B. forgetHiddenComputer pins the revoke to the LIVE session account instead of the row's owning account, so a row left on screen after an account switch can revoke the new account's binding. Test only; the fix is a one-line arg change. C. The broker reads access and refresh tokens through two independent snapshot calls; a force refresh between them pairs a stale access token with a rotated refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by performRequest until next commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: fix forget backup-team routing, revoke account pinning, broker credential pairing Behavior fixes for the three autoreview findings; the failing tests from the prior commit now pass (CI red -> green). A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam` scope: the local row still deletes under `team` (nil stays nil), but the backup tombstone routes to `backupTeam`. The new removeExactScope(...backupTeamID:) override supplies the captured display team, and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less row forgotten under a selected team tombstones the right per-team Durable Object instead of whatever team is selected at flush time. B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID` (the row's owning account) instead of the live session, so the runtime forget's generation/account check fails closed when a stale row is forgotten after an account switch, rather than revoking the new account's binding. C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair (both tokens from one snapshot) over the two independent closures, and MobileIrohRuntimeComposition supplies a credentialPair closure that captures one authenticatedSessionSnapshot under the same generation/account pinning. A force refresh mid-request can no longer pair a stale access token with a rotated refresh token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(iroh): harden lifecycle and close attribution * test(iroh): decode Effect failures through public API * test(ios): require stable simulator device identity * fix(ios): seed simulator Iroh device identity * test(iroh): accept unscoped workspace events in rollover gate * fix(iroh): validate fresh rollover events by topic * test(iroh): cover release closeout regressions * fix(iroh): preserve trusted connection recovery * test(iroh): cover redaction and binding cap semantics * fix(iroh): harden release lifecycle boundaries * fix(iroh): clear retry inspection on scope exit * test(iroh): reproduce multi-Mac release gate targeting * fix(iroh): pin release gate to foreground Mac * fix(ios): isolate durable identity defaults safely * test(iroh): reproduce release gate readiness race * fix(iroh): require stable gate readiness * test(ios): reproduce stale reconnect client clobber * fix(ios): reject stale reconnect before client mutation * test(ios): reproduce restored identity and backup scope leaks * fix(ios): preserve device and backup scope identity * chore(iroh): adopt continuous relay token handoff * test(ios): cover exact release-gate simulator targeting * fix(ios): target release gate simulator by identifier * test(ios): reproduce release gate output sink displacement * fix(ios): isolate release gate terminal observation * test(ios): reproduce stale release gate workspace identity * fix(ios): reacquire long-lived release gate workspace * test(ios): cover complete relay refresh suspension * fix(ios): suspend every automatic relay renewal lane --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#8888) * iOS: stable Keychain device id + Forget computer (iroh re-key client) Client complement to the broker binding re-key (manaflow-ai/cmux#8883), which changes the iroh binding slot from unique(app_instance_id) to unique(user_id, device_uuid, tag) and replaces the 409 binding_replacement_requires_revocation with a newest-authenticated-wins in-place UPDATE. Two changes make the phone cooperate with that slot: 1. Stable device id across reinstall. The iOS device-registry id moves from UserDefaults (erased on delete/reinstall) to a device-only Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1, AfterFirstUnlockThisDeviceOnly). A returning phone now presents the same device_uuid and overwrites its own binding in place instead of stranding a fresh one. Keychain is authoritative; a pre-Keychain UserDefaults id is migrated on first read, and the generated id is mirrored back to UserDefaults for downgrade safety. This service is distinct from the iroh endpoint-identity store that sign-out/reinstall wipes, so forgetting the endpoint identity does not churn the slot key. 2. Forget a hidden computer. The per-phone Hidden Computers list gains a destructive Forget action (swipe + context menu, both gated behind a confirmation dialog, mirroring MacComputerRow's Hide) that revokes the Mac's account binding through the user-ownership-scoped broker endpoint. It resolves the binding id at action time via a fresh broker.discover() (so an offline Mac's binding is still listed and revocable), matches by canonical device id plus exact tag when known, revokes each match, then clears the local hidden marker and paired-Mac row. A still-online Mac re-registers and reappears on its next connect. Failure keeps the row and surfaces a toast. New narrow capability MobileIrohMacForgetting keeps the shell store's dependency minimal; en+ja localization added for the Forget copy. * iOS: fail closed on unreadable device id, alert on Forget failure, pin account Address the four P1 review findings on the iroh re-key iOS client branch. Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an optional, collapsing "no id yet" and "Keychain locked before first unlock" into nil. A background launch before first unlock therefore looked like a fresh install and minted a NEW id, stranding the phone's existing (user, device, tag) binding. read() now returns DeviceIdentityReadResult (.found/.absent/ .unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses the legacy UserDefaults mirror if readable, else a per-process ephemeral id that is never persisted, so the durable id is adopted once the store unlocks. A .found id is re-mirrored to UserDefaults (only when it differs) for downgrade safety; a present-but-blank/corrupt item is treated as .absent and re-minted. Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow can't revoke a binding under the wrong account (MobileIrohForgetError. accountChanged). Finding 3 (Forget ordering): MobileShellComposite forget removes the row before clearing the hidden marker and returns Bool so a failed broker revoke surfaces instead of silently dropping the row. Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a toast) on Forget failure, so the error surfaces even with the Toasts beta flag off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok localized en+ja. CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition transitively need GhosttyKit, so they compile only in the fleet iOS build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: harden iroh re-key client per review (device-id, session snapshot) Address the P1 findings from review of the iroh re-key client changes. Finding 1 (composition-half): re-resolve the durable device id at each activation via DeviceRegistryService.durableDeviceID(defaults:) instead of capturing it once at root init. A value captured while the durable identity store was unavailable (Keychain locked before first unlock, or a persistent write failure) is an ephemeral throwaway id; registering a binding under it would orphan the retained (user, device, tag) binding. When the durable id is nil, activation now defers (throws .inactive) and retries on the next reconcile once the store becomes readable. The injected resolver is @MainActor () -> String? so it can capture UserDefaults, which is not Sendable under Swift 6. Finding 2: forgetComputer now pins the revoke to one atomic AuthenticatedSessionSnapshot (session generation + account id + both tokens) captured from a single auth-session generation, and the caller passes the row's captured expectedAccountID. Reading the observed identity and the live tokens separately let a lagging observed id authorize a revoke that then ran with a different account's freshly-stored tokens. The broker token source and every mid-flight re-check now require BOTH the generation and the account id to be unchanged, so a sign-out/sign-in (even as the same user) aborts safely. Finding 4: clear the captured scope's durable row and hidden marker unconditionally after a successful revoke. removeStoredPairedMacRow targets the CAPTURED scope, so it cannot touch another account's data; skipping it on a mid-flight scope flip reported success while the row survived, so returning to the old scope showed the supposedly forgotten computer. Tests: activationDefersWhenDurableDeviceIDUnavailable proves no endpoint binds and the retained binding survives when the durable id is unavailable; forgetRemovesCapturedScopeRowEvenWhenScopeFlipsMidRevoke proves the captured account is forwarded and the row is removed on a mid-revoke scope flip; DeviceRegistryRouteSelectionTests cover the durable-id defer/mirror/adopt paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget of team-less Mac deletes wrong team on mid-revoke switch The forget-hidden-computer flow snapshots its owner scope before the async iroh revoke, then deletes the stored row. When the captured scope is team-less (no team selected) and the user switches into a team while the revoke is in flight, local cleanup goes through the team-scoping decorator's plain remove, which substitutes a nil teamID with the now-current team. It deletes that team's row and leaves the forgotten team-less computer behind, so it reappears on returning to no-team. This commit adds only the failing regression test (drives forgetHiddenComputer through a TeamScoped-wrapped store with a mid-revoke team flip); the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured scope, not the live team Add removeExactScope to MobilePairedMacStoring: same shape as remove but it never substitutes a nil teamID with the currently-selected team. The team-scope decorator (TeamScopedPairedMacStore) and the backup mirror (BackingUpPairedMacStore) override it to forward the captured teamID verbatim; the base SQLite store, MobileMacCompatible, and IOSBuildScoped decorators inherit the default forward (none of them substitute, so plain remove and removeExactScope are equivalent there). forgetHiddenComputer captures its owner scope before the async iroh revoke, so removeStoredPairedMacRow now deletes via removeExactScope — a mid-revoke team switch can no longer retarget a team-less forget onto the freshly-selected team. Also call clearSavedMacHintWhenNoStoredMacsRemainIfNeeded() on the forget path after reloading, matching the hide path, so forgetting the last stored Mac drops the saved-Mac hint instead of leaving a dangling reference. Makes the prior commit's regression test pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: converge device identity under races, gate snapshot during token transition Device id (FIX #3): adoptOrGenerateDeviceID now goes through Keychain createOrAdopt instead of last-writer-wins write. createOrAdopt does SecItemAdd first and, on errSecDuplicateItem, adopts the value already stored, so two launches racing to mint an id converge on one instead of overwriting each other and registering two device rows against the broker. The UserDefaults mirror is reconciled to the winning id; Keychain stays authoritative and survives app reinstalls so the broker binding is not orphaned. Session snapshot (FIX #1): authenticatedSessionSnapshot() now also requires !sessionTokenTransitionIsActive in both guards, so a snapshot taken mid token rotation cannot hand back a half-swapped session that would drive a redundant re-register. Adds convergence coverage in DeviceRegistryRouteSelectionTests (createOrAdopt adopts the concurrent winner rather than minting a second id). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: correct forget-scope regression test to genuinely catch mid-revoke team flip The committed version of this test asserted contradictory post-conditions, so it did not actually prove removeExactScope deleted the right row. Rewrite it to load the base store once and partition rows by each row's own stamped teamID (loadAll(teamID: nil) returns every team's rows, and loadAll(teamID:) also returns team-less rows, so the returned set must be filtered by teamID to prove which row was deleted). This version is red against the current visibleScope-based removeExactScope: it deletes the flipped team-b row and the team-less row survives, failing at the team-b assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes the exact captured team scope, no visibleScope re-derivation removeExactScope forwarded through visibleScope/visibleMac, which call inner.loadAll(teamID:): a nil team returns every team's rows and a set team also returns team-less rows, ordered by lastSeenAt descending, so .first could resolve a DIFFERENT team's row than the scope captured before the async revoke and delete that row instead. When the user switches into a team mid-revoke, the team-less forget then deleted the freshly-selected team's row and left the forgotten team-less computer behind. Make removeExactScope a pure pass-through to inner.removeExactScope, honoring the exact (stackUserID, teamID, instanceTag) owner key verbatim; the layers below do not substitute the team. Turns the regression test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: break corrupt-Keychain mint deadlock; move in-memory device store to tests createOrAdopt, on errSecDuplicateItem, reads the item to converge racing callers on one id. But read() maps a present-but-undecodable item to .absent (so a fresh caller re-mints over garbage), which created a deadlock: a corrupt Keychain item made every SecItemAdd return errSecDuplicateItem while read() kept returning .absent, so the device could never mint a device-registry id and iroh activation stayed permanently disabled. On .absent after a duplicate, overwrite the corrupt item via SecItemUpdate and return desired, or nil (retry a clean add) if a concurrent delete raced it to errSecItemNotFound. .unavailable still defers so a locked-before-first-unlock item is never clobbered. Also relocate the InMemoryDeviceIdentityStore test double out of the production target into the test target; nothing in production or the app referenced it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: hidden-computer unhide spinner tracks its own task, not forget's The unhide Button's ProgressView keyed off forgetTask, so it never spun during an actual unhide and could spin during an unrelated forget. performUnhide sets actionTask; key the unhide spinner off actionTask. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget deleting wrong paired-Mac scope Two regression tests, RED before the fix (commit adds tests only): - Finding 2 (release-reachable): a team-less pairing shown under a selected team (legacy visibility) is forgotten; the forget captures the LIVE display scope and deletes with it, so removeExactScope(teamID: "team-a") misses the team-less row, the hidden marker is cleared, and the row resurfaces as a normal computer on returning to no-team. - Finding 3 (dev/tagged builds): removeExactScope falls back to the protocol-default remove through MobileMacCompatiblePairedMacStore over IOSBuildScopedPairedMacStore, so an exact-scope team removal also deletes the co-located team-less build-scope fallback row. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: forget deletes each pairing's own captured scope, not the live display scope The forget flow captured the live display scope and deleted with it, so a team-less paired-Mac row shown under a selected team (fetchAllMacs legacy visibility) was missed by removeExactScope(teamID: "team-a"); the hidden marker cleared and the row resurfaced (Finding 2, release-reachable). Plumb each row's own stackUserID/teamID through MobileHiddenComputer and delete with the row's own scope. Keep exact-scope removal exact through both store decorators: add removeExactScope overrides to MobileMacCompatiblePairedMacStore and IOSBuildScopedPairedMacStore so the call no longer falls back to the protocol default remove, which over-deleted the team-less build-scope fallback via scopedTeamID(nil) on dev/tagged builds (Finding 3). The pre-existing flip regression test seeded team-less then team-b for the same device+instanceTag, but base upsert claims the team-less row into team-b (moveMacRowScope), collapsing both into one team-b row, so the old assertions passed vacuously (forget deleted a nonexistent owner_key). Reorder the seed (team row first, which a later team-less upsert never claims) so two genuinely independent rows exist, and forget the team-less one explicitly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing tests for forget backup-team routing, revoke pinning, broker credential pairing Three autoreview findings on the forget/revoke path, each with a failing regression test. This commit adds only the tests plus the inert API surface they reference; the behavior fixes land in the next commit so CI goes red then green. A. removeExactScope reuses the nil local team for the backup tombstone, so a team-less row forgotten under a selected team routes its backup delete to whatever team is selected at flush time (can wipe the wrong team's backup). New removeExactScope(...backupTeamID:) surface (default forwards to the 4-arg, so behavior is unchanged until BackingUp overrides it next commit). B. forgetHiddenComputer pins the revoke to the LIVE session account instead of the row's owning account, so a row left on screen after an account switch can revoke the new account's binding. Test only; the fix is a one-line arg change. C. The broker reads access and refresh tokens through two independent snapshot calls; a force refresh between them pairs a stale access token with a rotated refresh token. New CmxIrohBrokerCredentials + credentialPair surface (unused by performRequest until next commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: fix forget backup-team routing, revoke account pinning, broker credential pairing Behavior fixes for the three autoreview findings; the failing tests from the prior commit now pass (CI red -> green). A. BackingUpPairedMacStore.removeMirroring now takes a separate `backupTeam` scope: the local row still deletes under `team` (nil stays nil), but the backup tombstone routes to `backupTeam`. The new removeExactScope(...backupTeamID:) override supplies the captured display team, and MobileShellComposite's forget passes `displayScope.teamID`, so a team-less row forgotten under a selected team tombstones the right per-team Durable Object instead of whatever team is selected at flush time. B. forgetHiddenComputer pins the revoke to `computer.stackUserID ?? scope.userID` (the row's owning account) instead of the live session, so the runtime forget's generation/account check fails closed when a stale row is forgotten after an account switch, rather than revoking the new account's binding. C. CmxIrohTrustBrokerClient.performRequest prefers tokenSource.credentialPair (both tokens from one snapshot) over the two independent closures, and MobileIrohRuntimeComposition supplies a credentialPair closure that captures one authenticatedSessionSnapshot under the same generation/account pinning. A force refresh mid-request can no longer pair a stale access token with a rotated refresh token. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — session snapshot pairs stale access with rotated refresh authenticatedSessionSnapshot() reads the access and refresh tokens through two separate awaits (currentTokens()), so a concurrent force refresh can rotate the pair between them and hand the broker an old access token with a new refresh token. Neither snapshot guard trips on a plain token rotation. The test scripts that torn store state and asserts the snapshot returns the access minted for the captured refresh, not the stale stored access. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: session snapshot derives access from the captured refresh token authenticatedSessionSnapshot() now reads both tokens through consistentTokenPair(), which captures the refresh token once and mints the access token FOR that exact refresh via freshAccessToken(accessToken: nil, refreshToken:). The returned access always belongs to the returned refresh, so a concurrent forceRefreshAccessToken() can no longer hand the iroh broker an old access token paired with a rotated refresh token. currentTokens() is unchanged for its broader callers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * iOS: failing test — forget routes backup tombstone to display team A team-less row's backup was uploaded under the row's own (nil) team scope, but forgetting it routes the tombstone to whatever team it happened to be displayed under. The tombstone lands in the wrong per-team backup scope: the row's real backup survives (and a restore under the row's own scope can resurrect the forgotten row), while a same-device record in the displayed team's backup can be wrongly deleted. Replaces the previous test, which asserted the display-team routing as the desired behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route forget backup tombstone to the row's own team scope The forget path routed the backup delete to the team the row was displayed under. For a team-less row that team is arbitrary (legacy visibility shows it under every selected team), while upsert stamps the row and uploads its backup under one resolved team, so the row's own team_id is the only client-side value tied to where the backup lives. Display-team routing also split the pending- delete lifecycle across two scopes: the tombstone was written and flushed under the display team's outbox scope, but a restore under the row's own (team-less) scope never saw it and could resurrect the forgotten row locally. Route the tombstone to the row's own captured team, the same scope the backup was uploaded under, keeping outbox key, local apply, flush, and restore- suppression on one scope. This removes the removeExactScope(backupTeamID:) variant entirely; the 4-arg exact-scope delete already carries the row's own team. Residual: a row uploaded while no team was selected client-side had its backup scope resolved server-side, and that resolution is not echoed back or persisted, so no client-only routing can name that scope with certainty. The symmetric nil route re-resolves through the same server path as the upload. Persisting a server-echoed backup team is a cross-stack follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — pending-delete replay deletes a surviving sibling row A forget whose backup upload fails leaves its tombstone in the outbox; the next read replays it through the broad remove path. TeamScopedPairedMacStore's remove re-resolves the device under the scope's team, which also returns team-less legacy rows, so with the exact row already deleted locally the replay resolves a SURVIVING unrelated alias of the same device and deletes it — the exact over-deletion the exact-scope forget path exists to prevent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: replay pending backup tombstones through the exact-scope delete A pending tombstone names one exact pairing and its outbox scope key pins the exact (account, team) it was deleted under, so the replay's only job is to finish or confirm that one deletion. Replaying through the broad remove re-resolved visibility on the way down: TeamScopedPairedMacStore looks the device up under the scope's team (which also returns team-less legacy rows) and the build-scope decorator's broad remove drops its team-less fallback alias. In the common failed-upload case the exact row is already deleted, so the broad replay resolved a surviving unrelated alias of the same device and deleted it. Replaying via removeExactScope is a no-op there and, after a crash between the tombstone write and the local delete, removes exactly the named row. Residual: a crash-interrupted BROAD remove now replays exact too, so a team-less build-fallback alias can outlive that narrow window in dev builds; it resurfaces visibly and the next hide drops it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — wildcard forget leaves the device's sibling rows saved A row with no instance tag cannot name its broker binding, so forgetting it revokes EVERY binding for the device. The local cleanup deleted only the exact nil-tag row, leaving the device's coexisting tagged rows saved locally while their bindings were just revoked: dead entries that resurface in the computer list until the Mac happens to re-register. A tag-known forget stays narrow on both sides (second test, passing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: match wildcard forget's local cleanup to its revoke breadth A tag-less row cannot name its own broker binding, so forgetting it revokes every binding of the device for the pinned account. Local cleanup deleted only the exact nil-tag row, stranding the device's coexisting tagged rows as dead entries whose bindings were just revoked. After the wildcard revoke the forget now also deletes the device's tagged sibling rows visible in the captured display scope and owned by the pinned account, each through the same exact-scope removal as the primary row. Tag-known forgets stay narrow on both sides. Rows in other teams' scopes are not enumerable through the scoped store rail and self-heal when the Mac re-registers; rows owned by other accounts keep their live bindings and survive. Closes https://github.com/manaflow-ai/cmux/issues/9078. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — forget mints a Stack token for every broker leg The forget flow captures one coherent session snapshot up front, but the broker token source re-snapshots on every request, and each snapshot now mints a fresh access token over the network. Discovery plus every sequential revoke each add a Stack round-trip, so forgetting a computer with many bindings can stall for minutes and fail during a Stack outage even though the pinned credentials in hand are valid. The test drives a forget across four broker legs through a broker fake that fetches one credential pair per request, exactly like the real client, and expects a single mint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: reuse the forget's pinned credential pair for every broker leg The forget captures one coherent session snapshot up front; the broker token source now returns that pinned pair after only the cheap local session check (generation + account), instead of re-capturing a snapshot per request. Each snapshot performs a network token mint, so the old path added a Stack round-trip for the discovery and for every sequential revoke: forgetting a computer with many bindings could stall for minutes and fail during a Stack outage despite holding valid credentials. The pinned pair is coherent by construction, and the access token always travels with its refresh token, so the server can re-mint server-side if it expires mid-operation. A mid-forget sign-out or account switch still fails the check and yields nil, so the revoke fails closed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — tombstone ignores the server-reported backup team A team-less row uploads with a nil team and the SERVER resolves which per-team Durable Object stores it; that resolution is not derivable client-side and can drift by the time the row is forgotten. The new uploadReportingResolvedTeam seam (default: echo unknown) lets a transport report the verified team an upload was stored under; the failing test shows the backing-up store discards the echo and re-resolves nil at delete time, so the tombstone can land in a different team's backup than the record it is meant to delete. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: route delete tombstones to the server-reported backup team A team-less row uploads with a nil team and the presence worker resolves which per-team Durable Object stores it. That resolution is not derivable client-side and can drift by the time the row is forgotten, so re-resolving nil at delete time could send the tombstone to a different team's backup: the forgotten Mac's record survived and restored later, and a same-device record in the wrong team could be deleted. The worker now echoes its verified resolved team in the backup POST and GET responses (from the DO, which receives the verified value). The client persists the echo per pairing in a UserDefaults-backed map owned by the backing-up store, and the tombstone flush groups pending deletes by each pairing's persisted backup team (falling back to the scope's own team when no echo was ever seen), uploading each group to the backup its records actually live in. A flushed pairing's mapping is dropped with its backup record. Legacy rows converge on their next successful upload; restores still fetch the live scope (read-path residual, benign). Closes https://github.com/manaflow-ai/cmux/issues/9076. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — restore drops the backup-team echo; wildcard forget refreshes per sibling Two gaps in the round-4 fixes. Restored rows never pass through the upload path, so the reinstall case (empty mapping store, rows arriving via restore) loses the server's statement of where their backups live: a later forget re-resolves nil and the wrong-backup deletion returns for exactly the restored rows. The snapshot now carries the worker's echoed resolved team so the restore can persist it. And the wildcard forget's cleanup refreshes the paired list per deleted sibling, re-running the backup restore fetch each time — up to the 256-binding snapshot limit of sequential round-trips for one tap; the new test pins the whole cleanup to at most one refresh. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: persist the restore snapshot's backup team; batch wildcard cleanup The restore path now records the worker's echoed resolved team for EVERY live record in the snapshot (not just locally-written ones — each record lives in that team's backup regardless of the local merge outcome), so a row restored after a reinstall and forgotten later routes its delete tombstone to the backup it actually lives in instead of re-resolving nil at delete time. The wildcard forget now deletes all of the device's rows first and runs ONE refresh (paired list + registry + reconnect hint) after the batch, instead of reloading per deleted sibling — each per-row reload also re-ran the backup restore fetch because the removal clears the restore memo, so a forget covering many bindings issued that many sequential network round-trips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: make the coherent credential pair the broker token source's only input CmxIrohBrokerTokenSource previously accepted independent access and refresh closures with the coherent pair optional. Several production constructions (iOS reconcile/quarantine paths, macOS host activation) omitted the pair, and their two closures each called auth.currentTokens() separately, so a session transition between the two reads could assemble one session's access token with another's refresh token and fail registration, discovery, or revocation. The pair closure is now the ONLY construction input, so a two-source token assembly is no longer expressible; the single-token accessors are derived from the pair. Every construction site provides a coherent capture: pinned-session pairs for the forget flow, pairs captured together up front for sign-out revokes, and a single currentTokens() call per fetch for the runtime paths. The performRequest legacy two-closure branch is gone. No new regression test: the removed hazard is inexpressible at compile time, and CmxIrohBrokerCredentialPairTests keeps asserting each request performs exactly one atomic capture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-5 review findings A wildcard forget must delete the device's same-account rows in OTHER teams (their bindings were revoked account-wide and an offline Mac cannot re-register to self-heal); the activation broker's credentials must fail closed after an account switch instead of vending the new session's tokens against the old activation; and a legacy device-id whose Keychain migration cannot persist is NOT durable (a reinstall wipes the only copy and strands the slot). Supersedes the adopt-legacy-despite-failed-persist test and the scope-flip test's sibling-survives assertion, both of which pinned the rejected contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: pin activation credentials; cross-team wildcard cleanup; defer non-durable legacy id Round-5 review fixes. The activation path now captures one coherent session snapshot, verifies it belongs to the activating account, and pins the broker token source to it (same helper as the forget path): a mid-activation account switch makes every later leg fail closed instead of mutating the new account's broker state against the old activation's endpoint identity. Wildcard forget cleanup now enumerates the device through a new cross-team loadAllInstances seam on the paired-Mac store rail — the team-scoping decorator forwards it verbatim (its live-team substitution is exactly what the cleanup must see past), the build-scope decorator bounds it to its own build scope, and the backup decorator forwards without triggering a restore. Every same-account row of the device is deleted by its own exact scope, matching the account-wide revoke. DeviceRegistryService no longer reports a legacy UserDefaults id as durable when the Keychain migration write fails: the store was readable (id absent) but nothing durable holds the id, so binding activation defers and retries instead of registering a slot a reinstall would strand. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-6 review findings A valid stored access token must be reusable without a network mint (forcing a mint made the session snapshot, and with it broker activation, fail offline despite a usable stored pair); and the persisted backup-team echo must be keyed by the row's own team — the local store deliberately allows the same (account, device, tag) pairing under several teams, so a team-agnostic key let team B's upload overwrite team A's destination and route A's tombstone into B's backup. Fixture fakes gain the SDK's likely-valid reuse semantics; the forget test's mint expectation drops to zero accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: store-level coherent pair, per-request pinned activation source, keyed echo, forget deadline Round-6 review fixes, one architectural piece plus three scoped ones. coherentTokenPair() replaces the always-minting snapshot read: capture the refresh token, resolve a usable access token FOR it (the SDK reuses a valid stored access without the network and mints only otherwise), then re-read the refresh — an unchanged refresh proves no rotation crossed the window, a changed one retries. It runs inside the coordinator's bounded token-touching phase. The session snapshot, the iOS quarantine-recovery source, and the macOS host activation source all read through it, so no torn two-await assembly remains and an offline launch with a valid stored pair succeeds. Activation no longer freezes an activation-time pair for the runtime's lifetime (ordinary force-refresh rotation does not bump the session generation, so a frozen pair went stale and stranded relay refresh and discovery until an unrelated reconcile). The activation gate is now a cheap local identity check — no token read, so offline activation still reaches the cached relay/offline-policy recovery — and every broker request re-checks the account/generation pin and re-reads a coherent pair from the store. The backup-team echo mapping key now includes the row's own team, and the forget revoke loop gets a 60-second operation deadline (deadlineExceeded surfaces the failure; applied revokes stand and a retry re-discovers what remains) instead of up to 256 sequential broker timeouts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-7 review findings An ordinary same-account foreground revalidation must not advance the session generation (every generation-pinned broker source would starve after the first foreground), and a UserDefaults device-id mirror must never be adopted when the Keychain authoritatively reports the id absent — the mirror travels in device backups onto NEW phones while the ThisDeviceOnly Keychain item does not, so adoption would make two physical devices fight over one (user, device, tag) slot on every phone upgrade. Also pins persist-and-reuse of refreshed access tokens across repeated coherent captures (contract coverage: the ephemeral side-store defect is not expressible through the fake), and reworks the fakes to model the live store's stale-refresh-persist semantics. Supersedes the legacy-mirror-adoption migration test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: round-7 identity and credential lifecycle fixes Same-account revalidation no longer bumps the session generation: the bump now happens only on a genuine transition (signed-out -> signed-in, or a different account), so generation-pinned broker sources survive ordinary foreground returns while sign-out/sign-in still fences stale flows. The device id is minted fresh when the Keychain authoritatively reports it absent, never adopted from the UserDefaults mirror (which migrates in phone backups and would collide two physical devices onto one binding slot); the mirror remains trusted only while the Keychain is temporarily unreadable. This deliberately drops the seamless pre-Keychain upgrade migration — a one-time re-pair for existing installs — to prevent a permanent cross-device identity collision on every phone upgrade. The coherent pair now resolves the access token through the LIVE store inside the refresh bracket, so a stale token is refreshed once, persisted, and deduplicated by the SDK instead of re-minted per capture through an ephemeral side store. The long-lived activation source reads a full authenticated snapshot per request (atomic identity+credential capture, transition-checked) validated against the activation pin, closing the check-then-read race. Both credential containers get redacted descriptions so reflection cannot copy live tokens into logs or crash reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-8 review findings An in-place upgrade (Keychain absent, mirror holding the id the live binding already uses, no witness recorded) must ADOPT the mirror — minting there changes every existing installation's identity once and strands all of their bindings. A mirror whose recorded device witness belongs to ANOTHER phone (a restored backup) must still mint fresh, and a witness matching this phone adopts. These pin the provenance mechanism that separates the two cases the last two rounds traded against each other. (The tests reference the new witness parameter, so this commit is red at compile time without the fix.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: device-witness provenance for the id mirror; pin the macOS broker source The UserDefaults device-id mirror now carries a per-device witness (identifierForVendor — a value a restored phone does not inherit), written on every mirror update. On authoritative Keychain absence the mirror is adopted only when the witness proves it was recorded on THIS device or predates the mechanism (the in-place upgrade population, whose mirror holds the id their live binding already uses); a mismatched witness means a backup restored onto another phone, which mints fresh so two physical devices never share one (user, device, tag) slot. The locked-Keychain fallback applies the same test. Residual: restoring a PRE-witness backup onto a new phone is indistinguishable from an upgrade and adopts — bounded to backups taken before this ships. The macOS host runtime's broker source now mirrors the iOS one: activation verifies the live account, captures the generation, and every request reads an atomic authenticated snapshot validated against that pin, so an A-to-B account switch fails the old runtime's requests closed instead of registering B's credentials against A's endpoint state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-9 review findings A wildcard forget's tombstones must travel in ONE request per destination (a device can carry 256 bindings, and per-row flushes each burn a request timeout); a pending tombstone must be visible to restores of its DESTINATION scope, which must both suppress the deleted record and retry the flush; an unmapped team-less tombstone must PARK instead of shipping with a guessed nil team the server would re-resolve from current account state; and a failed cross-team sibling enumeration is a cleanup failure, not silent success. Legacy tests that modeled the pre-echo worker now arm the echo; the nil-team routing test is superseded by the parked contract, and the crash-intent test becomes the mapping-recovery test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iroh: destination-keyed tombstone outbox, batched wildcard flush, propagated enumeration failure Round-9 review fixes. Pending backup tombstones are now keyed by their DESTINATION scope — the team whose Durable Object actually holds the record (the persisted echo, else the row's own concrete team) — with the row's LOCAL team encoded in each record for exact local replay. A restore of the destination therefore both suppresses the deleted record while its upload is pending and retries the flush, closing the resurrect-and-never-retry gap of local-scope keying. A team-less row with NO verified destination is parked under the nil-team scope and never uploaded with a guessed nil team; parked intents migrate to their destination and flush once a restore's echo recovers the verified mapping. Legacy single-field records decode as local==scope, preserving old outboxes. Residual, documented in code: while parked, a restore of a different team's scope cannot see the intent and may resurrect the record there; re-forgetting that row routes exactly, which is recoverable — unlike a misrouted destructive delete. removeExactScopes batches several rows: local deletes and outbox writes first, then ONE tombstone flush per destination, replacing the per-row flush that gave a wildcard forget up to one network round-trip per row. The composite deletes the primary and all wildcard siblings through one batch and clears markers only after it succeeds, and a failed sibling enumeration now fails the forget instead of silently claiming success after an account-wide revoke. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-10 review findings A TAGGED forget's revoke is also account-wide for that (device, tag) binding, so same-tag rows in other teams must be cleaned too while different-tag rows survive; and reviving one team's row must clear only THAT row's pending tombstone — the destination-keyed outbox can hold same-pairing records for different local teams, and cancelling them all lets another team's forgotten record survive in the backup and restore later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: tag-scoped cross-team forget cleanup; revive clears only its own row's tombstone Round-10 review fixes. Cross-team sibling cleanup now runs for EVERY forget: a tagged revoke kills the (device, tag) binding account-wide, so other teams' same-tag rows are dead and get cleaned, while different-tag rows keep their own live bindings and survive; the tag-less wildcard keeps its every-tag breadth. And a revive clears only the pending tombstone whose LOCAL team matches the re-added row — same-pairing records for other local teams in the same destination stay pending, so their forgotten backup records still get deleted instead of surviving to restore later. Legacy unscoped records decode their local team from the scope they sit in and so match only in the re-added row's own scope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-11 review findings Three confirmed defects, each with a failing test: - A wildcard forget's exact-scope cleanup silently skips rows whose instance tag is incompatible with this build, while the tombstone still flushes and the forget reports success; the revoked-binding row survives to resurface as a dead entry. - Forget clears hidden markers only in the display scope; markers are stored per (user, team), so another team's marker survives its row's deletion and keeps a re-registering Mac unexpectedly hidden there. - A whitespace-only persisted device identity classifies as .found, so the corrupt-item repair deadlocks: the mint path re-reads and adopts the same whitespace value and every launch advertises an invalid opaque device id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: exact-scope deletes match wildcard breadth; markers and identity repair Round-11 review fixes: - The build-compatibility store no longer guards exact-scope deletes. An exact-scope delete targets a row the cleanup explicitly captured from loadAllInstances, and the broker's wildcard revoke is tag-blind, so the local cleanup must cover incompatible tags too; the guard let the tombstone flush and the forget report success while the revoked-binding row survived. Ambient verbs keep the guard. - Forget clears each deleted row's hidden marker in that row's OWN team scope in addition to the display scope. Markers are stored per (user, team); clearing only the display scope left another team's marker to keep a re-registering Mac unexpectedly hidden there. - KeychainDeviceIdentityStore classifies a whitespace-only item as corrupt (.absent), so the duplicate-item repair path overwrites it instead of endlessly re-adopting it as .found; the in-memory test double mirrors the contract, now documented on DeviceIdentityStoring. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-12 review findings - A pre-witness UserDefaults mirror is adopted on authoritative Keychain absence with no proof this is the same physical device; a backup taken before the witness shipped restores onto a new phone and clones the old phone's (user, device, tag) binding slot. - A concrete-team restore neither suppresses nor resolves a PARKED unknown-destination tombstone, so the supposedly forgotten computer is resurrected locally and its backup survives every future restore. - A partially failed batched cleanup still runs the post-forget refresh, whose rowless-marker migration clears the deleted primary's hidden marker — the retry entry disappears while the failed sibling row keeps its already-revoked binding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: continuity-gated mirror adoption; parked tombstones suppress and resolve Round-12 review fixes: - Pre-witness mirror adoption now requires device-continuity evidence: a non-migrating artifact proving the install continues on this hardware. The probe is the iroh endpoint identity — in Release an AfterFirstUnlockThisDeviceOnly Keychain item that never travels in a backup, and one every install with a live binding necessarily has. A restored pre-witness backup on a new phone lacks it and mints fresh (no more cloned (user, device, tag) slots); an in-place upgrade with a binding has it and keeps its id; an install that never activated iroh mints harmlessly. Both production device-id callers pass the same probe so concurrent resolutions agree, and the locked-Keychain mirror branch defers instead of trusting a possibly-restored mirror. - Every restore's suppression list now includes the account's PARKED (unknown-destination) tombstones, and a verified team's snapshot echo resolves any parked intent whose pairing it contains: the mapping is recorded under the parked record's own key and the parked scope flushes, migrating the intent to its destination and deleting the backup. A forget the user was told succeeded can no longer be resurrected by the next restore. FakeBackup now honors successful delete uploads in its snapshot, mirroring the server. - The post-forget refresh runs only after COMPLETE cleanup, so a partial batch failure keeps the hidden entry as the retry owner instead of letting the rowless-marker migration clear it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-13 review finding A forget's cleanup enumerates only the LOCAL store, but backups live in per-team Durable Objects and only the selected team's backup has been restored on this phone. The same device's records in another team's backup get no tombstone even though the wildcard revoke killed their bindings account-wide; switching to that team later restores the supposedly forgotten computer as a dead entry. FakeBackup gains a per-team-bucket mode to model the server's per-team storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-wide forget tombstones; device-id resolution off the UI actor Round-13 review fixes: - A forget now parks one ACCOUNT-WIDE tombstone per forgotten pairing in addition to the routed per-row intents. Backups are per-team Durable Objects and only restored teams have local rows, so the local enumeration cannot match the broker revoke's account-wide breadth; the parked intent suppresses the pairing in EVERY team's restore, each verified snapshot that proves its team holds the pairing gets a direct delete (a tag-less intent is the device-wide wildcard and matches every tag, with the snapshot supplying the concrete tags), and the intent persists until a re-pair revives the pairing. Parked intents no longer migrate to a single destination — no single team could retire an account-wide tombstone. - Durable device-id resolution moved off the MainActor for activation: a private actor captures the identifierForVendor witness with one MainActor hop and runs the Keychain reads/writes, defaults mirror, and continuity probe on its own executor, restoring the off-UI-actor guarantee the merge reconciliation had dropped. DeviceRegistryService gains a nonisolated durableDeviceID(defaults:deviceWitness:...) for such callers, and currentDeviceWitness() is public. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-14 review findings - Parked (account-wide) tombstones replay their local delete only when the nil-team scope itself is requested, so an offline launch after a crash keeps showing the supposedly forgotten computer: crash recovery must be network-independent. - The parked tombstone set retires only on revive and grows by every forget forever — unbounded persisted size and per-restore scan work; retention must be bounded. The forget-deadline scope finding (discovery and in-flight broker calls can suspend past the deadline) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: network-independent parked replay, bounded retention, full forget deadline Round-14 review fixes: - Both restore entry points now replay the account's PARKED tombstones locally before any backup fetch, so crash recovery (outbox written, local delete never landed) works offline instead of depending on the restore's suppression list reaching the network. - The parked account-wide tombstone set is bounded at 256 entries (matching the discovery wire cap): intents are deduped by identity, stamped with a coarse insertion time via an injected clock, and evicted oldest-first when over the cap — an evicted intent's forget has had the longest time to propagate, and losing one degrades to the pre-account-wide behavior for that single pairing. Routed records' encodings are unchanged, so exact-string outbox clearing still works. - The forget deadline now bounds the WHOLE operation: forgetComputer races credential capture, discovery, backpressure waits, and every revoke against a cancellable sleeper, cancelling in-flight broker work at the deadline instead of only checking between revokes; the per-revoke clock checks remain as a cheap early exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: fix Swift 6 isolation and stale optional binding in cmuxFeature Round-15 review findings — both compile errors in the iOS-only targets (no host-runnable or CI compile covers them, so no regression test is practical): - deviceLocalIrohIdentityExists (and its directory helper) are nonisolated so the off-main resolver actor's synchronous continuity probe closure can call them without a MainActor hop. - The sign-out test fake still optional-bound credentialPair from before it became the token source's only, non-optional input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: forget deadline sleeper becomes static — extensions cannot hold storage Round-16 review finding: the cancellable sleeper was declared as an instance stored property inside the extension that hosts the forget flow, which does not compile. Static storage keeps the bounded-timeout shape unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-17 review finding A completed same-account sign-in (fresh credential exchange while already authenticated) preserves the session generation, so operations pinned to the prior session — the forget flow's frozen credential pair, the activation runtime's pinned source — keep passing the session fence with the replaced session's authority. The sibling round-17 finding (the activation path creates the iroh endpoint identity before the device-id continuity probe checks for it, so a restored pre-witness backup sees its own moments-old identity as continuity evidence) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: sign-in always advances the session generation; probe before identity Round-17 review fixes: - applySignedInUser now takes an explicit SessionPublication reason: a completed credential exchange (.signIn) always advances the session generation, even for the same account, because the token session was replaced and prior-session pins must fail closed; only .revalidation (foreground/startup re-checks of the already-published session) preserves the generation for the same account. - The activation path resolves the durable device id BEFORE creating the iroh endpoint identity. The continuity probe treats a device-local identity as proof the install continues on this hardware; creating the identity first handed a phone restored from a pre-witness backup its own moments-old identity as evidence and adopted the migrated mirror id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: drop @MainActor child annotation the isolation checker cannot verify The hosted iOS build fails on the forget-deadline task group: "pattern that the region-based isolation checker does not understand how to check" at the @MainActor-annotated child. The plain child hops to the MainActor implicitly at the revokeMatchingBindings call, which is exactly what the annotation expressed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-19 review findings - The upload echo is keyed by the live display team, but loadAll's legacy visibility can match a TEAM-LESS row: the forget then looks the mapping up under the row's own nil team, misses it, and parks the tombstone — undeliverable when the network is down at echo time. - A parked delete suspended in its upload can race a concurrent re-pair on the reentrant actor: the revive clears the intent and uploads the record, the older delete lands after it, and nothing repairs the wiped backup. - A partially failed batch cleanup returns before clearing ANY markers; rows deleted before the failure can never be re-enumerated on retry, so their per-team hidden markers keep a re-registering Mac hidden. FakeBackup gains an on-delete-upload hook (to interleave a mutation inside the uploader's suspension window), record-op application to its buckets, and a post-construction fetch-failure switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: row-keyed echoes, delete/revive reentrancy fences, narrowed marker cleanup Round-19 review fixes: - The upload echo's mapping is keyed by the ROW's stored team (mac.teamID), not the live display scope: loadAll's legacy visibility matches team-less rows under a selected team, and the forget looks the mapping up under the row's own team — a display-keyed echo was never found, leaving the tombstone parked and undeliverable offline. - Both delete uploaders (the concrete-scope flush and the parked echo resolver) now fence against the actor's reentrancy: any sent tombstone whose outbox record vanished during the upload suspension was revived by a concurrent re-pair, so its current local row is re-uploaded — the stale delete can no longer silently wipe the just-revived backup. The concrete flush also retires only the records it SENT, so intents added during the suspension survive to their own flush, and revived records keep their freshly re-saved mapping. - A partially failed batch cleanup clears the markers of rows it DID delete — narrowly: only the deleted row's own team key and the user-wide key, never the display scope, which the failed scope (the retry owner) shares. Rows deleted before the failure can never be re-enumerated on retry, so this is the only moment their markers can be cleared. FakeBackup applies record uploads to its per-team buckets only; the legacy single-bucket mode serves its seeded list to every team, so applying uploads there would leak one team's mirror into every other team's restore. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-20 review findings - The account-wide parked intent is inserted only AFTER the batch's local deletes have awaited; a Mac re-registering during that window clears the routed tombstone but cannot clear the not-yet-created parked intent, which then suppresses the revived pairing forever. - The flush retires sent tombstones by set subtraction computed AFTER its post-upload awaits; a re-pair plus second forget during those awaits re-adds the identical encoded record, which the subtraction silently consumes — an undelivered second tombstone loses its retry. - The persisted backup-team mapping grows without bound: entries retire only when THIS device delivers the pairing's tombstone. Test doubles: a paired-Mac store and a team-mapping store that fire a one-shot hook inside their suspension windows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: park before deletes, atomic flush retirement, bounded team mapping Round-20 review fixes: - removeExactScopes resolves accounts and persists the account-wide parked intents BEFORE the first local-delete suspension, so a Mac re-registering during a delete clears every tombstone covering its pairing — routed and parked alike — instead of leaving a stale account-wide intent that would suppress the revived pairing forever. The parked scope now also dedupes by identity in addPendingDelete and applies the same oldest-first cap there, so a row intent never stacks a second encoding beside its account-wide twin and single exact-scope removes cannot grow the scope unbounded. - The concrete flush retires its sent tombstones atomically in one actor turn right after the upload (synchronous cache read + write), before the mapping-cleanup and repair awaits: a re-pair plus second forget interleaving those awaits re-adds its identical record AFTER retirement and keeps its own retry. - The persisted backup-team mapping is bounded at 512 entries with move-to-newest insertion order and oldest-first eviction; losing an evicted mapping degrades that pairing's next forget to the parked, echo-recovered path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-21 review findings - A parked intent matches later snapshots solely by pairing id and is cleared only by a LOCAL re-pair: when another device re-creates the record, this phone deletes the revival on every restore and keeps the intent forever, making cross-device re-pairing impossible to persist. - The restore echo records every snapshot mapping under the restore team, but LWW can retain a NEWER team-less local row un-stamped; the later forget looks the mapping up under the row's actual nil team, misses, and parks — undeliverable when the network drops. The third round-21 finding (a same-account sign-in advances the session generation but the long-lived activation runtimes stay pinned to the old generation and return nil credentials until restart) is fixed in the same round; it lives in the iOS-only and macOS app targets, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: account-pinned runtimes, revival-aware tombstones, retained-row echoes Round-21 review fixes: - The LONG-LIVED activation runtimes (iOS composition and the macOS host) pin their broker token sources to the ACCOUNT only, not the session generation: every completed sign-in now advances the generation, and a same-account re-sign-in must keep the runtime serviceable — it is the same user, so serving the new session's credentials via the atomic snapshot is correct, where the generation pin stranded the runtime on nil credentials until relaunch. The forget's short-lived frozen pair stays strictly generation-pinned. - The restore echo now fires AFTER the merge and carries, per snapshot record, the RETAINED local row's actual team and the record's creation time. Mappings are keyed by the retained row's own scope (LWW can keep a newer team-less row un-stamped, and the forget looks the mapping up under the row's real team), falling back to the restore scope for records with no local row (the reinstall case). - A snapshot record CREATED after a parked intent's stamp is a REVIVAL — another device re-paired the Mac — and retires the intent instead of feeding it a delete; without this the forgetting phone deleted the revival on every restore forever. Unstamped legacy intents keep the old delete behavior (no boundary is known for them). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-22 review findings - A revived record is recognized only AFTER suppression already filtered it out of the merge; with the completed restore memoized, the re-paired Mac stays missing locally until relaunch. - The revival signal compared client-authored createdAt, which another phone preserves across a re-pair; the genuine revival misclassifies as stale and is deleted on every restore. The record model gains the SERVER-authored serverUpdatedAtMs (decoded from the snapshot, never uploaded). - Restore echoes persist mappings one save per record; the production store rewrites its whole state per save, so a large restore does quadratic UserDefaults work. The mapping protocol gains a batched saveAll (default forwards per entry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: server-authored revival signal, in-merge revivals, batched mappings Round-22 review fixes: - The worker now surfaces the sync machinery's server-authored per-record write time as serverUpdatedAtMs on the restore read (never accepted from clients — sanitize strips it). Revival classification compares THAT against the tombstone's stamp through a shared skew-margined rule biased toward revival: client-authored createdAt is preserved across re-pairs on other phones and proves nothing. - Restore suppression is now stamp-aware: run() takes suppression entries (pairing + tombstone stamp), and a record every covering tombstone sees as revived MERGES in the same restore instead of being filtered out and stranded behind the completed-restore memo until relaunch. The post-merge echo then retires the covering intents. - Restore echoes persist their mappings through one batched saveAll — the UserDefaults store performs a single read-modify-write of its dictionary and ordering for the whole snapshot instead of a full-state rewrite per record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing tests — round-23 review findings - The revival skew allowance accepts server writes up to a minute BEFORE the forget as revivals. Forgetting a currently-online Mac whose backup was route-mirrored seconds earlier is the COMMON case; the allowance bypasses suppression, retires the intent, and the supposedly forgotten Mac restores instead of receiving its delete. - A partial batch failure never records a hidden marker for a FAILED undisplayed sibling: the deleted primary's marker turns rowless and is migrated away, so the sibling — with its already-revoked binding — resurfaces as a normal computer with no Hidden Computers entry left to retry from. The third round-23 finding (the sign-out quarantine's destructive retry captures live credentials without pinning them to the pending revocation's account) is fixed in the same round; it lives in the iOS-only cmuxFeature target, where no host-runnable test exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: strict revival boundary, pinned quarantine retry, sibling retry markers Round-23 review fixes: - The revival boundary is STRICT: only a server write after the tombstone's stamp counts. Forgetting a currently-online Mac whose backup was mirrored seconds earlier is the common case, and the skew allowance let those pre-forget writes bypass suppression and retire the intent. The residual (phone clock behind the server) fails in the recoverable direction: the revival is deleted once and the other device's next mirror re-uploads it with a fresh server stamp. - The sign-out quarantine's destructive retry pins its credentials to the pending revocation's account through the atomic session snapshot, failing closed if the user switched accounts between the guard and the credential capture. - A partial batch failure records a hidden marker for every SURVIVING failed scope in its own team, so an undisplayed sibling with a revoked binding keeps a durable Hidden Computers retry entry even offline — where the account-wide parked intent cannot yet finish the cleanup. Once any restore completes it, the marker turns rowless and the existing migration clears it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: failing test — round-24 review finding The tombstone stamp is floored to whole seconds while server write times carry milliseconds, so a server write from the same second but BE…
MobileDevicesToolbarLabel.macPairingIDs is a static helper on a SwiftUI View, so its filter closure inherited main-actor isolation and trapped the whole xctest process when sshComputersNeverCountAsMacsForTheWarning called it from a nonisolated test. In the iOS simulator lanes that crash took down hundreds of unrelated tests per launch (xcresult: "Crash: xctest at closure #1 in static MobileDevicesToolbarLabel.macPairingIDs"). The pure helpers are now nonisolated, and the test that builds the view runs on the main actor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Add CmuxMobileSSH core and direct SSH PRD SwiftNIO SSH over Network.framework: connect with host key policy, key/password auth, exec, PTY shells with resize, direct-tcpip channels, jump hosts, subsystem channels. OpenSSH private key parsing for Ed25519 and ECDSA. Live integration tests against a local sshd lab. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add SSH port forwarding, key/host stores, key installer, encrypted key import Local forwards over direct-tcpip for the in-app browser, Secure Enclave and imported keys in the Keychain, local host records with known-hosts pinning, password-once authorized_keys install, and bcrypt_pbkdf + AES decryption for passphrase-protected OpenSSH keys. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add SFTP v3 client for the SSH file browser Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add SSH computers runtime to the mobile shell SSH hosts publish workspace rows through workspacesByMac like the demonstration computer. Demo-only branch points become locally-served checks so SSH surfaces route input, replay, viewport, and composer paste to the SSH runtime instead of a Mac. Plain, tmux, and cmux-tui persistence providers; cmux-tui client with bytes-mode attach and phone geometry; npm-verified cmux-tui upload; TOFU and changed-key prompts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Expose SSH file, forwarding, and lookup API for the UI Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add SSH terminal fidelity, file browser, port forwarding, image paste The phone's emulator answers terminal queries for plain/tmux SSH surfaces and filters its replies for cmux-tui (whose server already answers), SSH surfaces get local pixel scrolling and mouse clicks, sign-out keeps SSH rows, the app injects a persistent SSH runtime, and SSH workspaces gain an SFTP file browser, port forwarding into the native browser, and image paste over SFTP. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add SSH computers UI: Computers section, host editor, keys, prompts, signed-out use SSH hosts get their own section in Computers and open their workspace list; add/edit form with key picker, jump host, persistence, idle close, and password-once key install; SSH key management (Secure Enclave generate, OpenSSH import); root-level trust/changed-key/persistence prompts; and SSH without a cmux account. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Register CmuxMobileSSH in workspaces and pin SwiftNIO SSH dependencies Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui: per-terminal idle-close policy (terminal-idle-close-v1) set-terminal-idle-policy stores an idle close time per hosted terminal in the registry; an owner-side reaper closes terminals with no attached views past their deadline through the normal close path. Reattach resets the clock; null clears the policy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Add cmux-tui browser surfaces over SSH, idle policy client, installer lab test, translations cmux-tui browser tabs stream into the existing browser stream pane with tap/scroll/keys/navigation; the phone applies the host's idle-close policy when the server supports terminal-idle-close-v1; the cmux-tui upload is lab-tested; all SSH strings are localized in the 9 catalog languages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * reload-build: cap Xcode selection at the pinned toolchain major The hosted Blacksmith macos-26 image now carries the Xcode 27 RC and select-ci-xcode.sh ranks by newest macOS SDK, so every dispatched iOS dev-build archive switched to the 27 SDK and fails compiling main's SwiftUI (toolbarMinimizeBehavior: https://github.com/manaflow-ai/cmux/actions/runs/35783022784 and https://github.com/manaflow-ai/cmux/actions/runs/35786136840). Feed the selector's existing CMUX_CI_MAX_MACOS_SDK_MAJOR ceiling from .xcode-version's major so the ranking keeps the newest pinned-major Xcode and skips unvalidated newer SDKs, with the older-runner fallback unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * cmux-tui: rustfmt idle-close files Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Accept smart-punctuation-mangled OpenSSH key armor Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * reload-build: sign the simulator leg to run locally so Keychain works Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui: address seeded idle-close test terminals by stable id Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui: satisfy clippy in idle-close reaper Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Fix SSH issues found in simulator verification - Handshake deadline pauses while the host key prompt is open; a declined key reports hostKeyRejected (including behind a jump host), not a timeout. - Replayed history and cmux-tui snapshots strip terminal query requests, so the phone never answers stale queries into the PTY (Mac #10332 class). - Replacements fully reset the local terminal before replaying history. - Selected SSH host auto-connects on launch/foreground; explicit disconnect or a declined prompt stays manual. - Signed-out SSH mode skips Mac-centric What's New/pairing sheets. - Literal text entry (no autocorrect) on SSH host and key fields. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * PRD: record verification status Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH round 2: tmux control mode, browser modes, UI polish - tmux via control mode: session = workspace, pane = tab, New Terminal opens a window, phone attaches through its own grouped session. - cmux-tui New Terminal; plain workspaces have no terminal tabs. - cmux-tui: re-snapshot when a full-screen app exits so shell history behind it returns. - Browser: shared bottom chrome for streamed and native browsers, a Streamed / On iPhone mode picker remembered per browser, SSH On iPhone routed through a SOCKS5 proxy over SSH plus a loopback port mirror. - Files chip opens SFTP at the shell's directory; title-menu SSH items and the open-port sheet removed. - Sign-in 'Use with SSH only', mode-aware empty states with Mac-parity status, + chooses a computer under All Computers, declined identity pauses auto-connect across relaunch, key origin on every key row, friendly Face ID errors, no dev-tag suffix on SSH hosts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Fix tmux server crash on abrupt phone disconnect and SSH list bugs - Phone grouped sessions no longer use destroy-unattached (tmux 3.7c frees a session another exiting client still references and segfaults); the phone kills its grouped session on close and collects stale ones on connect. Repro: 8/10 crashes before, 0/20 after. - Paired-Mac reconcile, team switches, and Mac outage handling leave SSH computers alone; newest listing wins; lists refresh on appear/active. - SSH workspace ids resolve through the row's RPC id, so New Terminal works when several computers are live. - Strip screen-style title sequences (ESC k ... ST) from tmux pane output. - A successful refresh after a failure reports the host connected. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * PRD: round 2 decisions Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH round 3: mixed session kinds per host One SSH connection per host serves cmux-tui workspaces, tmux sessions, and plain shells side by side. Each workspace row is one kind's top-level primitive (subtitle shows the kind); + offers a menu of kinds; existing server sessions and cmux-tui workspaces from any cmux-tui session are discovered; the tab switcher groups tmux windows and cmux-tui screens with New Window / Split Pane / New Screen / New Tab. Per-host persistence mode and the first-connect prompt are removed. The phone claims cmux-tui geometry only while a terminal is on screen. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Confirm before ending tmux sessions and cmux-tui workspaces over SSH Every close entrypoint asks through one store decision: tmux and cmux-tui rows outlive the phone, so ending one explains what stops on the computer; plain shells close in one tap. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui: test that a displaced terminal geometry owner reclaims when the new owner leaves Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui: hand terminal geometry back to the displaced owner When a phone claimed a shared terminal's geometry and then released its viewport or disconnected, the grid froze at the phone's size and the laptop client that it displaced stayed non-authoritative until its user focused a pane. Each terminal runtime now remembers the owners a claim displaced. When the current owner releases, disables its sizing, or disconnects, the most recent displaced owner that still reports a viewport for a view of that terminal becomes the owner again and the PTY resizes to its report. Departed clients are forgotten, and an explicit use-all-sizes release still freezes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Test SSH version line race and shared trust prompts Two failing regressions found in simulator verification of password-once key install: - A server version line that arrives before the caller resumes from the TCP connect is dropped, so the handshake stalls until the timeout (every app-launch auto-connect timed out in the simulator). - Saving a new computer auto-connects it while Install with Password logs in; the second trust question cancelled the first, failing the install with "server identity not trusted" before the user answered. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Install the SSH handler before connect; share identical trust prompts SSHConnection.connect added NIOSSHHandler to the pipeline only after the awaited TCP connect resumed. Servers send their version line on accept, so when the caller's resumption was delayed (a busy cooperative pool at app launch) the line hit an empty pipeline and was discarded, and the client never sent KEXINIT. The handler and handshake observer are now installed by the bootstrap's channel initializer (and the jump channel's initializer), so no inbound byte can precede them. The handshake budget now also covers the TCP connect, matching its documentation. MobileSSHComputers.ask cancelled any pending waiter with the same prompt id. An identical question (same host, address, and keys) now joins the pending prompt and receives the same answer; a question about a different key still replaces the stale one with a cancel. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: reconnect after key install; plain wording for connect failures A key install that succeeds clears the refusal left by a connect that ran before the key existed and connects with the key. Refused, timed-out, unresolvable and unreachable connects read as sentences instead of POSIXErrorCode values. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Test that a stale Cancel after Trust does not pause the host When the trust sheet goes away after Trust and Connect, SwiftUI writes nil through the sheet binding and the presenter answers the last rendered prompt with Cancel. That pauses the host's automatic connects, so a newly added computer never reconnects at app launch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Ignore answers for SSH prompts that are no longer pending After Trust and Connect, the dismissing prompt sheet writes nil through its item binding and the presenter answers the prompt it last rendered with Cancel. answer() treated that as a decline and persisted autoConnectPaused, so a newly added computer never auto-connected again. answer() now acts only on a prompt that is still pending with the same question; a stale or superseded answer is a no-op. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Conform the SSH packages to the iOS package conventions The iOS package-conventions lint flagged 28 errors in code this branch added. Each namespace enum becomes a value or moves onto the type it serves: - Copy (L10nSSH, SSHCopy, SSHKeyErrorCopy, MobileSSHBiometryErrorCopy) becomes instantiable structs, like MobilePairingCopy. - Parsers and codecs become initializers on their output: SSHParsedPrivateKey(openSSH:passphrase:), SSHHostKeyVerdict(presented: pinned:), MobileSSHTmuxLayout(_:).leaves, CmuxTUIBrowserEventWire(line:) .surfaceEvent, Decodable.init(cmuxTUILine:), Data(cmuxTUIBase64:). - Configured workers become values: BcryptPBKDF(rounds:), SSHPrivateKeyDecryption(cipher:kdfOptions:), SSHKeyInstaller( sshDirectory:), MobileSSHCmuxTUIInstaller(binDirectory:). - SSH ids become a MobileSSHIdentifier value over the raw string. - Wire constants become typed values (SFTPStatusCode, SFTPAttributeFlags as an OptionSet). - Shell quoting and the terminal query-reply filter move onto String and Data. The two NIO auth delegates drop OSAllocatedUnfairLock and become actors. NIO completes both callbacks through promises, so the actor hop only delays the promise; the host-key delegate still pauses the handshake deadline synchronously on the event loop before hopping. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: four regression-pass UX fixes - SSH-only mode no longer reads "Mac update required" on the Computers button. The toolbar label treated SSH computer ids as Macs; with no version on record the Mac floor called them outdated. The label now scopes its warning to paired-Mac ids. - Saving a new SSH host, or new connection details, connects it through MobileSSHComputers.autoConnect (saveHostAndConnect). Changed details close the stale connection and clear the old failure. The password install keeps its own connect after the key lands. - The terminal keeps the keyboard after the system "Allow Paste" alert. The alert makes the app inactive, and the input-session reducer forgot the focused owner on every resign. It now restores the owner a foreground interruption took, and forgets it on background or any newer intent. Shared by SSH and paired-Mac terminals. - The SSH Files chip shows when the terminal opens. It is the only entry to the server's files, so it no longer waits for a scroll reveal (TerminalFilesChipReveal.always); the Mac chip stays scroll-revealed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * PRD: round 4 decisions and verification Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: await queued auto-connect pause writes instead of polling The pause flag is persisted from an unstructured Task, and the tests waited for it with a 1000-yield poll that the test-determinism gate rejects (yield-count-poll). Chain the writes through one stored task so a pause and a later resume always land on disk in order, and let tests await that task directly. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui SDKs: count set-terminal-idle-policy in the command inventory tests The idle-close change added set-terminal-idle-policy to the generated protocol (113 commands) but the per-language coverage tests still pinned 112, so every SDK package job failed on the exact count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: only the newest pause write restores the in-memory flag Each pause-flag write re-applied its own value to the in-memory host after landing, to undo a reload that read the old flag. When a resume followed a pause (opening a host behind a declined jump host), the older pause write landed afterwards and set the flag back to paused until the resume's write caught up, which aDeclinedJumpHostPausesTheHostsBehindIt caught under full-suite load. Writes now carry a per-host generation and only the newest one touches memory. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: creating on a selected SSH computer does not need the Mac WorkspaceMacSelectionScope.canCreateWorkspace checked the foreground Mac's create gate before the locally served (SSH) case, so with no Mac connected the SSH computer's create action was disabled even though it creates on its own connection. sshComputerIsSelectableAndCreatableWithout WorkspacesOrMac covers this and failed in the iOS simulator lanes. The locally served case now returns before that gate; a pending computer switch still blocks it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: keep the Devices toolbar label's ID filter off the main actor MobileDevicesToolbarLabel.macPairingIDs is a static helper on a SwiftUI View, so its filter closure inherited main-actor isolation and trapped the whole xctest process when sshComputersNeverCountAsMacsForTheWarning called it from a nonisolated test. In the iOS simulator lanes that crash took down hundreds of unrelated tests per launch (xcresult: "Crash: xctest at closure #1 in static MobileDevicesToolbarLabel.macPairingIDs"). The pure helpers are now nonisolated, and the test that builds the view runs on the main actor. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui SSH: list hashed session sockets, add split, learn session from identify Sessions whose names are too long for a socket path live at cmux-tui-hashed-<uid>/<sha256>.sock. Discovery now lists them next to named sockets (first runtime directory per session wins) and matches a session to a hashed socket by SHA-256 digest. The control's session is the name the owner reports in identify, so a hashed socket learns it. Socket names follow the server's validate_session_name (spaces, Unicode, long names are valid); server ensure keeps its stricter rule. Adds CmuxTUIControl.split(pane:direction:) for Split Pane and SSHConnection.isOpen. Lab-free wire tests drive a scripted peer over an in-memory channel: identify-reported session, subscribe routing of tree-changed/surface-exited, split params and errors. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: cmux-tui rows follow remote topology; Split Pane per screen Topology: each cmux-tui provider subscribes on its control connection. tree-changed, surface-exited, empty, overflow, daemon shutdown, and an owner that went away (control dropped while SSH lives) ask the runtime to relist through the existing onTopologyChange path. A gate coalesces a burst to one request per listing; titles, sizes, and bells never relist. No timers. Hashed sessions: the registry keys a hashed socket's provider by the name its owner reports and finds cached providers by digest. Split Pane (D32): a cmux-tui screen section offers New Tab, then Split Pane, which splits the screen's active pane to the right (split). tmux windows keep Split Pane only. Section actions are one enum shared by the store, the registry, and the picker; the label reuses the localized mobile.ssh.tabs.splitPane string. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui: test that another client's creations keep a frontend's view A phone creating a screen, a tab, or a split through new-screen, new-tab, or split moves the shared tree's active fields only; an attached frontend keeps the screen, pane, and tab it shows (preserve_client_view, present since before 0.13.4). Test-only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * PRD: D40-D43 for the deferred cmux-tui items Hashed sockets listed, rows follow remote topology through subscribe, cmux-tui Split Pane, and phone creations keep the laptop's view. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: plain shells report their folder to Files through OSC 7 The Files chip in a plain SSH shell always opened the remote home folder: MobileSSHPlainProvider did not conform to MobileSSHCurrentDirectoryProviding, so currentDirectory(surfaceID:) returned nil for every shell. tmux and cmux-tui can be asked for a pane's folder; a plain login shell cannot. Terminals learn a shell's folder from the OSC 7 report (ESC ] 7 ; file://host/path) the shell prints before each prompt, which fish sends by default and zsh/bash send with terminal shell integration. MobileSSHWorkingDirectoryReport reads those reports passively from the channel's output (split chunks joined, BEL or ST terminated, percent-decoded, bounded), and the plain provider keeps the newest one per shell and answers currentDirectory with it. Nothing is sent to the shell and no dotfile is touched; a shell that never reports keeps the home-folder fallback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: a relaunch lands on the SSH computer used last The signed-out SSH shell cannot show "All Computers" (its empty states are Mac-oriented), so on launch selectSSHComputerForSignedOutShellIfNeeded scopes the list to an SSH computer whenever the saved scope is not one. It always picked hosts.first, the oldest host, so after using another host under "All Computers" (or deleting the selected host) every relaunch went back to the first computer instead of the one in use. A paired Mac is restored from its persisted active flag, written when the user switches to it. SSH computers now keep the same fact: MobileSSHComputers.open(hostID:), the one path every SSH selection goes through (title picker, Computers screen, new host, row switch), records the host in SSHHostStore (ssh-last-used-host.json, cleared when the host is deleted), reload() restores it before publishing hosts, and preferredHost (last used, else oldest) is what the signed-out shell selects. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: name the switcher's browser section by computer kind The terminal switcher listed an SSH computer's browser tabs under "Mac Browsers": TerminalPickerMenu hard-coded that section title for every workspace, and the tabs of a cmux-tui host are not on a Mac. TerminalPickerMenuValue now carries whether the workspace belongs to an SSH computer and titles the section "Browsers" there (new key mobile.ssh.browserStream.menuTitle, all nine languages); cmux Mac workspaces keep "Mac Browsers". The kind is part of the menu value, so the native menu rebuilds when it changes. HIG Menus: a section title names its group. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: Browse Files in the workspace title menu Browsing an SSH terminal's files was only reachable from the Files chip on the terminal, a control the user is deciding whether to keep always visible. HIG Toolbars places whole-document commands in the document menu next to the title, and HIG Menus asks for verb labels, so the workspace title menu now offers "Browse Files" (folder symbol, own section above the workspace actions) whenever an SSH terminal is showing. It calls the chip's own presentSSHFiles(terminalID:), so both open the same SFTP browser at the shell's current folder. The chip is unchanged. New key mobile.ssh.files.menuItem in all nine languages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH tmux: pin the control-mode seed order (B6 investigation) B6 reports a tmux pane that is sometimes blank on first open and renders after reopening. This Mac has no free PTYs, so tmux cannot start a pane here; instead the seed path is pinned with an in-memory tmux -C stream. MobileSSHTmuxControlClient now takes its byte pipe through a small MobileSSHTmuxControlTransport protocol (SSHSessionChannel conforms; tests pass a pipe), with no behavior change. MobileSSHTmuxSeedOrderTests drives the provider's attach order and checks that refresh-client -C precedes the pause/capture/state/continue batch in one ordered stream, a flag-0 startup reply block is not taken as the reply to the phone's first command, %output before the capture reply is dropped (the capture has it), and the pane gets the grid, then the snapshot, then output that followed the capture. It passes on the current code, so the blank first open does not come from the client's seed ordering; the remaining suspects are on the phone side (see artifacts/dssh/v3/night/results.md). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: pin seed delivery across late teardown and before the first grid (failing) Two of the three new tests fail on the current code: a late teardown of the previous view retires the new view's viewport negotiation and the SSH attach never starts, and a replay before any grid attaches at 80x24. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: attach at the phone's grid as soon as the view subscribes (B6) The SSH attach was triggered by the Mac cold-replay deferral, which waits for the viewport acknowledgement. A late teardown of the previous view (clearTerminalViewport after the new view subscribed) retires that negotiation, so the replay never ran: no attach, no tmux capture-pane or cmux-tui vt-state seed, blank until a reopen. And a replay with no grid yet attached at a placeholder 80x24 and resized afterwards. The phone owns SSH geometry, so the grid is recorded when the viewport is prepared (before the sink registers), SSH sinks replay at registration without waiting for the negotiation, and an attach with no known grid waits for the first one (input typed meanwhile is queued) instead of seeding at 80x24. Output that arrives with no subscriber was already kept in the surface's replay buffer and repainted on subscription; the new test pins that too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: offer Reconnect only when the host or the shown session is down The workspace title menu used the Mac rule for every row, so a connected SSH shell offered Reconnect, and choosing it ran the Mac redial path (switchToMac with an SSH id, then reconnectOrRefresh of the foreground Mac) instead of touching the SSH host. MobileSSHComputers now owns both halves: canReconnect(hostID:surfaceID:) is true when the host is idle or failed, or the shown terminal's session ended (tracked in endedSurfaces), false while connecting and for a live terminal on a connected host; reconnect(hostID:surfaceID:) opens the host and reattaches the shown terminal when it is not live (an ended shell opens a new one). Mac rows keep canReconnectFromTitleMenu. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: a refused terminal request reads as a sentence channelRequestRejected("pty-req") reached the terminal as a raw enum dump. A refused pty-req or shell now reads "This computer refused to open a terminal. Try again."; any other refused request reads "This computer refused the request (<reason>)." keeping tmux's own reason. Nine languages. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * SSH: New Workspace follows a host switch The + menu is Equatable on its value alone, and the value held only the kinds, which are the same on every host. After switching SSH hosts SwiftUI kept the old menu and its create closure, so the first New Shell opened on the previous host: in the night pass that host was a real sshd out of PTYs, which refused pty-req (channelRequestRejected), and the server being watched logged no session. A retry worked because the menu had re-rendered by then. The value now carries the SSH target host, so a host switch invalidates the menu and its action. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * PRD: overnight changelog Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * cmux-tui: test that an empty workspace create reaches a subscriber `workspace create --empty` goes through the resource router's pure creation path, which commits the registry patch without emitting a coarse MuxEvent, so `subscribe` clients (phones, native attach frontends) never see a tree-changed push for it; the row appears only when the next real change flushes. Seen live in the direct-SSH v4 pass (artifacts/dssh/v4/pty-live/results.md, check 5). This commit adds the failing regression; the fix follows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * cmux-tui: push tree-changed when an empty workspace is created The resource router's pure creation path (resource_create_empty_workspace_selected) committed the registry patch and published only the journal event, never a MuxEvent, so subscribe clients learned about `workspace create --empty` only when the next real change flushed. Emit the same WorkspaceAdded TreeDelta the legacy create-workspace path emits, after the patch applies, with the entity snapshot and workspace revision the delta contract requires. Server-side only: SSH hosts see it once a cmux-tui release past the pinned 0.13.4 ships (PRD changelog updated). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: test that a dead tmux server asks for a relist v4 pty-live finding: when the tmux server ends, its control-mode exec channel dies while the SSH connection stays up, and sessions on the next server never appear until a manual pull-to-refresh. Adds the failing regression (an unexpected control death on a live host must request one relist and forget the per-server grouped-session collection pass) plus the behavior-preserving seams it drives: a nil-connection test path like MobileSSHPlainProvider's, the control wiring extracted into adopt(_:session:), an injectable hostConnectionIsOpen, and a pump completion await on the control client. Scripted in-memory tmux -C pipe, no PTY, no sleeps. The fix follows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: regression test for a deleted key reading as a raw error A host whose key was deleted keeps a dangling keyID; a connect then loads a key whose secret is gone and throws SSHKeyStoreError.missingSecret, which MobileSSHComputers.describe(_:) rendered as the raw enum case name in the terminal and the row status. This test pins that it must read as the 'choose a key' sentence instead. Fails on the current code; the next commit fixes it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: a deleted key reads as 'choose a key', not a raw error MobileSSHComputers.describe(_:) had no case for SSHKeyStoreError, so a connect on a host whose key was deleted (the host keeps a dangling keyID, and privateKey(for:) throws missingSecret) fell to String(describing:) and showed the literal 'missingSecret' in the terminal and the row status. Map SSHKeyStoreError.missingSecret to the existing localized noKey copy ('Choose a key for this computer first.'), the same guidance the noKey path gives and the exact recovery the user needs (re-pick a key in the editor). Covers every missing-key-secret cause (deleted key, keychain eviction, restore) with no new string. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: relist once when the tmux server dies under a live connection A control client that ends while still registered was closed by tmux, not the phone (phone-initiated closes remove it from the registry first). When the SSH connection is still open that means the server (or this session) ended: forget the per-server stale-grouped-session collection pass, which belonged to the dead server, and fire the existing onTopologyChange relist path once, so a restarted server's sessions appear without pull-to-refresh. The relist reuses refreshWorkspaces' generation coalescing and failure handling; no timers, one relist per death. Connection teardown stays on the runtime's own close path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: test that a failed Reconnect answers in the terminal Tapping Reconnect in the title menu while the computer is still down changes nothing on screen: the title already read Disconnected, the row already carried the failure sentence, and nothing new reaches the terminal, so the tap looks ignored (v4 edges pass, scenario 1). Expected to fail until the next commit delivers the failure sentence to the shown surface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: a failed Reconnect prints its failure sentence in the terminal Reconnect against a still-down computer already walks connecting -> failed, but a refused loopback connect resolves in milliseconds and the failed state renders the exact chrome (red Disconnected) shown before the tap, so nothing visibly happens. reconnect(hostID:surfaceID:) now delivers the failure sentence to the shown surface after a failed open, with the same red-notice rendering a failed attach uses (shared errorNotice helper). A declined identity prompt leaves the status idle, so cancelling stays quiet; the still-visible Connecting/Reconnecting state during a slow connect is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: All Computers empty state stops pitching Mac pairing to SSH-only users The aggregated (All Computers) empty state always rendered the Mac-pairing copy ("Enable iOS pairing in cmux Settings > Mobile on your Mac..."), which describes a Mac an SSH-only user does not have; per-host SSH empty states were already right (v4 edges pass, secondary observation; PRD D29 mode-aware empty states). WorkspaceListEmptyGuidance decides from what exists: SSH computers with no paired Mac get SSH guidance (the per-host "No Workspaces" title, a terminal icon, and "Choose a computer from the menu at the top, or add one from the Computers screen."), and any paired Mac keeps the Mac copy. The SSH variant also drops Retry and See Docs, which drive the Mac workspace-list recovery and the Mac pairing docs. The guidance rides the table snapshot into the empty row model, so a change re-renders the row. New string localized in all nine app languages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: regression that a dropped transport leaves the terminal with no 'Session ended' line connectionClosed removes the host's attachments silently and relies on each child channel's own close event to write the '[Session ended]' line, so a transport drop whose channel close lags leaves the shown terminal with nothing. MobileSSHConnectionDropTests.droppedTransportEndsTheShownTerminal drives the connection-close path alone and fails: no notice, endedSurfaces empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: a dropped transport ends its terminals deterministically A whole-connection drop (server death) now routes through the same idempotent per-surface end path a child channel's close uses, so the '[Session ended]' line and the endedSurfaces mark land on the transport close instead of waiting on each channel's own close event, which can lag arbitrarily under load. It also drops stale attachAwaitingGrid entries so a reconnect re-seeds through the ordinary subscribe path. handle(.ended) and connectionClosed share endTerminalSurface, which is idempotent through endedSurfaces so the two paths never double-print. MobileSSHConnectionDropTests.droppedTransportEndsTheShownTerminal now passes; channelCloseEndsTheShownTerminal and reconnectWhileSubscribedRepaints guard the channel-close line and the reconnect repaint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * PRD: overnight 2 changelog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: the Files chip is scroll-revealed again, exactly like a Mac's Round 4 made the SSH Files chip always visible (TerminalFilesChipReveal .always) because it is the only entry to the server's files, but a chip that never fades sits over the first terminal rows. Aziz: it should look and behave exactly like Files on a paired Mac. The reveal special case is reverted: every terminal's chip is hidden at rest, shown while scrolling, and faded after the same linger, with the same assistive-technology bypass, through the same component. Browse Files in the workspace title menu (D28 follow-up) remains the always-visible entry point, so discoverability does not regress. TerminalFilesChipReveal and its tests are deleted as dead code; the SSH chip's persistent mount (no artifact count to gate it) is unchanged (PRD D44). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: Split Right and Split Down replace the single Split Pane The grouped tab switcher's one "Split Pane" always split one way (tmux stacked below, cmux-tui to the right), with no way to pick the other orientation. It is now the two directional actions the cmux macOS app has, with the same names, translations (all nine app languages, copied from the macOS catalog), and SF Symbols: Split Right puts the new pane side by side (tmux `split-window -h`, cmux-tui `split dir:"right"`) and Split Down stacks it (`-v` / `dir:"down"`), on both tmux windows and cmux-tui screens, still detached so no attached client's view moves (PRD D45, superseding D32/D42's single action; HIG Menus: one item per action). MobileSSHSectionAction carries its direction; the tmux flag mapping is a pure helper with a unit test, and the cmux-tui wire tests already pin `dir` for both values. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * SSH: empty workspace lists render the paired-Mac empty state An SSH host's "No Workspaces" overlay was a bare ContentUnavailableView and the SSH-variant All Computers row used its own terminal icon and title, so SSH empty states looked like a different app from a paired Mac's. The paired-Mac empty row's visual scaffold (macbook.and.iphone icon at 44pt, "No workspaces yet" title2.bold, secondary message, spacing, width cap) is now one shared view, WorkspaceListEmptyStateScaffold, used by the table row and the per-host SSH overlay, so every empty state is pixel-identical. Only what must differ differs: SSH messages carry the host's status line (per host, still inside the pull-to-refresh scroll view with auto-connect) or the choose/add-computer line (All Computers), and the Mac-only Retry and See Docs buttons stay on the Mac variant, which drives Mac workspace-list recovery and pairing docs (PRD D46). The mobile.ssh.empty.title key is now unused and removed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * iOS: remove the SSH-only entry; every user signs in The sign-in screen's quiet "Use with SSH only" entry, the signed-out SSH shell it opened (SSHOnlyWelcomeView, MobileSSHOnlyPreference, the mobileSSHOnlyEntry environment action), and the audience machinery that suppressed Mac notices for it (MobileWhatsNewAudience) are removed: MobileRootAuthGate.shouldShowSignIn no longer takes a bypass, so a signed-out launch always lands on sign-in and every user signs in before using the app (PRD D47, superseding D5's no-account entry; deliberate deviation from HIG Managing accounts, since every cmux surface is account-backed). The SSH computers feature itself is untouched for signed-in users: hosts and keys stay on-device, the Computers screen and pairing's "Connect with SSH Instead" still add hosts, and WorkspaceListEmptyGuidance still keys on has-SSH-computers / no-paired-Mac, which a signed-in account before its first pairing hits. An attach-ticket session (no Stack account) keeps its settings sign-in row. Localization keys for the removed strings are deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * PRD: round 5 (D44-D47) Files chip back to the Mac scroll reveal, Split Right/Split Down, one empty-state scaffold, and required sign-in; D5/D29/D42 and the round-4 chip note updated to point at their supersessions; changelog line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Summary
Testing