Skip to content

Fix find focus and align search overlay - #1

Merged
lawrencecchen merged 1 commit into
mainfrom
fix/find-cmdf-focus
Jan 30, 2026
Merged

lawrencecchen merged 1 commit into
mainfrom
fix/find-cmdf-focus

Conversation

@lawrencecchen

Copy link
Copy Markdown
Contributor

Summary

  • align find/search overlay and focus handling with Ghostty's Swift implementation
  • add debug lorem tab and menu hooks for easier search testing
  • add search state wiring and keypress handling to match Ghostty

Testing

  • ./scripts/reload.sh

@vercel

vercel Bot commented Jan 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmuxterm-docs Ready Ready Preview, Comment Jan 30, 2026 4:37am

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread Sources/TabManager.swift
Comment on lines +342 to +346
_ = selectedSurface?.performBindingAction("search:next")
}

func findPrevious() {
_ = selectedSurface?.performBindingAction("search:previous")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@lawrencecchen
lawrencecchen merged commit a9ca768 into main Jan 30, 2026
4 of 5 checks passed
@JerryZhu86 JerryZhu86 mentioned this pull request Mar 22, 2026
@cliffluo cliffluo mentioned this pull request Mar 24, 2026
bn-l pushed a commit to bn-l/cmux that referenced this pull request Apr 3, 2026
dezren39 added a commit to dezren39/cmux that referenced this pull request Apr 12, 2026
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.
no1coder pushed a commit to no1coder/cmux that referenced this pull request Apr 19, 2026
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.
jack-tsai added a commit to jack-tsai/cmux that referenced this pull request Apr 23, 2026
… 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.
ejc3 added a commit to ejc3/cmux that referenced this pull request Jul 15, 2026
…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).
azooz2003-bit added a commit that referenced this pull request Jul 26, 2026
… 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>
azooz2003-bit added a commit that referenced this pull request Jul 29, 2026
* 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>
azooz2003-bit added a commit that referenced this pull request Jul 30, 2026
…#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…
azooz2003-bit added a commit that referenced this pull request Sep 26, 2026
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>
azooz2003-bit added a commit that referenced this pull request Sep 28, 2026
* 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>

This branch was successfully deployed

1 active deployment
Preview — 3d022db1 Deployed Jan 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant