* test: require Tailscale-only Mac pairing QR
* fix: focus Mac pairing on Tailscale QR
* test: require Tailscale pairing action names
* fix: name QR entrypoints for Tailscale
* test: require Tailscale setup guidance in scanner
* fix: explain Tailscale pairing prerequisites
The workspace list's computer picker already switches Macs, pairing
lives in the Connection Method section and onboarding, and hiding a
computer lives in the Hidden Computers list. Settings > Switch Computer
duplicated all three, so drop MobileHostPickerView, its Settings entry,
and the 15 mobile.hostPicker.*/switchMac localization keys (en+ja) it
alone used. The Connection section now renders only when it has a live
connection row, so its header never sits empty.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add model selection lab to the New Task composer
Adds a curated per-provider model catalog (Claude, Codex, OpenCode) with
opt-in model-flag injection into template commands, and five UX variants
for picking the model in the New Task sheet (combined agent menu, model
row, trailing chip, pill strip, context row), switchable at runtime from
the DEBUG-only CMUX Labs 'New Task Model Lab'. No model selected keeps
template commands byte-for-byte verbatim; release builds stay off.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add Codex-style composer layout to the New Task sheet
New minimal layout (lab-switchable, DEBUG default): full-bleed prompt
canvas titled by the working directory, back chevron, and a bottom
control bar with a + options sheet (name, Mac, directory), agent pill,
model pill, and a circular submit button. Classic card layout stays
available via CMUX Labs and renders unchanged; release builds keep
classic. Adds GPT-5.6 Luna to the Codex model catalog.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review findings on the model picker
- Dissolve the MobileTaskAgentModelCatalog static namespace into
MobileTaskAgentProvider (detection init, models, model(id:),
command(applying:to:)) per the no-static-namespace policy.
- Replace an existing --model/-m/--model= value in place instead of
injecting a duplicate flag that the template's own value would
override; stop scanning at the -- end-of-options token.
- Pin the task-composer accessibility preview to the classic layout and
Off variant on fresh installs so the XCUITest suite keeps a stable
element tree; CMUX_UITEST_TASK_COMPOSER_LAYOUT/_MODEL_VARIANT opt in.
- Give each lab variant exactly one placement in the composer layout:
combined stays in the agent submenu, contextRow stays in Task Options,
the rest collapse to the standalone bottom-bar pill.
- Gate the composer submit button on blocking completed-operation
recovery, matching the classic layout.
- Make combined menu taps a single atomic template+model mutation.
- Share the model display-name fallback and accessibility triple; share
the directory search/list fallback closures.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-2 review findings
- Quote-aware token scanning in command(applying:to:): flag text inside
single/double-quoted arguments is one opaque token, so quoted mentions
of --model are never rewritten; every real model flag before -- is
replaced (not just the first); a flag directly before -- gets its
value supplied in place.
- Gate selectedModel on the rendered picker variant so a draft-restored
model cannot ride into snapshots or submissions while the picker is
Off; the stored selection survives for when a variant is re-enabled.
- Show the selected model in the composer layout's combined variant
(agent pill title gains ' · <model>') and add visible checkmarks to
the combined submenu rows.
- Extend compact composer controls (+, submit, pills, chip, row, pill
strip) to 44pt activation targets without changing their visuals.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-3 review findings
- Stop the model-flag scan at the first simple command's end: a newline
separator or a token carrying an unquoted ;, |, or &. A compound
template like 'claude "$CMUX_TASK_PROMPT"; formatter --model compact'
now inserts Claude's flag after the first token and leaves the later
command untouched.
- Route every submission through effectiveSubmissionSnapshot: while the
picker variant is Off, a hidden model captured by the cached restored
request (or an adopted recovery request) is stripped and the command
recomposed with the same operation identifier, closing the untouched-
draft bypass of the selectedModel gate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-4 review findings
- Build the composer options sheet lazily: MinimalLayout now takes a
deferred builder, so directory-candidate construction (a workspace
walk) runs only when Task Options is presented, not on every prompt
keystroke's body rebuild.
- Reconcile a hidden model at the submission boundary by marking the
request dirty instead of post-hoc snapshot surgery: resolution runs
through makeSubmissionSnapshot (whose selectedModel gate strips the
model) and MobileTaskSubmissionIdentity mints a fresh operation ID for
the changed bytes, keeping retries idempotent.
- Process a model flag attached to a command separator: --model=old;,
--model old;, and --model; are rewritten before scanning stops, so a
stale value can no longer override the selection.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-5 review findings
- Reconcile a hidden model at BOTH request-resolution boundaries via one
shared resolver: when the Off picker hides a model a clean cached
request still carries, resolution is forced through the selectedModel
gate and the identity mints a fresh operation ID, so a persisted draft
can never pair model-less bytes with an ID previously bound to
model-bearing bytes. Replaces the submit-only proxy check.
- Treat redirection operators as part of the simple command: & adjacent
to > (2>&1, >&2, &>file) and | preceded by > (>|file) no longer end
the flag scan, so a stale --model after a redirection is still
replaced. Control operators (;, |, &, &&, ||) still end it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address final review round: comments and delisted-model drafts
- Stop the model-flag scan at an unquoted word-initial #: a commented
flag is never rewritten, and the selection is inserted after the first
token instead of being silently swallowed by a comment edit.
- Do not reuse a draft's operation ID (or restore its completed-
operation recovery) when the draft's model no longer survives
curated-list validation; the resulting default-model command gets a
fresh idempotency key.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use the adjustments glyph for the composer options button
Dogfood feedback: + implied adding something; the button configures the
task (name, Mac, directory), so it now shows slider.horizontal.3.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Stop pill labels clipping when the selection gets longer
Dogfood feedback: switching the agent or model to a longer title left
the pill label clipped for the length of the resize animation. The pill
content now uses fixedSize so the capsule adopts the new intrinsic
width immediately, and the label subtree is identity-keyed on the title
so it swaps instead of animating through stale-width frames.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add mobile task attachments
* Always show the model pill in the composer layout; fix iOS 26 pill clipping
Dogfood feedback: the combined lab variant hid the standalone model
pill (models lived only in the agent submenu), which read as the model
picker disappearing. The composer layout now has one canonical model
treatment: a dedicated pill beside the agent pill for every non-Off
variant; the agent menu is forced plain and the options sheet never
repeats the contextRow, so the pill stays the single entry point.
The label clipping on longer titles survived the fixedSize fix because
the identity key sat on the label content while the UIKit menu button
still animated its frame. The .id now keys the whole Menu, so a title
change swaps the button instead of animating through stale bounds.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fade the composer pill scroller into the bar at both edges
Dogfood request: pills should dissolve toward the neighboring options
and submit buttons instead of clipping at the scroller bounds. iOS 26
uses the native soft scroll edge effect (progressive blur + fade);
earlier systems approximate it with a 14pt alpha-mask fade per edge.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Discover task models from connected Macs
* Adopt MacPairingKey lookups in task capability checks
Main's typed MacPairingKey re-key changed the secondary-subscription
registry key from a device-id string to the full pairing key. The
attachment and model-discovery capability checks now resolve through a
shared controlSubscriptionMatching helper that keeps the old semantics:
exact pairing when a tag is given, any same-device pairing otherwise.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scroll the composer pills under the bar buttons with a real edge effect
Dogfood feedback: the scroll edge effect never rendered because the
buttons sat NEXT TO the scroller, so no content ever passed beneath an
edge. The pill scroller now spans the bar with the attachment/options
buttons and the submit button living in its leading/trailing safe-area
insets: pills genuinely scroll under them, which is what activates the
native iOS 26 soft scroll edge effect (progressive blur + fade). Pre-26
keeps an opaque button background as the fallback occlusion.
Also stop the prompt editor from yanking long text back down while
scrolling up: interactive keyboard dismissal resized the editor every
drag frame and UITextView re-scrolled to the caret each time; dismissal
is now immediate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Render the pill scroller edge effect through UIKit's container interaction
SwiftUI's scrollEdgeEffectStyle only styles effects the system already
owns (bars/glass), so pills merely underlapped the buttons. The bar row
is now a thin UIKit host: a horizontal UIScrollView spans the bar, the
button clusters float above it, and on iOS 26 each cluster carries a
UIScrollEdgeElementContainerInteraction bound to the scroll view's
edge, which renders the real progressive blur+fade beneath the buttons
as pills pass under. Pre-26 clusters keep an opaque background.
Content insets track the cluster widths (the attachment button is
capability-gated), resting the pills between the clusters.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Attach the scroll edge effect via probes instead of rehosting the bar
The UIKit-hosted bar livelocked SwiftUI (hosting-controller sizing
feedback re-rendered every frame) and blanked the composer, which also
made the pills unscrollable. The pills return to the proven SwiftUI
ScrollView under safe-area-inset button clusters; a zero-size probe in
the scroll content walks to the backing UIScrollView and a coordinator
binds UIScrollEdgeElementContainerInteraction to transparent container
views behind each cluster. Fail-soft: if the probe finds no scroll view
or the OS predates iOS 26, nothing attaches and the bar just underlaps.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Reproduce the scroll edge effect deterministically in SwiftUI
Two native attempts failed structurally: SwiftUI's scrollEdgeEffectStyle
never renders for floating siblings, and hosting the bar (or just the
clusters) in UIKit for UIScrollEdgeElementContainerInteraction either
livelocked the view graph or dropped cluster content, because the
effect's shape must come from the container's descendants. The bar now
stays pure SwiftUI: clusters carry an ultraThinMaterial background
(full blur under the buttons) and a 24pt gradient-masked material band
beside each cluster fades passing pills into the bar background --
the same progressive blur+fade the system effect draws, with no
UIKit bridging left to break scrolling or layout.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use native scroll edge effects in task composer
* Fix task composer scroll edge blur
* Use native shaped scroll edge effects
* Test composer pill scroller hard edges
* Fix composer hard-edge UI test lookup
* Restore hard edges to composer pill scroller
* Exercise overflowing composer pills in hard-edge test
* Test composer prompt scroll gesture ownership
* Prioritize prompt scrolling over sheet drag
* Test composer prompt scroll position stability
* Keep composer prompt at manual scroll position
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test: cover mobile input session ownership
* fix: centralize mobile terminal input ownership
* chore: add mobile dock verification geometry
* test: cover keyboard ownership review edges
* fix: close mobile input ownership review gaps
* Test foreground recovery teardown handoff
* Keep disconnected recovery foreground-only
* Respect the active foreground recovery owner
* Test clientless foreground aggregation
* Require a live client for aggregation
* Use UIKit keyboard guide for terminal dock
* Test workspace group docs locale overrides
* Translate workspace group anchor guidance
* Test localized workspace group action labels
* Translate workspace group action labels
* Test workspace group docs match native labels
* Match Khmer docs to workspace group menu
* Use static imports in localization test
* Add regression tests for PATH directory shadowing of provider binaries
FileManager.isExecutableFile(atPath:) returns true for directories on macOS,
so a directory named like a provider binary earlier on PATH is selected by the
CLI and app PATH walks. These tests fail until the resolvers reject directories.
Refs #8743
Co-Authored-By: Claude Opus 5 <[email protected]>
* Skip directories when resolving provider executables on PATH
FileManager.isExecutableFile(atPath:) returns true for directories on macOS, so
a directory named like a provider binary (~/bin/omx/, ~/bin/claude/) earlier on
PATH was selected as the executable and the launch failed at execv with a
confusing "Permission denied". Reject directories with
fileExists(atPath:isDirectory:) before the executable check in all three PATH
walks, mirroring the guard resolveClaudeExecutable already applied to configured
candidates.
Fixes#8743
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add Pi landing page and agent SEO
* Keep homepage copy localized
* Limit Pi discovery to English
* Expand coding agent SEO coverage
* Localize coding agent landing pages
* Localize Pi guide card
* Remove stale English-only Pi link copy
* Keep agent metadata locale-native
Fix same-pane tab reordering to middle indices by taking the Bonsplit SwiftUI delegate path as the sole reorder owner. Includes hosted E2E coverage for later-to-middle and earlier-to-middle tab drags.
* ios: keep diff scroll momentum by persisting the row only at scroll idle
FileDiffPageView propagated every scrollPosition row change up into the
pager's @State while the finger was still down or the view was
decelerating. Each write re-rendered the pager mid-fling and the bound
scrollPosition(id⚓.top) re-anchored the tracked row on the next
layout pass, cancelling the remaining momentum: lifting the finger
stopped the diff dead.
Route persistence through SettledScrollRowReporter, which reports the
tracked row only when the scroll phase returns to idle (plus once on
page unmount), so nothing re-renders during a fling. Restore-on-remount
behavior is unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: add a many-screen diff to the changes preview fixture
Every hand-written fixture diff fits on one screen, so scroll flings
and deceleration could not be exercised deterministically. Add a
400-line generated diff (Sources/RenderPipeline.swift) to the DEBUG
changes preview fixture.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: drop the anchor from the diff page's live scrollPosition binding
On-sim verification showed flings still died with only the idle-phase
reporter fix: scrollPosition(id⚓.top) itself re-aligns the
tracked row flush to the viewport top on every internal position
update during deceleration, so the fling stops at the first row
crossing (the settled frame shows the row pixel-flush at the top).
Removing the anchor removes the alignment contract; tracking and
restore-on-remount keep working via the id binding.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: give the diff scroll offset a single owner
Real-path dogfood showed flings, rubber-banding, and pull-to-refresh
displacement all being cut short on real diffs even with the anchor
removed: any live scrollPosition(id:) binding makes SwiftUI a second
continuous owner of the scroll offset, and on heterogeneous multi-
thousand-row diffs every lazy row materialization re-resolves the bound
position against the moving offset (the uniform 400-row fixture never
re-resolved, which is why the earlier sim verification passed).
Drop the binding entirely. The top row is tracked in a plain reference
box via onScrollTargetVisibilityChange (no view state, no body
dependency, no layout participation), persisted at scroll-idle and on
unmount as before, and restore-on-remount becomes a one-shot
ScrollViewReader.scrollTo at appear. After that single command the
offset is owned exclusively by the scroll view's physics.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: resolve the settled diff row by document order, not callback order
onScrollTargetVisibilityChange documents no ordering for the ids it
reports, so taking visibleIDs.first as the top row was an assumption.
FileDiffPresentation now carries a rowOrderIndex built once alongside
the rows (off-main on the async paths), and TopVisibleRowPolicy picks
the id earliest in document order. Also balances the braces in the
generated fixture diff and documents why the pre-iOS-18 fallback is
acceptable (app floor is iOS 18.4; macOS builds this package for
tests only).
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: preserve diff restore across refresh
* ios: avoid fixture lint false positive
* ios: run diff preparation off the main actor
* ios: align sentry-cocoa pins with main (9.24.0)
The fleet builder's shared warm DerivedData precompiles Sentry modules
against main's pin; this branch's older 9.21/9.23 pins invalidated those
.pcm files ("header has been modified since the module file was built")
and failed every cloud iOS build. Pins-only change, byte-identical to
main's lockfiles.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: restore the diff scroll row from an explicit target, not the tracker
On-sim the remount restore landed at the file top: the visibility
callback fires for the unrestored top of the list before onAppear runs,
overwriting rowTracker.topRowID, so restoring from the tracker anchored
to row 1. The restore target is now captured explicitly — from the pager
at mount, from the live tracker only when a refresh re-arms the restore —
and scrollTo is re-applied once after the first layout pass because
LazyVStack only estimates offsets for unrealized rows.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test for auto-naming --mcp-config argument
Extracts the claude summarizer argv into
AutoNamingEnvironmentPolicy.claudeSummarizerArguments and asserts the
--mcp-config value is a valid MCP configuration object. It currently
emits a bare {}, which Claude Code rejects.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Pass a valid empty MCP configuration to the auto-naming summarizer
Claude Code 2.1.220 validates --mcp-config against a schema requiring an
mcpServers record, so the bare {} cmux passed made the summarizer exit
on argument validation and every workspace auto-naming attempt recorded
category: failed. Emit {"mcpServers":{}} instead.
Fixes#9457
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add failing test for leaf local-path Package.resolved false positive
Adding a dependency-free local-path package to a manifest that already has
remote pins makes check-package-resolved-policy.py demand three Package.resolved
diffs that swift package resolve cannot produce.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Key Package.resolved policy off reachable remote dependency calls
check-package-resolved-policy.py demanded a Package.resolved diff whenever a
manifest's dependency calls changed and that manifest's graph had any remote
dependency anywhere. Adding a dependency-free local-path package to such a
manifest therefore reported violations for lockfiles that `swift package
resolve` leaves byte-identical, so the demanded diff could not exist.
The graph now records the normalized text of every `.package(url:)` call per
manifest, and a manifest edit requires a lockfile diff only when the set of
url calls reachable through its local-path closure differs between merge-base
and HEAD. That set is exactly what SwiftPM pins, so version-requirement bumps
on an unchanged URL and newly reachable remote-bearing local packages still
require the diff.
Fixes#8871
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add failing test for recovered daemon transport bounce leaving sidebar error
Co-Authored-By: Claude Opus 5 <[email protected]>
* Retract recovered daemon transport errors from the workspace sidebar
Fixes#8917
* Move the daemon recovery regression test to Swift Testing
* Drop stray blank line in WorkspaceRemoteConnectionTests
* Import CmuxSidebar in the daemon recovery test
---------
Co-authored-by: Claude Opus 5 <[email protected]>
Track whether mobile workspace-list responses actually include groups, preserve the last authoritative group snapshot through reconnect and empty transient states, and allow healthy connected empty snapshots to clear stale group headers.
* Add failing test: Attempt Update with no update available must not report install failure
* Add failing UI test: Attempt Update with no update available must not show an error pill
* Treat 'no update available' as a success in Attempt Update
Fixes the red "Update Didn't Start / check your internet connection" pill
shown when Attempt Update runs while already on the latest version.
* Add failing regression test for Cmd+Shift+R on a focused workspace group
Covers https://github.com/manaflow-ai/cmux/issues/9199: renaming from the
shortcut while a group's anchor row is focused leaves the group header
name untouched.
* Rename the focused workspace group with Cmd+Shift+R
A workspace group's header row is backed by an anchor workspace whose own
title is hidden: the row renders the group name. The anchor's title is
seeded from the group name at creation and never resynced, so renaming
the focused anchor workspace prefilled a stale name ("Group 3") and
changed nothing visible.
Resolve the palette rename target through a shared resolver: when the
focused workspace is a group anchor, target the group, matching that
row's "Rename Group..." context menu item. Every other workspace still
renames itself.
Fixes#9199
* Move rename-target resolution onto CommandPaletteRenameTarget
Review feedback: the static-only resolver enum was a namespace type. The
focused-workspace resolution is now an initializer on the value it builds,
and the group anchor descriptor lives in its own file.
* Make the group rename UI test tolerate headless CI activation
* Assert the group rename regression through the control socket
The accessibility-label assertion passed on the unfixed build, so it was
not catching the bug. Setup and verification now go through the control
socket: create a group, rename only the group so the anchor workspace
title goes stale, focus the anchor, press Cmd+Shift+R, and assert the
group's name in the model changed.
* Drop the non-discriminating group rename UI test
The test passed on a build without the fix (run 30786168603), so it did
not capture the regression. Coverage stays with the resolver unit tests
until a UI-level check that actually fails on the bug is written.
* Restore the group rename UI test with a launch path that cannot pass vacuously
The previous version wrapped app.launch() in a non-strict XCTExpectFailure.
On a headless runner that absorbs the launch failure and, with
continueAfterFailure = false, abandons the rest of the test body without
recording anything, so the test reported success without running a single
assertion (proved by a variant carrying an unconditional XCTFail that also
passed: run 30787440944).
* Drop the group rename UI test: it cannot run on the e2e runner
With the vacuous-pass workaround removed, the test fails on both a fixed
and an unfixed build at the same line: app.activate() raises "Failed to
activate application (current state: Running Background)". The hosted
runner has no foreground GUI session, and XCUITest keystrokes only reach
a frontmost app, so a keystroke-driven test cannot work there.
Runs: 30788920066 (no fix) and 30788928069 (fix) — identical failure.
Pins the cmux Ghostty submodule to the locale-before-crash-reporting startup fix. ASC evidence for local 2026-08-02 showed all three cmux INTERNAL reports shared EXC_BAD_ACCESS in ghostty_init + 1388 with the main thread in setlocale/loadlocale via GhosttyRuntime.swift:110.
* Add failing tests for route-content equivalence hardening
Pins six behaviors from the cubic review of #9342: reorder-only
capability, relay fleet, and grant verification key revisions keep
live sessions; a snapshot installed for a revision recorded without
content fails closed; an older route revision install cannot roll
back a newer one; a redundant-dial close raced by invalidation
redials instead of returning the closed winner.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Harden route-content equivalence against reorders and races
Canonicalizes route content so order carries no meaning where the
admission policy reads sets: binding capabilities, the relay fleet,
and grant verification keys (by kid) are sorted when the content is
built, so reorder-only revision bumps keep live sessions.
didInstallRouteRevision now drops installs older than the recorded
revision, so an older completion of an overlapping reconciliation
cannot roll back a newer installed revision. The same-revision branch
compares the stored baseline and fails closed through the standard
superseded-peer invalidation when the baseline is missing or differs,
instead of silently adopting the content.
The peer session no longer returns a stale winner capture after the
redundant-dial close: settleRedundantDial re-reads the active slot
and its liveness after the close suspension and redials when the
winner was invalidated, replaced, or remotely closed.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test(ios): cover workspace group row actions
* fix(ios): restore workspace group row actions
* test(ios): cover group destructive confirmations
* test(ios): cover group read-state action refresh
* fix(ios): refresh group native action state
* test(ios): cover group native action inputs
* fix(ios): refresh group native action inputs
* test(ios): cover group swipe completion and rename alert
* fix(ios): restore workspace preview compilation
* test(ios): target visible group rename fixture
* fix(ios): preserve group swipe completion and compact rename
* test(ios): exercise group action presentation lifecycles
* test(ios): preserve workspace actions on group menus
* fix(ios): preserve workspace actions on group menus
* test(ios): exercise full group read swipe
* test(ios): isolate native group menu assertions
* test(ios): cover preserved group actions
* test(ios): keep preview fixture state owned
* test(ios): cover preserved group create actions
* fix(ios): preserve group creation entrypoints
* fix(ios): make destructive group requests atomic
* test(ios): cover configured group icons
* fix(ios): sync effective group icons
* test(ios): target live group row swipe
* test(ios): disambiguate group workspace rename
* fix(ios): disambiguate group workspace rename
* test: cover disconnected iOS dogfood launch
* fix: require connected iOS dogfood launches
* fix: make mobile readiness event driven
* Add failing iroh wake reconnect regressions
* Guarantee bounded foreground reconnect
* fix: harden mobile readiness lifecycle
* perf: buffer deadline event reads
* Add failing test: session snapshot mid-revalidation must classify transient
Every launch/foreground kicks a /users/me revalidation and
sessionTokenTransitionIsActive is true for its whole round trip.
authenticatedSessionSnapshot() throws .unauthorized for that window, which
the iroh broker token source treats as signed out, so endpoint activation
fails closed (endpointFailed authorizationFailed) on every app launch until
the revalidation completes. The same state is already classified
.networkError by accessToken(); the snapshot must match.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Classify transient token misses as connectivity, not authorization failure
Three-layer fix for the launch-time wedge where every iroh endpoint
activation failed closed (endpointFailed authorizationFailed) while a
foreground session revalidation owned the token store:
1. AuthCoordinator.authenticatedSessionSnapshot() now throws .networkError
while sessionTokenTransitionIsActive, matching accessToken()'s
classification. Every launch/foreground kicks a network /users/me
revalidation, and that window previously read as "signed out".
2. CmxIrohBrokerTokenSource.credentialPair is now throwing. A throw means
"cannot read a coherent pair right now" and the broker classifies it
.connectivity, so retry policies, verified-policy preservation, and the
cached offline-policy bootstrap all apply. nil still means definitively
signed out and fails closed with .missingAuthentication.
3. The iOS activation token source maps AuthError.unauthorized to nil
(fail closed) and rethrows every transient failure instead of collapsing
both into nil with try?.
Diagnosed from cmuxdiag exports on build 1.0.4 (20260731034828): three
consecutive relayPolicyRefreshFailed/endpointFailed(authorizationFailed)
within 10ms each (no network round trip) at launch, recovering only ~15s
later when the revalidation settled and the backoff retried.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Apply the same transient-token classification to the Mac host runtime
The Mac host's activation token source had the identical try? collapse:
a session revalidation window read as signed-out and tore the host
runtime down as unauthorized. Same mapping as iOS: unauthorized fails
closed with nil, transient failures rethrow and classify connectivity.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add failing wake-auth transport regressions
A broker 401 at app wake (token pair rotated by another lane between
capture and server validation) must not tear down the verified iroh
runtime, and the Mac being redialed must not be dialed a second time as
a background-control aggregation candidate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Survive wake-time broker auth rejections without endpoint teardown
At app wake the relay-policy refresh races the RPC lane's force token
refresh: the pair captured coherently a moment earlier reaches the
broker after rotation and gets a 401. That single 401 used to fail the
endpoint, clear routes and the offline cache (or tear down the whole
runtime on warm wakes), and nap 30-36s of flat backoff, turning a
seconds-long token race into the 30s-2.5min reconnect outages visible
in every wake ring.
Four changes:
- CmxIrohTrustBrokerClient recovers exactly once from a 401: the token
source re-captures (force-minting only when the rejected access token
is unchanged) and the request retries with the recovered pair. Frozen
pinned sources (sign-out revocation) opt out by default.
- 401/403 now preserve verified policy during refresh, and 401 retries
initial activation; resolvePolicy falls back to the verified offline
bootstrap on auth rejections like it already did for connectivity, so
LAN and cached-relay dials keep working while auth settles.
- The relay-policy refresh loop retries authorization failures on a
2s..120s ladder instead of the flat 30s+jitter schedule.
- The Mac being redialed is excluded from secondary aggregation while a
stored-Mac reconnect is in flight, removing the duplicate
background-control dial (and its drain wait) from every recovery.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Handle route-gated diagnostics in iOS settings
* test: remove source-shape admission assertion
* test(iroh): cover cached registration recovery
* fix(iroh): recover cached host registration
* test connection readiness failures
* test: cover cached host binding publication
* fix: publish cached mobile host binding
* iOS: replace disconnect chrome with Mail-style status line under the computers picker
While a reconnect attempt has not been rejected, the last visible workspace
list and terminals stay accessible. The workspace list shows a caption status
line (spinner + Reconnecting… / Not Connected) under the computers picker,
like Mail's Checking for Mail…; the terminal keeps only the compact status
pill. The full-screen TerminalDisconnectedOverlay, the list's
Disconnected/Reconnecting status row for non-startup states, and the
connection status toasts are removed. The reauth banner (rejected
connection, Sign Out is the only fix) and the initial-restore status row
(Retry / Add Computer, possibly no cached content) remain. Input gating and
the pill's recovery folding, previously behind the Toasts beta flag, are now
unconditional; a Reconnect item appears in the picker menu while Not
Connected.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Harden mobile connection readiness
* Keep subscription readiness separate from recovery
* Model delayed subscription acknowledgements
* test: cover usable mobile session readiness
* fix: require usable mobile connection readiness
* test: fail closed across broker auth cancellation
* test: cover complete iOS dogfood readiness
* test: fail closed when Mac pairing setup is unavailable
* fix: make iOS dev reload dogfood ready
* test: require ensure-mac to self-heal exact tag
* fix: let ensure-mac relaunch its exact tag
* fix(ios): keep list probe state coordinator-owned
* test(ios): pass active listener to recovery validation
* test: cover unsigned simulator identity evidence
* fix: trust seeded identity in unsigned simulator
* test: disambiguate group rename alert save
* test: expose expired-ticket group rename failure
* Authorize mac-scoped workspace mutations by Stack account, not ticket lifetime
The mobile data plane's design authority is the signed-in Stack account;
attach tickets are route discovery plus scope narrowing. Four verbs
(workspace.move, workspace.group.action, workspace.group.create, and
workspace.create with group_id) still hard-required a current attach
ticket, and minted tickets default to a 600s TTL, so iOS drag-and-drop
and the + button's New Workspace Group item silently disappeared ten
minutes after pairing (and never appeared for tokenless zero-touch
pairings).
Host: ticketAuthorizationResultIfNeeded no longer fails these verbs when
the attach token is missing, unknown, or expired; a token that maps to a
current stored ticket still narrows scope, so workspace-pinned tickets
remain rejected for Mac-wide mutations. Advertised as
workspace.mutations.account_auth.v1.
iOS: MobileShellWorkspaceMutationTicketPolicy mirrors the host: against
hosts advertising the capability, mutations stay allowed unless a
current workspace-scoped ticket narrows the connection; legacy hosts
keep the fail-closed behavior. Applied to the foreground gate, the
per-target mutation gate, and secondary-Mac handle capabilities.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): cover recovery transport drain
* fix(ios): drain stale route before recovery
* test: expose process-local readiness clock
* fix: use system monotonic readiness clock
* test(ios): expose scoped-ticket group rename gap
* fix(ios): preserve account-authorized group actions
* test(ios): keep group menus group scoped
* fix(ios): keep workspace group menus group scoped
* fix(ios): pass readiness clock after main merge
* test(ios): close group action review gaps
* Fix missing return in restoreCLIArgument (main compile break)
5bf9595804 (#9265) left the final expression of a multi-statement String?
method without an explicit return; every target compiling this file fails,
which currently blocks all merge-gate runs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): redact workspace mutation failure diagnostics
An rpcError message is an arbitrary host string; exported diagnostics now
carry only the bounded DiagnosticFailureKind plus the short RPC code, and
the os.log line marks the raw error private.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): stop presenting gated connect attempts as timeouts
connectAttemptGated means another attempt owns the route, not that the
Mac failed to respond. New pairing category with wait-for-active-attempt
copy and guidance (en+ja) instead of 'No response from …' timeout text.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): add missing statusLine keys to MobileShellUI catalog
mobile.workspaces.statusLine.reconnecting/notConnected were referenced by
WorkspaceConnectionStatusLineView but absent from the package catalog, so
Japanese fell back to English defaults.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(cli): monotonic events timeout budget, deterministic reconnect wait
The --timeout budget now runs on ContinuousClock so wall-clock changes
cannot expire or extend it; each socket call derives a fresh short-lived
Date from the monotonic remainder and authentication re-checks the budget
first. The reconnect pause replaces the Timer+RunLoop pump (which can spin
or park on the CLI's unpumped command thread) with a bounded thread sleep
clamped to the remaining budget.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): drop stale swiped-row identity on structural refresh
A structural update invalidates the row identity captured at swipe start;
keeping editedItemID could defer a reload against a row that no longer
exists.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): align drop fixture with connection chrome
* fix: return validated restore argument
* test(ios): port drop tests to the status-line WorkspaceListTable API
Main's drop tests (from #8602) still passed connectionRecoveryFailed,
isRecoveringConnection, and retryConnectionRecovery, which this branch's
status-line rework removed from WorkspaceListTable; the package no longer
compiled on the merged tree.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): drop superseded relayPolicyRetrySchedule test
The cause-aware relayPolicyRetrySchedule(for:) API this test pinned was
replaced by the shared foreground reconnect-backoff ladder during the
connection-supervisor cross-merge (see the scheduleRelayPolicyRefresh
comment); the symbol exists nowhere, so cmuxFeatureTests did not compile.
The fast-auth-retry concern lives in the ladder's own coverage.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): drop superseded relay schedule assertion
* test(ios): identify inherited group menu actions
* test(ios): lock group menu action order
* test(iroh): expose truncated registration discovery
* fix(iroh): distrust truncated registration discovery
* test(connectivity): expose truncated sync snapshots
* fix(connectivity): prove complete sync snapshots
* test(connectivity): expose discovery revision races
* fix(connectivity): snapshot routes atomically
* test(ios): expose discovery blocking saved reconnect
* fix(ios): prioritize saved routes during recovery
* test(connectivity): expose endpoint recovery race
* fix(connectivity): await endpoint recovery before dialing
* fix(connectivity): fail closed on offline auth fallback
* Harden mobile group actions and reconnect readiness
* Include mobile debug registry source
* Fix group rename alert target lifetime
* Address workspace merge policy findings
* Scope reconnect policy to owning view
* Fix SSH retry test diagnostic compilation
* Align host refresh tests with auth recovery
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: cmux reload-cloud <[email protected]>
* Test isolated four-language SDK publishing
* Isolate and coordinate four SDK publishers
* Harden SDK release orchestration
* Make SDK publishing explicitly dispatched
* Enforce SDK release provenance
* Serialize coordinated SDK releases
* Test resumable SDK publishing
* Make SDK releases safely resumable
* Test ambiguous registry publish recovery
* Reconcile ambiguous registry publishes
* Test fully reproducible SDK preflights
* Complete reproducible SDK preflights
* Test SDK publisher security boundaries
* Secure reproducible SDK publishing
* Test usable registry release state
* Require usable registry release state
* Test pre-tag registry and Go gates
* Gate SDK tags on consumable releases
* Test final SDK release race guards
* Close final SDK release race windows
* Test SDK bootstrap and propagation recovery
* Make SDK bootstrap and propagation resilient
* Test release bootstrap and public Go verification
* Fail closed before coordinated SDK releases
* Run SDK surface gate after main validation
* Test Go probe polling without pipe reuse
* Poll Go verification without pipe reuse
* Test attested PyPI project bootstrap
* Reserve PyPI SDK name before release tags
* Test non-UTF-8 Go probe output
* Decode Go probe output defensively
* Test SDK registry ownership gates
* Require SDK registry ownership before tags
* Test registry ownership and monotonic recovery
* Reconcile registry ownership and release history
* Test publisher identity and reproducible recovery
* Test reproducible Python source archives
* Bind publisher identity and reproduce SDK artifacts
* Test registry error privacy and recovery placement
* Sanitize registry transport failures
* Test monotonic and attested release recovery
* Enforce monotonic attested release recovery
* Test current provenance and post-publish reconciliation
* Verify registry state after every publish
* Test prerelease recovery and registry index skew
* Recover prerelease and index propagation safely
* Test external SDK release authority
* Gate SDK release authority outside branch workflows
* Test repository-dispatched npm provenance
* Verify repository-dispatched npm attestations
* Test approval-fresh commit-bound release checks
* Revalidate release authority at tag creation
* Test least-exposure release credentials
* Limit SDK tag credentials to the atomic push
* Test credential-locked SDK bootstraps
* Harden SDK registry bootstraps
* Test isolated release authority and convergence
* Isolate SDK release credentials
* Test fresh recoverable SDK tag retries
* Make SDK tag retries fresh and recoverable
* Test isolated registry bootstrap credentials
* Isolate registry bootstrap credentials
* Test registry recovery identity binding
* Bind registry recovery to publisher identity
* Test publishing tool cancellation and Python pinning
* Harden publishing tool runtime behavior
* Test bounded registry publisher execution
* Bound registry publisher subprocesses
* Test multi-entry npm integrity metadata
* Verify multi-entry npm integrity metadata
* Test tag recovery after main advances
* Recover tag push after main advances
* Test rerun snapshot tag normalization
* Normalize rerun release tag snapshots
* Test crates.io access policy compliance
* Honor crates.io data access policy
* Test cross-process crates.io pacing
* Pace crates checks between processes
* Test PyPI bootstrap source revalidation
* Revalidate PyPI bootstrap source
* Test published SDK source identity
* Bind published SDKs to typed source
* Test multi-entry npm provenance SRI
* Accept multi-entry npm integrity metadata
* Test publisher artifact identity binding
* Bind publishers to validated artifacts
* Scope release artifacts to workflow attempts
* Test release artifact rerun identity
* Bind reruns to attempt artifacts
* Test publisher authority revalidation
* Revalidate publisher registry authority
* Test publisher verifier isolation
* Isolate PyPI publisher authority checks
* Route SDK jobs through runner controls
* Test npm provenance runner isolation
* Keep npm provenance on GitHub runner
* Add failing test that cmux ssh startup scripts parse under /bin/sh
Regression coverage for #9423.
* Fix cmux ssh startup script syntax error in no-progress retry loop
The reusable foreground-auth + SSH PTY attach path passed a compound 'if'
as the no-progress retry loop's attach command. That loop prefixes the
command with environment assignments, which POSIX only allows before a
simple command, so /bin/sh rejected the generated cmux-ssh-startup script
with 'syntax error near unexpected token then' and cmux ssh failed
immediately.
Wrap the attempt registration and the attach command in a shell function
and pass its name, matching SSHPTYAttachStartupCommandBuilder.
Fixes#9423
* Move #9423 regression coverage to Swift Testing
Cover the defect where it lives, in the shell generator, with a Swift
Testing case instead of an XCTest addition to the CLI integration suite.
Reverts the call-site-only workaround so the next commit fixes the
generator for every caller.
* Export the no-progress attach budget instead of prefixing the command
SSHPTYAttachExitCode.noProgressRetryLoopLines prefixed the caller's
command with environment assignments. POSIX only allows an assignment
prefix before a simple command, so the reusable foreground-auth attach
path, which passes a compound 'if ...; then ...; fi', generated a
cmux-ssh-startup script that /bin/sh rejected with 'syntax error near
unexpected token then'. cmux ssh failed immediately on 0.64.21.
Assign and export the budget on their own lines so any command shape is
legal and children still see the values.
Fixes#9423
* Add failing test for CLAUDE_SECURESTORAGE_CONFIG_DIR capture
Co-Authored-By: Claude Opus 5 <[email protected]>
* Allowlist CLAUDE_SECURESTORAGE_CONFIG_DIR in agent launch env capture
Co-Authored-By: Claude Opus 5 <[email protected]>
* Move Claude secure storage env tests to their own file
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add failing regression test for bash shim noclobber error
Repros https://github.com/manaflow-ai/cmux/issues/9356: with `set -o noclobber`,
the bash integration's second shim write prints "cannot overwrite existing file"
and leaves the shim stale.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Force-clobber cmux-owned generated files in bash/zsh shell integration
Under `set -o noclobber` the bash integration's per-surface CLI shim write
(`} >"$shim_path"`) is refused by the shell, printing
"cannot overwrite existing file" on every prompt and leaving the shim stale.
`2>/dev/null` cannot suppress it: the shell reports the redirect failure before
the compound command's stderr redirection applies.
Switch that write, and the remaining plain-`>` writes to cmux-owned generated
files in the bash integration (bg pid file, gh stderr capture, history temp
file, history-last marker) plus the zsh gh stderr capture, to the explicit
clobber operator `>|`, matching what the rest of both integrations already use.
Fixes#9356
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add failing tests: live sessions must survive equivalent route revisions
Two regressions captured from foreground telemetry on build 20260801001626:
1. equivalentRouteRevisionBumpKeepsTheLivePeerSession: a broker
connectivity sync that bumps the account route revision without
changing the peer's material route content (only last_seen_at moved)
tears down the live admitted session with runtimeReconfigured.
2. concurrentRedialCannotDisplaceAnInstalledLiveSession: two concurrent
connectedSession callers can both pass the installed-slot check across
the dead-on-arrival probe suspension, so the second install displaces
the first admitted session without closing it and records a second
established lifecycle event.
Both tests fail on current code; the fix lands in the next commit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Preserve live peer sessions across equivalent route revision bumps
The connectivity engine tore down every peer session whenever the
account route revision changed, even when the peer's route content was
identical. Broker registration heartbeats bump the revision while only
moving last_seen_at and path-hint freshness, so a foreground iOS client
lost its live control session every 10-90 seconds to a
runtimeReconfigured close followed by a full rediscover-dial-pair cycle.
The engine now derives CmxConnectivityRouteContent from each installed
snapshot: per-peer admission material (binding id, app instance, tag,
platform, identity generation, pairing flag, capabilities) plus
account-wide trust material (relay fleet, LAN rendezvous, grant
verification keys). On a revision change it invalidates only peers whose
material content differs. A changed endpoint identity keys the peer out
of the new content, a removed binding leaves it unrouted, and any
account-material change tears down all peers, so every security-relevant
change still invalidates. A missing baseline or a revision bump without
a replacement snapshot fails closed and keeps the old invalidate-all
behavior.
Also close the double-establish race in CmxConnectivityPeerSession: the
dead-on-arrival probe suspends the actor between clearing the pending
dial and installing it, so a concurrent caller could install its own
dial in that window and the late installer silently displaced the live
session while double-recording an established lifecycle. The installer
now rechecks the installed slot after the probe and adopts the winner,
closing its own redundant session.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Show signed-out state on app pricing page with in-app sign-in
When the embedded /app-pricing webview has no authenticated session, the
page used to claim "Current plan: Free" (misleading for signed-out Pro
users, invites duplicate purchase) and offered no way to sign in.
Now a banner at the top says the user is not signed in and links to
sign-in, the current-plan badge is suppressed while unauthenticated, and
the Free card CTA becomes Sign in. The sign-in link runs the existing
native-sign-in handler flow inside the webview, so Stack cookies land in
the webview session and /handler/after-sign-in hands tokens to the app
via its <scheme>://auth-callback URL. BrowserNavigationDelegate now opens
the app's own auth-callback scheme via NSWorkspace (user-activated
main-frame links only) since WKWebView cannot open native schemes.
* Scope auth-callback intercept to the app web origin and split it into its own file
Two review-driven fixes to the new native auth-callback intercept:
1. Security (Codex/Greptile P1): the intercept accepted a user-clicked
<scheme>://auth-callback link from ANY page in the embedded browser.
Because HostBrowserSignInFlow accepts stateless callbacks, a malicious
page could hand attacker-chosen tokens to the app and swap the
signed-in account on one click. The predicate now also requires the
navigation's SOURCE frame origin to match AuthEnvironment.appWebOrigin
(the origin serving /handler/after-sign-in), reusing the normalized
BrowserWebAuthnSecurityOrigin comparison. Links from any other origin
fall through to the regular external-navigation handling.
2. workflow-guard-tests: BrowserNavigationDelegate.swift grew +36 lines,
past the 25-line incidental allowance over its 635-line budget. The
predicate and router now live in a dedicated collaborator,
BrowserAuthCallbackNavigationPolicy, matching the delegate's existing
pattern of small policy objects; the delegate is back to +20.
* Pin auth-callback intercept to this build's own callback scheme
Structured-review P1: AuthCallbackRouter accepts the built-in cmux,
cmux-nightly, and cmux-dev schemes plus the extra one, and the trusted
/handler/after-sign-in page can legitimately emit any allowed scheme as
native_app_return_to. Stable cmux would therefore auto-open a
token-bearing cmux-nightly://auth-callback link, handing this session's
tokens to whatever app registered that scheme (attacker-registerable
when Nightly is absent). The predicate now requires the destination
scheme to equal AuthEnvironment.callbackScheme before NSWorkspace.open;
other schemes fall through to regular external-navigation handling.
* Fail-closed auth-callback dispositions and in-process delivery
Two structured-review P1s on the intercept:
1. Not fail-closed: a rejected cmux://auth-callback link fell through to
the generic external-app prompt, so an untrusted page's attacker-token
link could still reach the app after one confirming click, and a
crafted cmux-nightly link from the trusted page could reach that
scheme's handler. The policy now returns a disposition: user-activated
main-frame auth-callback-shaped links that fail the scheme/origin
checks are cancelled outright (.block). Non-link-activated navigations
keep the browser's regular handling, same as every other custom scheme.
2. Token egress through LaunchServices: NSWorkspace.open routes the
token-bearing URL to whatever app currently claims the scheme. Accepted
callbacks are now delivered in-process through the app delegate's
application(_:open:) entrypoint (the exact path LaunchServices would
invoke), so the URL never leaves this process.
The disposition handling lives in a BrowserNavigationDelegate extension in
the policy file, keeping the delegate at +6 lines over its budget base.
* Fail closed on every auth-callback-shaped navigation; return webview to pricing after delivery
Extends 6c71e6b91d on review findings:
1. disposition() now blocks ALL auth-callback-shaped navigations that are
not the exact trusted flow (user-activated main-frame link, own scheme,
trusted source origin). JS redirects and subframe navigations previously
passed through to the generic external-app prompt, where one confirming
click could hand attacker-chosen tokens to the stateless callback path.
2. The popup/new-window path (BrowserPanel.createWebViewWith) applies the
same rule via shouldBlockExternalNavigation: auth-callback-shaped URLs
from window.open never reach the external-app prompt.
3. After a delivered callback, the embedded flow no longer strands the
webview on the 'Signed in to cmux' page: /app-pricing passes
web_return_to on the after-sign-in URL and the navigation delegate
navigates the webview back to it (same-origin relative path only), so
the pricing page reloads with the authenticated session and shows the
restored plan. The switch-account flow preserves the param.
* Add signed-out pricing regression coverage
* Complete embedded pricing sign-in safely
* Fail closed on targetless auth callbacks
* Split auth callback disposition policy
* Add auth callback recovery regression tests
* Complete auth callbacks across browser surfaces
* Test: recovery triggers fired while inactive must wait for the foreground probe
A recovery trigger arriving while the iOS scene is inactive or mid-
backgrounding must not dial: the dial suspends with the process (field
traces on the reconnect incident showed ~9.5s stalls) and then competes
with the foreground recovery pass. Expect no probe until
resumeForegroundRefresh(), then exactly one.
Red on current main; the parking fix lands in the next commit.
Ports the regression from https://github.com/manaflow-ai/cmux/pull/9256
onto connectivity v2.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Park inactive-phase recovery triggers and replay them on foreground
Recovery triggers (network change, presence push, liveness, dead event
stream) that arrive while the iOS scene is inactive or mid-backgrounding
used to dial immediately. The dial suspends with the process (field
traces on the reconnect incident showed ~9.5s stalls) and later competes
with the foreground recovery pass. Park the trigger in
pendingInactiveRecoveryTrigger while foregroundRefreshIsActive is false
and replay the most recent one exactly once in resumeForegroundRefresh(),
after the foreground passes, so the replay coalesces into any attempt
they already started.
An explicit pairing connect and the account boundary clear the parked
trigger, matching how they supersede live recovery.
Green for the regression added in the previous commit. Ports the
inactive-parking piece of https://github.com/manaflow-ai/cmux/pull/9256
onto connectivity v2.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Classify connect-registry gate refusals as connectAttemptGated, not timedOut
When the connect-attempt registry refuses a dial because the exact route
already has a connect attempt in flight (.busy), the session threw
requestTimedOut. The refusal is instantaneous and never reached the
network, so diagnostics recorded fabricated sub-30ms "timedOut"
failures that poisoned lastFailureEvent and made exports look like the
network was timing out during recovery storms.
Add MobileShellConnectionError.connectAttemptGated with a dedicated
DiagnosticFailureKind.routeGated (raw value 25, append-only) and throw
it for the .busy gate. Callers keep their previous user-facing behavior
(retryable timeout category); only the diagnostic taxonomy and the
settings diagnostics rows distinguish the gate refusal. New localized
strings (en/ja) for the error and both diagnostics surfaces.
Single commit: the regression tests reference the new enum cases, so a
tests-first commit cannot compile against main. Tests:
- activeRouteAdmissionReportsRouteGatedInsteadOfTimedOut (RPC): a second
session on a route with an in-flight dial gets connectAttemptGated and
never allocates a transport.
- gatedDialRefusalsReportRouteGatedNotTimedOut (CMUXMobileCore): a gated
refusal surfaces as routeGated in lastFailureKind, never timedOut.
- Taxonomy raw-value and diagnosticFailureKind mapping expectations.
Ports the truth-telling piece of
https://github.com/manaflow-ai/cmux/pull/9256 onto connectivity v2.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Throttle unchanged-evidence presence-push recovery restarts to one per 45s
The iOS presence subscription delivers ~15s heartbeats about online
Macs. While the phone is disconnected, every heartbeat restarted
connection recovery through recoverFromPushedRouteBatch, so during a
persistent outage the phone kept abandoning its own in-flight dials on
the heartbeat cadence and each abandoned dial fed the connect-registry
gate (https://github.com/manaflow-ai/cmux/issues/9177).
Connectivity v2 did not absorb this: CmxConnectivityInvalidationSubscriber
and ConnectivityInvalidationSubscriberCoordinator replaced the Mac-side
PresenceNudgeSubscriber, while the phone-side presence path
(PresenceClient -> syncPushedRoutes -> recoverFromPushedRouteBatch ->
recoverMobileConnection(.presencePush)) survives unthrottled on main.
MobilePresencePushRecoveryThrottle passes changed evidence (new routes,
a Mac coming online) unconditionally and unchanged heartbeats at most
once per 45s, above the heartbeat cadence and a recovery pass's dial
budget, below the 30-60s automatic backoff ladder. Clock is injected
per call (runtime?.now()); a rewound wall clock re-admits instead of
freezing. Account boundary resets the throttle.
Single commit: the tests reference the new type, so a tests-first
commit cannot compile against main. Ports the throttle piece of
https://github.com/manaflow-ai/cmux/pull/9256 onto connectivity v2.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
PR 9265 landed restoreCLIArgument with a guard statement plus a bare final
expression; Swift only allows implicit return in single-expression bodies, so
every app build on main fails with 'missing return in static method expected
to return String?'. CI is paused (workflow_dispatch only), which is how the
break reached main.
Co-authored-by: Claude Fable 5 <[email protected]>
5bf9595804 (#9265) left the final expression of a multi-statement String?
method without an explicit return; every target compiling this file fails,
which currently blocks all merge-gate runs.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing coverage for unbounded iOS iroh activation retries
A failing activation currently re-runs registration, discovery, and
relay-policy against the broker on every dial or preparation, with no
client-side spacing: field phones wedged in this loop issued broker
mutations every 2-10 seconds for 40+ hours. These tests pin the intended
bounds: a failed activation arms a client backoff visible as a
retryScheduled diagnostic no longer than the 30 s foreground cap, dials
inside the window stay broker-silent with the unchanged inactive error
shape, and a scenePhase-active transition clears the window immediately.
Tests-first commit: red until the client backoff lands.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bound iOS iroh client retries with one shared backoff policy
PR 9269 removed every server-side broker quota, so nothing bounded a
runaway client: wedged phones re-ran registration/discovery/relay-policy
every 2-10 s for 40+ hours, while single transient blips overshot the
other way (32-36 s retryScheduled naps, 33-65 s idle gaps) because the
default CmxIrohRetrySchedule is a host profile (30 s first retry, 1 h cap).
CmxIrohReconnectBackoff is the one shared, injectable ladder: decorrelated
jitter drawn from [floor, min(cap, previous*3)] with a 1 s floor and 30 s
foreground cap, seedable SplitMix64 for exact-schedule tests, reset() to
the floor, and server Retry-After honored as a bounded lower bound.
Wired without changing success paths: a failed broker-bound activation
arms the ladder (emits retryScheduled) and reconciles inside the window
skip broker work, cleared on scenePhase-active, network-path change,
account switch, and success; the relay-policy refresh loop draws its
failure delay from the same ladder; the client runtime builds its relay
credential coordinator with CmxIrohRetrySchedule.foregroundClient; and
CmxIrohBrokerBackpressureGate paces registration mutations to 2 s-spaced
slots via an injected sleep, so a wedged phone cannot exceed ~30
challenge/register attempts per minute even with no server rate limit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Unify connectivity v2 invalidation resubscribes onto the shared backoff
Connectivity v2 landed with its own private retry ladder inside
CmxConnectivityInvalidationSubscriber.run(): unjittered exponential
1,2,4...60 s with a hardcoded clock, no reset semantics, and a cap that
exceeds the 30 s foreground bound. The shared policy wins: failures now
draw decorrelated-jittered delays from the injected
CmxIrohReconnectBackoff (1 s floor, 30 s cap), a served stream resets the
ladder to its floor window, and the jittered draw spreads a fleet's
re-subscribes when a service deploy closes every socket at once. The
backoff and sleep are injectable with source-compatible defaults, and a
seeded twin-ladder test pins the exact schedule.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test: session snapshot mid-revalidation must classify transient
Every launch/foreground kicks a /users/me revalidation and
sessionTokenTransitionIsActive is true for its whole round trip.
authenticatedSessionSnapshot() throws .unauthorized for that window, which
the iroh broker token source treats as signed out, so endpoint activation
fails closed (endpointFailed authorizationFailed) on every app launch until
the revalidation completes. The same state is already classified
.networkError by accessToken(); the snapshot must match.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Classify transient token misses as connectivity, not authorization failure
Three-layer fix for the launch-time wedge where every iroh endpoint
activation failed closed (endpointFailed authorizationFailed) while a
foreground session revalidation owned the token store:
1. AuthCoordinator.authenticatedSessionSnapshot() now throws .networkError
while sessionTokenTransitionIsActive, matching accessToken()'s
classification. Every launch/foreground kicks a network /users/me
revalidation, and that window previously read as "signed out".
2. CmxIrohBrokerTokenSource.credentialPair is now throwing. A throw means
"cannot read a coherent pair right now" and the broker classifies it
.connectivity, so retry policies, verified-policy preservation, and the
cached offline-policy bootstrap all apply. nil still means definitively
signed out and fails closed with .missingAuthentication.
3. The iOS activation token source maps AuthError.unauthorized to nil
(fail closed) and rethrows every transient failure instead of collapsing
both into nil with try?.
Diagnosed from cmuxdiag exports on build 1.0.4 (20260731034828): three
consecutive relayPolicyRefreshFailed/endpointFailed(authorizationFailed)
within 10ms each (no network round trip) at launch, recovering only ~15s
later when the revalidation settled and the backoff retried.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Apply the same transient-token classification to the Mac host runtime
The Mac host's activation token source had the identical try? collapse:
a session revalidation window read as signed-out and tore the host
runtime down as unauthorized. Same mapping as iOS: unauthorized fails
closed with nil, transient failures rethrow and classify connectivity.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Serve the persisted token pair from the keychain before any connection attempt
authenticatedSessionSnapshot() queued behind launch restore and foreground
revalidation (network /users/me round trips bounded by the sessionRestore
timeout), then threw a retryable error for the transition window, so endpoint
activation waited out network latency plus a backoff nap to obtain tokens that
were sitting in the keychain the whole time.
Keychain reads are microseconds, so the snapshot now tries a stored fast path
first: read refresh + stored access (never network-refreshing), bracket with a
refresh re-read so a rotation crossing the window is detected, and pin
generation and account id across the reads. Backend calls send both tokens and
the server refreshes a stale access token itself, so the stored pair is
sufficient to dial with. The fast path declines (falls back to the full
bootstrap-awaiting path) on an auth-environment-switch launch, while a sign-in
exchange or sign-out capture owns the store, or when no complete pair exists;
the transient classification from the previous commits still covers those.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Propagate cancellation from the broker token read instead of connectivity
CodeRabbit finding on https://github.com/manaflow-ai/cmux/pull/9259: the
blanket transient mapping converted a caller's CancellationError into
.connectivity, letting retry and cached-policy fallbacks keep working on a
cancelled task. Cancellation now rethrows as CancellationError, with a
regression test pinning it.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: regression test for Forget swipe crash on Hidden Computers rows
Adds CMUX_UITEST_HIDDEN_COMPUTERS_PREVIEW (fixture Hidden Computers list
with production closure semantics) and a UI test that swipes a row, taps
Forget, and requires the app to survive with the confirmation dialog shown
and the row still listed. Fails on current main: the destructive-role swipe
button makes SwiftUI batch-delete the row while the model keeps it, which
is the UICollectionView item-count abort reported on TestFlight build
20260731052644 (iOS 27.0).
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: fix Forget swipe crash by dropping destructive role from confirm-first button
SwiftUI's list coordinator treats a destructive-role swipe button as "this
tap removes the row" and eagerly runs a collection-view batch delete. The
Forget tap only presents the confirmation dialog, so the model count never
changed and UIKit aborted with the invalid-item-count assertion. Keep the
red appearance with .tint(.red) and leave the dialog flow (whose own
destructive confirm does remove the row) untouched, matching
WorkspaceNavigationRow's confirm-first Delete. The context-menu Forget
keeps its destructive role: menus don't drive row removal.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
While a reconnect attempt has not been rejected, the last visible workspace
list and terminals stay accessible. The workspace list shows a caption status
line (spinner + Reconnecting… / Not Connected) under the computers picker,
like Mail's Checking for Mail…; the terminal keeps only the compact status
pill. The full-screen TerminalDisconnectedOverlay, the list's
Disconnected/Reconnecting status row for non-startup states, and the
connection status toasts are removed. The reauth banner (rejected
connection, Sign Out is the only fix) and the initial-restore status row
(Retry / Add Computer, possibly no cached content) remain. Input gating and
the pill's recovery folding, previously behind the Toasts beta flag, are now
unconditional; a Reconnect item appears in the picker menu while Not
Connected.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing tests: mac-scoped workspace mutations must survive attach-ticket expiry
The iOS workspace list's drag-and-drop and the +-button's New Workspace
Group item vanish ten minutes after pairing: both are gated on
allowsMacScopedWorkspaceMutations, which requires a current mac-scoped
attach ticket, and minted tickets default to a 600s TTL. The host already
treats Stack same-account auth as the sole authorization gate for every
other mobile verb; these tests pin the expected behavior that
workspace.move / workspace.group.* / create-in-group survive ticket
expiry on hosts that advertise workspace.mutations.account_auth.v1.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Authorize mac-scoped workspace mutations by Stack account, not ticket lifetime
The mobile data plane's design authority is the signed-in Stack account;
attach tickets are route discovery plus scope narrowing. Four verbs
(workspace.move, workspace.group.action, workspace.group.create, and
workspace.create with group_id) still hard-required a current attach
ticket, and minted tickets default to a 600s TTL, so iOS drag-and-drop
and the + button's New Workspace Group item silently disappeared ten
minutes after pairing (and never appeared for tokenless zero-touch
pairings).
Host: ticketAuthorizationResultIfNeeded no longer fails these verbs when
the attach token is missing, unknown, or expired; a token that maps to a
current stored ticket still narrows scope, so workspace-pinned tickets
remain rejected for Mac-wide mutations. Advertised as
workspace.mutations.account_auth.v1.
iOS: MobileShellWorkspaceMutationTicketPolicy mirrors the host: against
hosts advertising the capability, mutations stay allowed unless a
current workspace-scoped ticket narrows the connection; legacy hosts
keep the fail-closed behavior. Applied to the foreground gate, the
per-target mutation gate, and secondary-Mac handle capabilities.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix CmuxMobileShellUI test target compile: pass terminalFolderTapEnabled to Coordinator
The folder-tap change added a required terminalFolderTapEnabled parameter
to GhosttySurfaceRepresentable.Coordinator but the package test target is
not run by CI, so TerminalSurfaceMountOwnershipTests landed uncompilable.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: drop workspaces onto group headers natively and show end-of-group boundaries while dragging
Two drag-and-drop UX gaps in the workspace list: empty (anchor-only) and
collapsed groups had no drop slot at all, and the invisible 16pt
end-of-group footer made drops near a group's end ambiguous between
in-group and root placement.
Drop-into: dropSessionDidUpdate hit-tests the session location; a drag
hovering the vertical middle band of a group header row (8pt edge bands
still produce plain insertion gaps) returns
UITableViewDropProposal(.move, .insertIntoDestinationIndexPath), so
UIKit's native row highlight signals join-the-group. performDropWith maps
that to a join-at-end intent (groupID + nil beforeWorkspaceID, already
supported by MobileWorkspaceMovePolicy.applyingWorkspaceReorderToGroupEnd)
through the same optimistic-order + chained-send path grouped index moves
use, factored into one applyGroupedWorkspaceMove helper. Eligibility runs
through normalizedIntent, so anchors, unknown groups, and no-op joins
never highlight. The band decision lives in a pure
WorkspaceListDropProposalPolicy with unit tests.
Boundary signifier: the coordinator tracks drag-session lifetime
(dragSessionWillBegin/DidEnd) and reconfigures footer rows, which render
a 2pt separator capsule inside their unchanged 16pt slot only while a
drag is active — drop above the rule joins the group, below lands at
root.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Test the native drop-into-group flow through the real coordinator delegates
Drives dropSessionDidUpdate and performDropWith on a laid-out UITableView
with protocol-mocked UIDropSession/UITableViewDropCoordinator: middle-band
header hover proposes insert-into, edge bands keep insertion gaps,
ineligible joins fall back, a completed into-drop calls dropIntoGroup with
the native intoRowAt animation and no index move, a stale into proposal
without a recorded target never joins, and drag-session lifetime toggles
the footer boundary state. isDragSessionActive becomes private(set) for
the assertion.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Instrument the mobile workspace.move path end to end (DEBUG only)
Every decision point that could silently swallow a phone drag now logs:
coordinator drop rejection reasons, resolver no-intent, chain aborts,
client gates, send outcome (anchormux container log), and on the host the
requested params, every rejection, and the Bool each reorder actually
returned (cmuxDebugLog). A drop that reverts is now attributable from
either side's log instead of indistinguishable from success.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add failing tests: drops must survive nil sourceIndexPath
UIKit nils UITableViewDropItem.sourceIndexPath once the data source
applies any snapshot during the drag session. The footer-boundary
reconfigure does that on every drag, so every real phone drop arrived
with source=nil, failed the performDrop guard, and silently snapped
back (evidence: move.performDrop REJECTED ... source=nil in the device
log). Mocked drops always supplied a source index, which is how this
escaped.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Resolve drop source from dragged-item identity, not UIKit's snapshot-bound index path
UITableViewDropItem.sourceIndexPath goes nil the moment the data source
applies any snapshot mid-drag — the footer-boundary reconfigure does on
every drag session, so every real drop was silently cancelled and flew
back. Both drop branches now find the dragged WorkspaceListTableItem in
the current configuration items by identity, which stays valid across
mid-drag snapshot applies and live list updates.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Adopt main's WorkspaceListTable shape in the drop-test fixture
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): cover workspace drag transitions
* fix(ios): preserve native workspace drag transitions
* test(ios): stabilize workspace drag gesture
* test(ios): cover workspace drag transition matrix
* fix(ios): scope drag fixture data to preview view
* test(ios): update workspace drop fixture initializer
* test(ios): keep workspace drags above tab chrome
* test(ios): cover collapsed workspace drop snapshot
* fix(ios): settle collapsed workspace drops synchronously
* test(ios): cover workspace drop animation lifecycle
* fix(ios): complete workspace drop animations natively
* test(ios): require geometry targets for all drops
* fix(ios): unify workspace drop geometry transactions
* test(ios): require UIKit-owned drop completion
* fix(ios): let UIKit own workspace drop completion
* test(ios): require synchronous table drop batches
* fix(ios): coordinate workspace drops with table batches
* test(ios): distinguish workspace group drop boundaries
* fix(ios): identify each workspace group drop boundary
* refactor(ios): align workspace table apply diagnostics
* test(ios): preserve native drop ownership
* fix(ios): keep drag lifecycle under drop delegate
* test(ios): name legacy move-path invariant
* test: deduplicate host mutation authorization matrix
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
First unit-target compile of this file (gate run) rejected internal methods
whose signatures use the private AppStoredShortcut typealias.
Co-Authored-By: Claude Fable 5 <[email protected]>
check-test-determinism.py --strict flagged the post-close sleep; poll for
selection and close instead.
Co-Authored-By: Claude Fable 5 <[email protected]>
Replaces timer polling with demand-owned render/tick coalescing, binds transcript settlement to active turn ownership, and clears/replays previews on stream lifecycle changes.
* Move Sentry scrubbing layer to shared CmuxSentryTelemetry package
Co-Authored-By: Claude Fable 5 <[email protected]>
* Transport diagnostics core: DiagnosticLog tap, presentation, incident policy
DiagnosticLog gains a single settable event tap delivered on the drain task
(after ring retention, so selected-path dedup is respected and the hot-path
record() stays untouched). DiagnosticEventPresentation decodes events into
stable case names and per-code fields for telemetry sinks. Pure
TransportIncidentPolicy turns the failure stream into a bounded set of
reportable incidents: per-signature cooldown with coalesced counts, hourly
capture budget, sustained-streak outage escalation, and suppression of
attributable noise (cancelled/superseded churn, offline-while-unreachable,
idle timeout while backgrounded). pairFail now records the classified
DiagnosticFailureKind in its b slot so pairing failures group by cause.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bridge transport diagnostics into Sentry on iOS and macOS
TransportSentryReporter (CmuxSentryReporting) consumes the DiagnosticLog tap:
every retained event becomes a scrubbed breadcrumb and a budget-limited
structured log line, and failures that cross TransportIncidentPolicy's gates
become Sentry events fingerprinted by code/failure/transport signature with
the compact diagnostic ring export attached, so one issue carries the full
connection timeline that previously had to be pulled off the device by hand.
iOS gains the shared last-mile scrubber it was waiting on: beforeSend now
scrubs (in addition to the consent gate), beforeBreadcrumb and beforeSendLog
are installed, and enableLogs is on; swizzling and automatic network capture
stay off. macOS enables logs, scrubs them, and taps the Mac host's
hostDiagnosticLog with role macHost after SentrySDK.start.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Regenerate SwiftPM lockfiles for the CmuxSentryTelemetry dependency
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: tap admission floor, cooldown-on-drop bug, scrub arrays, single pairFail
The event tap now gates on an ingress admission sequence: installing an
observer while recorded events are still queued on the drain task no longer
delivers those pre-installation events (regression test records a 500-event
burst and installs the tap with no drain sync). A budget-dropped capture no
longer stamps lastCaptureTNanos, so a brand-new failure signature arriving
during budget exhaustion captures as soon as the window slides instead of
serving a phantom cooldown. The structured-log scrubber now handles
string-array attributes (previously bypassed) and writes back via
SentryLog.Attribute. One exhausted connect now records a single pairFail
carrying transport (a) and failure (b) instead of a pairFail+rpcFailed pair
that double-counted the outage streak; pairFail and routeUnavailable decode
their transport slot in presentation. The iOS workspace lockfile aligns
sentry-cocoa to 9.24.0, matching the package-local pins (fixes the SwiftPM
lockfile policy guard). Doc states coverage is policy-shaped, not per-event.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test: expose broken Pro fulfillment
* Fix Pro TestFlight purchase fulfillment
* Show current Pro benefits in welcome portal
* Avoid duplicate TestFlight invitations
* Fix Dock lifecycle module import
* Fix Dock snapshot type inference
* Make Dock resume policy return type explicit
* test: expose Pro welcome hydration failure
* Fix Pro welcome page hydration
* Add TestFlight link to Pro welcome
* test: catch Pro external assignment overlap
* Fix external TestFlight group handoff
* test: require TestFlight signup from Pro email
* Require Pro users to join TestFlight from email
* Use jovial Pro signup email copy
* Polish Pro welcome and isolate Founder email
* test: cover TestFlight invitation retry
* Retry TestFlight invitation after group assignment
* test: cover external TestFlight signing profile
* Select beta profile for external TestFlight overrides
* Test Pro welcome HTML escaping
* test: reproduce Pro TestFlight browser routing
* Fix authenticated Pro browser handoff
* Fix responsive dashboard navigation
* Fix repeat Stripe dev stack starts
* test: reproduce Pro handoff review findings
* Fix Pro handoff security and invite delivery
* test: preserve internal TestFlight job identity
* Fix external TestFlight profile selection
* Refine browser session handoff ownership
* test: cover handoff tokens and TestFlight identity
* Fix final Pro workflow review findings
* test: require localized Pro welcome surfaces
* Localize Pro welcome across web locales
* test: cover final Pro review regressions
* Fix final Pro workflow review findings
* test: isolate app handoff rate limit
* Fix WebKit store build import
* Remove unused Pro fulfillment identity
* Fix browser handoff task result inference
* test: cover browser handoff review gaps
* Fix browser handoff failure recovery
* Scope browser handoff cookie cleanup
* test: cover final Pro security regressions
* Fix final Pro security review findings
* test: cover final Pro merge blockers
* Fix final Pro merge blockers
* test: preserve legacy Pro tester email provenance
* fix: preserve legacy Pro tester email ownership
* test: cover final Pro session lifecycle gaps
* fix: close Pro session lifecycle gaps
* test: cover final Pro access safety gaps
* fix: close Pro access safety gaps
* test: cover final Pro handoff safety gaps
* fix: close final Pro handoff safety gaps
* test: cover lowercase handoff cookie headers
* fix: parse handoff cookies case insensitively
* perf: linearize TestFlight target matching
* test: cover final Pro review regressions
* fix: resolve final Pro review findings
* test: require personal Pro TestFlight copy
* fix: clarify personal Pro TestFlight access
* test: cover web fallback for Pro links
* fix: preserve clean Pro link fallbacks
* test: cover final Pro restoration regressions
* fix: keep transient Pro flows out of shared state
* Fix merged Subrouter env test fixtures
* test: remove Subrouter timing waits
* test: cover final Pro lifecycle races
* fix: close Pro lifecycle races
* test: follow centralized TestFlight variant output
* fix: resolve Pro review policy findings
* test: include symbols in TestFlight archive fixture
* test: cover final Pro auth lifecycle regressions
* fix: close Pro auth lifecycle gaps
* fix: type browser session cleanup task
* test: cover Pro concurrency and privacy regressions
* fix: isolate Pro external lifecycle work
* ci: route TUI inventory through runner variables
* test: observe Python stream disconnect races
* Fix duplicate sign-out transition notification
* test: cover final Pro lifecycle races
* Fix final Pro lifecycle races
* Use configured Stack app for Pro reconciliation
* Update pricing tests for metadata mutation lease
* Update pricing tests for metadata mutation lease
* fix: satisfy final Pro concurrency review
* Fail closed on unknown TestFlight lanes
* test: allow cold social card rendering
* test: capture docs search before Next build
* fix: build docs search before Next assets
* test: remove handoff registry timing dependency
* test: cover dashboard auth suspension
* fix: suspend dashboard auth provider
* refactor: isolate Pro handoff types and tests
* test: cover Dock handoff and Pro metadata
* fix: close final Pro handoff review gaps
* test: cover shared Pro handoff placement
* fix: centralize Pro handoff placement
* refactor: isolate app-link placement policy
* refactor: inject app-link placement policy
* test: cover Pro plan reconciliation contention
* fix: defer contended Pro metadata reconciliation
* test: cover final Pro admission regressions
* fix(web): keep metadata errors provider-neutral
* fix: restore Pro admission and mobile nav state
* test(web): isolate Pro welcome locale mocks
* test: import restored session auth models
* test: qualify restored-session auth types
PR 9269 removed every server-side broker quota, so nothing bounded a
runaway client: wedged phones re-ran registration/discovery/relay-policy
every 2-10 s for 40+ hours, while single transient blips overshot the
other way (32-36 s retryScheduled naps, 33-65 s idle gaps) because the
default CmxIrohRetrySchedule is a host profile (30 s first retry, 1 h cap).
CmxIrohReconnectBackoff is the one shared, injectable ladder: decorrelated
jitter drawn from [floor, min(cap, previous*3)] with a 1 s floor and 30 s
foreground cap, seedable SplitMix64 for exact-schedule tests, reset() to
the floor, and server Retry-After honored as a bounded lower bound.
Wired without changing success paths: a failed broker-bound activation
arms the ladder (emits retryScheduled) and reconciles inside the window
skip broker work, cleared on scenePhase-active, network-path change,
account switch, and success; the relay-policy refresh loop draws its
failure delay from the same ladder; the client runtime builds its relay
credential coordinator with CmxIrohRetrySchedule.foregroundClient; and
CmxIrohBrokerBackpressureGate paces registration mutations to 2 s-spaced
slots via an injected sleep, so a wedged phone cannot exceed ~30
challenge/register attempts per minute even with no server rate limit.
Co-Authored-By: Claude Fable 5 <[email protected]>
A failing activation currently re-runs registration, discovery, and
relay-policy against the broker on every dial or preparation, with no
client-side spacing: field phones wedged in this loop issued broker
mutations every 2-10 seconds for 40+ hours. These tests pin the intended
bounds: a failed activation arms a client backoff visible as a
retryScheduled diagnostic no longer than the 30 s foreground cap, dials
inside the window stay broker-silent with the unchanged inactive error
shape, and a scenePhase-active transition clears the window immediately.
Tests-first commit: red until the client backoff lands.
Co-Authored-By: Claude Fable 5 <[email protected]>
The Ghostty goto_split:previous/next mirror in the shortcut dispatch now
yields to a bound Focus Back/Forward shortcut (matchConfiguredShortcut,
including shortcuts.when gating), so ⌘[ / ⌘] reach the focus-history branch
and drive the exact same TabManager.navigateBack()/navigateForward() path as
the titlebar arrow buttons: same history model, same closed-workspace
pruning, same enable conditions. Unbinding Focus Back/Forward hands the keys
back to the mirror, as the keyboard-shortcuts docs already promised.
Pane cycling stays available two ways: the Ghostty goto_split trigger on any
non-colliding key, and new cmux-owned rebindable actions focusPreviousPane /
focusNextPane (default unbound) that share the same cyclePaneFocus body, per
the shared-entrypoint policy. The window key-equivalent fallback route gets
the same yield so both dispatch layers agree.
The new actions follow the full shortcut policy: KeyboardShortcutSettings +
CmuxSettings ShortcutAction (defaults, display names, panes group), Settings
recorder rows, cmux.json shortcuts.bindings support, schema enum, web
keyboard-shortcuts page (en+ja), and the shortcut-actions reference. Labels
localized in Localizable.xcstrings for all catalog languages.
Co-Authored-By: Claude Fable 5 <[email protected]>
Ghostty's macOS defaults bind goto_split:previous/next to cmd+[ / cmd+], the
same keys as Focus Back/Forward. The shortcut dispatch mirrors those triggers
to cycle pane focus and checks the mirror before the focus-history branch, so
the keys cycle panes inside the current workspace (or do nothing) while the
titlebar arrow buttons navigate across workspaces.
Coverage added ahead of the fix so CI shows red then green:
- cmuxTests/FocusHistoryBracketShortcutRoutingTests: dispatches real ⌘[ / ⌘]
events through debugHandleCustomShortcut with the Ghostty mirror installed
via a new DEBUG seam; expects workspace focus-history navigation.
- cmuxUITests/FocusHistoryShortcutUITests: end-to-end over the control socket
(simulate_shortcut uses the same matcher as the app-level monitor); walks
back/forward across three workspaces and checks closed-workspace skipping.
- tests_v2/test_focus_history_shortcut_cross_workspace.py: local socket
verification against a tagged build.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add regression test for restore-time same-directory title cloning
On relaunch, session restore must keep each workspace's own custom
title and color even when several workspaces share one working
directory. Today the per-directory sticky customization record is
reapplied to every same-directory workspace during restore, stamping
one workspace's rename over all of its siblings.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Stop cloning one directory's sticky identity onto restored workspaces
On relaunch, session restore reapplied the per-directory sticky
customization record over every same-directory workspace's own
snapshot identity, so after a restart most workspaces sharing a cwd
were renamed to whichever title the record held last. The reconcile
pass even seeds the record from the first restored workspace and
stamps it onto the rest within a single restore, so the clobber needs
no prior rename history.
Delete WorkspaceDirectoryCustomizationStore and its track/record/
reconcile wiring. Identity is per-workspace only: session snapshots
already persist and restore each workspace's own customTitle and
customColor keyed by the workspace itself, and closed-workspace reopen
keeps working from its own snapshot. The addWorkspace creation mode is
replaced by applyCreationTitleAsCustomTitle, since gating the explicit
creation title is its only remaining job.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
PR #9236 made upload-testflight.sh refuse archives without dSYM bundles
and final IPAs without Symbols/*.symbols, but did not update the
App Store lane identity guard's fake xcodebuild and archive fixtures.
Since then workflow-guard-tests fails for every PR gate run, which also
short-circuits the downstream required checks.
Give the fake archive a dSYMs/cmux.app.dSYM bundle and the fake export
a Symbols/cmux.symbols entry inside the IPA so the guard exercises the
new gates instead of tripping them.
Co-authored-by: Claude Fable 5 <[email protected]>
Colleague phones have been locked out of registration for ~2 days by the
broker's own quotas: 6 challenges per device-instance per 10 minutes with a
600s penalty, retried faster than the window resets, forever. Per Aziz's
directive, remove every iroh quota and rate limit:
- challenge quotas (account 120/10m, device-instance 6/10m, outstanding 32)
- relay token quotas (endpoint 3/10m, endpoint 12/day, user 100/day)
- pair-grant hourly quota (60/h)
- the Vercel firewall rate-limit check on iroh routes (firewall.ts deleted)
- challengeQuotaForUser / developmentBindingQuotaAllowed config plumbing
Auth and correctness guards are untouched: challenge replay/supersede gates,
endpoint_already_bound, binding-slot ownership, discovery pagination bounds,
and the relay reservation-expiry sweep all remain. IrohQuotaExceededError
stays in the wire vocabulary and the 429 mapping stays in routeHandler for
compatibility.
Verified: bun run typecheck clean; iroh-route-handler/trust-broker/model-crypto
suites pass (91 tests). iroh-db-behavior quota tests removed with the quotas;
suite not run locally (docker daemon wedged) - it runs in the db test lane.
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: user-selectable Tailscale connection method with QR-authorized pairing
Adds an Auto-Connect vs Tailscale connection-method choice to iOS Settings
and the last onboarding page. Choosing Tailscale reorders dialing to put
authorized Tailscale routes ahead of the iroh pin (iroh stays as fallback)
and routes the user to the Mac's compatibility QR scanner.
A scanned/pasted v2 compatibility code becomes the authorization event: a
new .userAuthorizedTailscalePairing transport mode dials only the exact
host:port the user entered, only while the peer is unidentified, and only
from explicit in-app code entry (external URL opens never mint it). After
the Mac authenticates, a device-local 'user'-origin grant row persists so
reconnects use the existing evidence path. v9 schema adds grant origin;
migration-origin grants keep dying on iroh arrival, user-origin grants
survive because the user chose Tailscale deliberately.
Mac pairing window's legacy toggle is relabeled "Use Tailscale Pairing
Code" (EN+JA) to match the iOS copy.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Anchor user Tailscale pairing authorization on destination, not identity
The Mac pairing window's Tailscale code is the tokenless v1 compatibility
ticket, which carries a self-reported macDeviceID. Gating the user-entered
authorization on an empty ticket identity would reject exactly the code
users scan. The claimed identity adds no authority at first dial, so the
authorization now anchors on the exact user-entered host:port alone; the
in-app entry gate and the interface-bound route proof are unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Onboarding: Tailscale-selected connect page gets a matching title
The body and primary button already switch to the Tailscale flow; the title
kept claiming automatic connection. Title now reads "Connect over Tailscale"
(EN+JA) while the method is selected and the Mac is not yet connected.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Resolve actual row scope before persisting user Tailscale grants
The scoped-store decorators forwarded the selected team verbatim, but the
base store's grant write requires an exact existing row and silently no-ops
otherwise, so a Mac whose row still lives in the team-less fallback scope
would drop the user-entered grant. Mirror the sibling exact-instance writes
(visibleScope / setCustomizationUnlocked) in both decorators.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
MobileIrohDevelopmentFileEvidenceProbe references
MobileIrohRuntimeComposition.developmentStoreDirectory, which is defined
inside #if DEBUG. The struct itself was unguarded, so Release archives
(ios-testflight.yml) failed with 'has no member developmentStoreDirectory'
while Debug builds compiled fine. Its only call site is already inside
#if DEBUG (sameDeviceEvidenceProbe), so wrap the struct in #if DEBUG too.
Broken since 099e7eaaa8 picked up 6eeae1c619 (PR 8888); six consecutive
internal TestFlight uploads failed.
Co-authored-by: Claude Fable 5 <[email protected]>
* Ship dSYMs with iOS TestFlight builds and persist them as run artifacts
App Store Connect reported "No dSYM files available" for every TestFlight
build, so crashes (e.g. build 20260730090940 on dev.cmux.app.internal)
arrive as raw `cmux + offset` frames and the ephemeral CI runner discards
the only dSYM copy.
Root cause: the export options already set uploadSymbols=YES, and the
archive does contain dSYMs, but the manual-signing re-sign path re-zips the
IPA from Payload/ alone, dropping the Symbols/ directory the export put in
the IPA for ASC crash symbolication.
- Re-zip every Apple package directory the export produced (Payload,
Symbols, SwiftSupport, BCSymbolMaps when present).
- Fail closed before export when the archive has no dSYM bundles, and
before upload when the final IPA carries no Symbols/*.symbols.
- Persist the archive's dSYM bundle as a 30-day run artifact
ios-dsyms-<variant>-<build-number> for both internal and demo variants,
via a pinned CMUX_IOS_UPLOAD_DIR so the workflow can find the archive.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: persist dSYMs on upload success, avoid grep -q SIGPIPE, require dSYM dirs
- Gate the dSYM artifact on steps.upload.outcome instead of job success()
so a post-upload step failure cannot skip symbol persistence for a build
that already reached TestFlight.
- Drop grep -q in the Symbols/ check: under pipefail its early exit can
SIGPIPE zipinfo and fail a valid IPA.
- Require *.dSYM entries to be directories (bundle contract).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Match only top-level Symbols/*.symbols entries in the IPA gate
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Bump ghostty: fix sentry-init racing environ mutation during init
Pulls manaflow-ai/ghostty#174: three iOS SIGSEGVs on 2026-07-30 (INTERNAL
builds 20260730090940 and 20260730213932) were the sentry-init thread
walking the freed environ snapshot while ghostty_init's ensureLocale ran
setenv on the main thread during the first terminal-surface mount. The fix
runs ensureLocale before crash.init and resolves the Sentry cache dir on
the spawning thread, so the spawned thread never reads the shared environ.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Pin GhosttyKit checksum for sentry environ race fix
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test resume approval batching behavior
* add generalized resume approval state
* batch surface resume approval prompts
* Add resume approval batching regressions
* Fix resume approval batch review findings
* Add round-two resume approval regressions
* Fix round-two resume approval findings
* Add round-three resume approval regressions
* Fix round-three resume approval findings
* test: cover unsafe resume command expansions
* fix: harden resume approval persistence
* test: cover resume approval authorization gaps
* fix: scope resume approvals to safe local execution
* test: cover remaining resume approval gaps
* fix: close remaining resume approval gaps
* test: cover env-flag and trailing-arg approval generalization
Regressions for the round-four review findings: env -i / env -u / nested
env wrappers must not become the scoped command, and commands with
arguments after the session id (codex resume <id> --yolo) must not
generalize to a wider prefix scope.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: fail closed on env flags and trailing resume arguments
generalizedApprovalPrefix now rejects a command whose executable slot is
an option token or another env wrapper (env -i FOO=1 claude ... scoped
approval to bare 'env -i'), and only generalizes when the session id is
the sole unmatched token, so prefix matching can never re-authorize a
session launched with different trailing options.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
https://github.com/manaflow-ai/cmux/pull/9240 added /company-information
to the sitemap but not to agent-page-paths' englishOnlyPages and
agentReadablePages registries, so the sitemap-driven variant test fails
on main: resolveAgentPageVariant returns null for
/company-information.md|.txt. Register the page in both lists so the
Markdown and text variants resolve like the other legal pages.
Co-authored-by: Claude Fable 5 <[email protected]>
Xcode 26.3's Swift rejects a discarded function-typed result as an
error ('function is unused') even with @discardableResult, breaking
tests-build-and-lag and all app-host unit test shards on every branch
since https://github.com/manaflow-ai/cmux/pull/8298 added these two
presentAlert call sites. Companion to the warning-gate hotfix in
https://github.com/manaflow-ai/cmux/pull/9233, which covers
CLI/cmux.swift and FileExplorerView.swift but not BrowserPanel.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add regression test for Compose bottom-row placement
* Align Compose with the iOS search control
* Test Compose above the iOS search control
* Stack Compose above the iOS search control
* Use the native iOS tab accessory for Compose
* Restore standalone iOS Compose placement
The cmux skill covered windows, workspaces, panes, surfaces, focus, moves,
reorder, identify, and trigger-flash, but never mentioned `cmux
workspace-action` — the command behind the workspace context-menu actions
(set-color, set-description, rename, pin, mark-read, move-up/down, ...).
Because those actions live under `workspace-action` rather than as
`cmux workspace` subcommands, they were effectively undiscoverable from the
skill: an agent reading it (or exploring `cmux workspace --help`) would wrongly
conclude there was no CLI to color or describe a workspace.
Add a "Context-Menu Actions" section to references/windows-workspaces.md with
the full action/flag set and named-color list, plus Fast Start examples and a
reference-table hint in SKILL.md so it's found on first look.
* Enforce iPhone+simulator default for iOS verification with an offline install queue
iOS verification reloads now target BOTH an isolated per-tag simulator
(cmux-dev-<slug>, created on demand) and the configured iPhone
(CMUX_IPHONE_DEVICE_ID or ~/.config/cmux/iphone-device-id; never
hardcoded). When the phone is unreachable at build time, the signed
build is parked in a persistent queue (scripts/iphone-install-queue.sh,
under ~/Library/Application Support/cmux-dev/iphone-install-queue) and
a LaunchAgent (scripts/install-iphone-queue-agent.sh) auto-installs and
launches it within seconds of the phone reconnecting, via launchd IOKit
matching on Apple USB attach, WatchPaths on the queue, and a periodic
network backstop, then sends a cmux notification. Every phone build
hard-requires the same-tag Mac dev build: ios/scripts/reload.sh builds
the Mac tag first when missing and refuses phone-only otherwise.
scripts/ios-sim-install.sh installs cloud-built simulator apps into the
isolated simulator for the reload-cloud-ios path.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Probe device reachability through the queue script in ios/scripts/reload.sh
One probe implementation (iphone-install-queue.sh probe) now decides
"unreachable" for both the local and cloud reload paths, including the
CMUX_IPHONE_QUEUE_FORCE_UNREACHABLE test hook; select_device still owns
name/ambiguity resolution for reachable devices and its failure is
treated as unreachable as before.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review findings: name-target queueing, enqueue race, fail-closed sim install
A --device-name target no longer probes or queues against the DEFAULT
device id (queueing for a different phone than the one named would
install on the wrong device); name targets error with a hint to use
--device-id when unreachable. drain_entry now re-reads enqueued_at
before every terminal action so a re-enqueue during an in-flight drain
leaves the newer build queued instead of silently deleting or failing
it. ios-sim-install.sh fails closed on an unreadable
CFBundleIdentifier. Also: quote $tab expansions (SC2295), correct help
sed ranges, document the one-time LaunchAgent install in CLAUDE.md.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Nudge PR sync
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(ios): stamp workspace and notification rows with the pairing instance tag
Workspace and notification payloads carry no Mac identity; the phone
attributes rows to the connection they arrived on. That attribution now
includes the pairing's app-instance tag: foreground rows are stamped
with the active connection's tag in setForegroundWorkspaceState,
secondary rows with the subscription's proven tag, and notification
feed items with the pairing behind the feed target. Aggregated rows
carry macInstanceTag, per-pairing row ids include the tag so sibling
builds' workspaces cannot collide, and the feed item identity includes
the tag so sibling notifications never dedupe into one row. Works for
every existing Mac; no wire change needed.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(ios): aggregate workspaces and notifications per pairing
Sibling builds of one Mac are now separate aggregation targets: the
one-build-per-device coalesce is removed from secondary candidate
selection, the foreground exclusion is pairing-exact so the sibling of
the connected build stays a candidate, and subscriptions, per-Mac
workspace state, and notification-feed maps are keyed by pairing id
(legacy untagged pairings keep device keys). Promotion resolves the
exact pairing and tagged switch requests can take the promotion fast
path. Workspace mutations route by the row's pairing, opens and
notification taps switch to the row's exact build, workspace counts and
the machine filter match per build (legacy untagged rows keep matching
device-wide), avatar colors stay per physical device, and hiding a
pairing tears down exactly that pairing's subscription and feed.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test: cover sibling-build separation across aggregation, filter, and feed
Aggregation ordering now iterates aggregate KEYS (pairing ids since the
re-key) instead of state device ids, which returned duplicate device
ids for sibling builds and dropped their rows; sibling entries order
deterministically by instance tag.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): keep selection scope self-contained for tag comparison
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): address review findings on pairing-scoped feed routing
Notification taps compare the exact pairing so a sibling build's
notification on the foreground device still switches builds; the
aggregate feed status compares owner keys instead of device ids;
snapshot stamping derives the tag from the owner key itself so sibling
items never dedupe even without a live subscription (covered by a new
tagged-owner-key test); hiding the foreground pairing also drops its
device-keyed feed snapshot when a sibling stays visible.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Restore main's ghostty submodule pin
The merge-conflict resolutions staged the worktree's stale ghostty
gitlink via git add -A, silently reverting main's pin bump; this branch
carries no ghostty changes, so main's pin is authoritative.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): close autoreview findings on the pairing key-space migration
Secondary refresh validation now checks the subscription under its
pairing key instead of the device id, which was tearing down every
healthy tagged subscription on refresh. Device-only promotion requests
fail closed when sibling builds are both live instead of promoting an
arbitrary one. Tagged notification items never fall back to the bare
device key, so an offline pairing's mutation no-ops instead of hitting
a sibling with a colliding id. Hiding the foreground pairing also
removes its device-keyed workspace entry when a sibling stays visible.
Workspace-create gating uses the live connection's instance tag rather
than the stored isActive flag, which lags promotion. Notification feed
scoping preserves the selected build (entry-aware item matching), and
dismiss-outbox routing sends only through an unambiguous client for the
device, deferring while sibling builds are both live.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): close round-two review findings on pairing identity boundaries
Legacy untagged rows on the foreground device are excluded from
secondary aggregation (their pairing id is the foreground's own
aggregate key and would overwrite it). The picker's switch decision and
the workspace-groups gate compare the live foreground pairing instead
of the stored isActive flag, which lags promotion. Computers-screen
status lookups query the pairing key first so tagged secondaries keep
their connection dot. Notification availability matches the exact
selected pairing for every signal, and the alias-selection test asserts
the pairing-formed filter entries with sibling exclusion.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): close round-three findings on legacy identity and promotion
Secondary rows are stamped with the subscription's STORED pairing
identity so reconstructed owner keys always find their subscription,
including upgraded-legacy pairings that adopted a tag at auth time.
Device-only promotion requires the device to have a single stored
pairing, not merely a single live one, so a reconnect meant for an
offline sibling never promotes the other build. Exact pairing scopes
exclude unknown-tag rows (they stay under device entries and All
Computers). Promotion clears the promoted pairing's feed bookkeeping so
the foreground refetch under the device key cannot duplicate rows, and
the workspace-detail reconnect passes the row's tag.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): keep failure downgrades and retained-state pruning pairing-keyed
An unreachable sibling's establish failure marks its own pairing entry
unavailable instead of the device key (which can be the live foreground
sibling), and retained pairing-keyed workspace states with no live
subscription are pruned when no longer wanted so a pairing reconnected
as foreground via the dial path cannot duplicate its rows.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): dismiss routing requires a single stored sibling
Counting live clients was not enough: the emitting build may be offline
while a sibling is the sole live candidate, and Mac-local notification
ids can collide across builds. Device-scoped dismisses now route only
when the device has one stored pairing at all.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): reset foreground feed bookkeeping on sibling build switches
The foreground feed lives under the shared device key, so switching to
a sibling build left the previous build's snapshot and revision in
place and rejected the new build's lower revisions as stale. Both the
promotion and dial connect paths now clear the device-keyed feed state
when the foreground instance tag changes on the same device.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): close remaining round-four findings on feed and status identity
Notification-open navigation matches workspaces and surfaces by the
item's exact pairing so colliding Mac-local ids on a sibling build fail
closed instead of navigating to the wrong workspace. The connection
status rollup never overwrites an exact pairing entry and rolls the
foreground's device-keyed status only onto its own pairing
representative, so an offline sibling can no longer render green.
Co-Authored-By: Claude Fable 5 <[email protected]>
* WIP: typed MacPairingKey owner-key re-key (registry + composite core; not compiling yet)
* WIP: typed owner-key re-key compiles (composite, promotion, feed, hidden, actions)
* WIP: typed key test-target compiles; MacWorkspaceState.id pairing-unique
* WIP: pool suites 98/111; feed reset semantics reapplied; device-level drain admission
* WIP: pool+sibling suites converging; per-pairing candidate selection + drain-path replacement retirement
* Restore deeplink collision test hints eaten by bulk rewrite
* Fix review round 6: sibling promotion demotes previous focus by owner key, feed target ownerKey consistency, offline foreground key captured before identity clear, pairing-aware reconnect, exact-pairing retained-snapshot pruning
* Fix review round 7: exact-pairing reconnect decisions, sibling-ambiguity fail-closed deeplink lookups, fail-closed tagged create gate, feed completion by owner key
* Fix review round 8: openWorkspace routes by exact pairing, group/reorder gate requires exact foreground pairing, demoted-foreground feed re-keys to pairing
* Fix review round 9: foreground terminal lookups scope by live pairing; known-tag row resolution in list apply and create
* Fix review round 10: pairing-exact connected-refresh target, live-identity hide disconnect, tag-aware selection remap, allocation-free exact terminal lookup
* Fix review round 11: fresh-dial takeover clears pairing-keyed feed source; preparse machine scope entries for row projection
* Fix review round 12: foreground-scoped raw-input lookup with unowned-row fallback, exact-lookup no global fallback, ambiguous device-only switch fails closed, tagged secondary feed bootstrap by pairing id, hide authority requires proven live tag
* Fix review round 13: untagged selections match only untagged live foreground; recovery flags attribute to the exact recovering pairing
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 BEFORE
the forget classifies as a post-forget revival: the intent retires and
the stale record restores instead of being deleted.
Of the two sibling round-24 findings: the forget deadline race is fixed
in the same round (the throwing task group structurally awaits an
unresponsive cancelled child past the deadline; it lives in the iOS-only
cmuxFeature target with no host-runnable test), and the retained-teams
dictionary finding is factually incorrect — assigning a String? through
the subscript wraps it (Swift removes only when the assigned expression
is already the subscript's doubly-optional type), which the passing
restoreEchoTracksTheRetainedTeamlessRow regression proves — but the code
switches to updateValue(_:forKey:) to make the retention explicit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: millisecond forget boundary, non-blocking deadline, explicit retention
Round-24 review fixes:
- Tombstone stamps carry epoch MILLISECONDS with an explicit `ms` unit
marker in the encoding (bare-integer third fields from earlier builds
decode as whole seconds). Flooring to seconds classified a server
write from the same second but before the forget as a revival,
retiring the intent and restoring the stale record.
- The forget deadline no longer structurally awaits the losing racer: a
throwing task group waits for every child, so a revoke suspended on a
dependency that ignores cooperative cancellation kept the forget busy
past the deadline — the exact stalled-request case it exists to
recover from. Unstructured racers resolve a one-shot gate; the
deadline returns immediately, cancellation is still requested, and the
stalled work unwinds in the background.
- The restore's retained-row map uses updateValue(_:forKey:) so the
retention of a TEAM-LESS row is explicit rather than relying on
optional-wrapping subscript semantics (behavior unchanged — the
routed-delete regression already proved the entry was stored).
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: failing tests — round-25 review findings (bounded pair)
- One tagged instance's revival retires the whole DEVICE-WIDE tombstone,
dropping suppression and deletion for a stale different-tag record
that exists only in another team's backup.
- The account-wide parked record stores a nil local team, so offline
crash recovery replays only nil-team rows: a concrete-team row whose
local delete never landed survives every offline launch.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: exact revival retirement; parked records carry their row's team
Round-25 review fixes (the two bounded findings):
- A revival retires only its EXACT pairing's intent, and the revive-clear
mirrors it: one tagged instance returning no longer retires the
device-wide tombstone (or clears it on local re-pair), so a stale
different-tag record in another team's backup keeps its suppression
and still receives its delete. Per-record revival classification lets
the revived pairing through everywhere, so retaining the wildcard
intent costs the revival nothing; deletes explicitly spare records
every covering intent classifies as revived.
- Account-wide parked records preserve the captured ROW's local team, so
offline crash recovery replays the exact delete for concrete-team rows
(a nil local team replayed only nil-team rows). Coverage semantics are
unchanged — suppression and echo matching key on the pairing id alone,
and the revive-clear cancels the pairing's intents regardless of the
recorded team.
The two remaining round-25 findings are deferred with rationale in the
PR discussion: cross-clock revival ordering (a sound fix needs
server-issued causal revisions — a worker protocol change reintroducing
a form of server-side tombstones, which this codebase deliberately
retired; the strict boundary fails only in the recoverable direction)
and post-deadline task abandonment (every dependency in the revoke path
is URLSession-backed and cancellation-aware; the detached racer is
cancellation-requested and cannot outlive its own bounded requests).
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: widen developmentStoreDirectory to fileprivate for the evidence probe
The DEBUG same-device evidence probe struct lives at file scope in
MobileIrohRuntimeComposition.swift and cannot reach a type-scoped private
static. Caught by the on-device build; host-side SwiftPM tests do not
compile the iOS-only cmuxFeature target.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Drop committed review logs from the branch
Co-Authored-By: Claude Fable 5 <[email protected]>
* Restore main's ghostty submodule pin (theme picker fix from #9218)
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
* docs: iOS browser streaming design
* Add mobile browser streaming wire protocol
* Add Mac mobile browser stream host
* Fix stream session compile (nonisolated encoder init) and momentum end phase
* Fix keyCode method shadowing in SyntheticKeyEventFactory
* Add iOS browser stream surface package
* Add browser stream RPC client plumbing
* Wire browser streams through the mobile shell
* Integrate browser streams into mobile shell UI
* Beacon: detect canvas/WebGL painting via requestAnimationFrame wrap
* ci: reload-build gains an ios-simulator platform
Builds the unsigned simulator .app and uploads it as an artifact, for
callers whose local xcodebuild is unavailable; the sim bundle installs
directly via simctl.
* ci: build the ios-simulator app arm64-only
GhosttyKit's simulator slice is arm64-only, so the generic destination's
x86_64 half fails at link; every target simulator is arm64.
* Fix display link teardown for Swift 6 nonisolated deinit
* Fix frame stall via store-owned decode pipeline; move chrome to bottom floating bar
* Self-heal browser stream: force restart past dedupe on recovery, unanswered-input watchdog, keyboard-pinned bottom bar
* Add mobile browser dialog wire model and broker
* Mirror Mac browser dialogs over mobile RPC
* Render mirrored browser dialogs on iOS
* Wire mobile browser dialog Mac sources into Xcode project
* Capture owner explicitly in basic-auth startPrompt closure
* Stack browser dialog buttons vertically for 3+ or long labels
* Reserve bottom bar space so chrome never occludes streamed page content
* Take main's reconnect route-isolation test (recoveryTask removed by Iroh fix)
* Browser bar: always-visible standard controls, drop collapse pill + confusing X/chevron; stop stream on surface exit
* Add mobile browser viewport RPC DTOs
* Reflow Mac browser streams to phone viewport
* iOS: report phone viewport to reflow the streamed Mac browser
* Fix streamed browser white-out: force repaint after viewport reflow so idle pages don't capture a blank frame
* White-out fix v2: real two-frame scroll repaint nudge + settle-capture burst after reflow
* Replace iOS tab switcher surface
* Fix iOS switcher integration and verification
* Test persistent browser render host portal ownership
* Share persistent browser offscreen render hosting
* Capture mobile browser streams in persistent render host
* Fix switcher initial positioning and accessibility
* Test switcher reopening after browser selection
* Reset switcher state for each presentation
* iOS browser stream: mirror phone frames in the Mac pane instead of blanking it
While a browser pane streams to the phone, the live WKWebView renders in the
offscreen host at phone width, so the Mac pane went fully blank. Show a
read-only, letterboxed, click-through mirror of the exact frames the phone
receives (fed from the same capture in MobileBrowserStreamSession at the same
cadence), added to the pane's superview on stream start and removed on teardown
when the full-width live web view returns.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Speed up browser stream capture on the offscreen render host
Continuous JPEG frames were snapshotting with afterScreenUpdates:true, which
blocks each takeSnapshot on the host window's screen-update cycle. The stream's
offscreen render host lives off all screens at alpha ~0, where macOS throttles
that cycle hard, so capture was capped to a few fps: the phone showed "super
slow" streaming that barely moved on scroll.
Snapshot continuous JPEG frames with afterScreenUpdates:false instead. That
captures the currently committed render, which already reflects the new scroll
offset, without waiting on the throttled cycle; the dirty loop re-captures to
stay current. The rare lossless PNG settle frame keeps afterScreenUpdates:true
for a pixel-perfect rest state.
Add DEBUG per-capture instrumentation (capture ms, encode ms, byte size, pixel
size, unacked count) so stream throughput is measurable from the debug log and
capture-bound vs flow-controlled is distinguishable.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Revert "Reset switcher state for each presentation"
This reverts commit ed9d5f8b46.
* Revert "Test switcher reopening after browser selection"
This reverts commit 2bfebb3346.
* Revert "Fix switcher initial positioning and accessibility"
This reverts commit 607ef33924.
* Revert "Fix iOS switcher integration and verification"
This reverts commit 9cfb181750.
* Revert "Replace iOS tab switcher surface"
This reverts commit 89105d342d.
* Revert "ci: build the ios-simulator app arm64-only"
This reverts commit f5e9324940.
* Revert "ci: reload-build gains an ios-simulator platform"
This reverts commit 42b65b2300.
* Scope PR to browser streaming: drop switcher residue from title menu and string catalog
Co-Authored-By: Claude Fable 5 <[email protected]>
* Test replayed browser input requests a stream capture
* Keep the stream render host visible to WebKit: on-screen floating window, input-replay dirty, event-driven scroll beacon
The persistent render host window sat at (-100000,-100000); AppKit reports a
window with no on-screen portion as fully occluded, and WebKit suspends
requestAnimationFrame and degrades trusted-event hit testing for occluded
hosts. The rAF-throttled dirty beacon therefore never fired during a scroll
gesture (one frame per gesture, captured after gesture end) and replayed taps
intermittently hit a stale tree and never navigated.
Host window now anchors on-screen (bottom-trailing, >=64pt visible, .floating
so ordinary windows cannot occlude it) while staying imperceptible (1% alpha,
click-through, non-activating). Hardening: every replayed input batch marks
the session dirty directly, and the beacon posts scroll/wheel dirt from the
event listener with a 16ms throttle instead of waiting for a rAF tick.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bind the browser-stream keyboard button to real keyboard visibility
The button showed the input proxy's focus intent, so a keyboard raised by the
address field or a dialog's text field left it stuck on 'Show Keyboard'.
The glyph now binds to MobileKeyboardVisibilityObserver (UIKit keyboard
notifications); tapping while the keyboard is up resigns whichever responder
raised it (shared dismissMobileKeyboard, moved to CmuxMobileSupport) and
releases the proxy's focus reasons via the policy's new explicit hide, which
never flips into a focus request the way toggling would.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Give dialog text fields a visible input well
The dialog card is glass, so the fields' glass background vanished into it and
prompt/basic-auth inputs read as labels. Fields now sit in a filled rounded
well with a hairline border, the same fill language as the bottom bar's
address field.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
Bump the ghostty submodule to pick up the theme picker fix, and stop the regression test from misreporting the failure. The picker never rendered a frame, so the test never sent Enter, yet it reported that the picker did not exit after Enter.
* iOS: regression test for reconnect toast firing on view remount
Extract the reconnect-toast decision into MobileReconnectedToastGate,
faithfully preserving the current WorkspaceShellView semantics (toast on
any observed .connected once a connection has been held), and add tests
for the intended behavior: toast only on a genuine disconnected ->
connected transport transition. viewRemountRefireDoesNotToast fails on
this commit because the current semantics cannot distinguish a genuine
reconnect from SwiftUI re-firing onChange(initial: true) when the
observing tab content remounts. Also add CmuxMobileShellModel to the CI
Swift package test list (its suite resolves and passes standalone).
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: only toast Reconnected on genuine transport reconnects
Switching Notifications -> Workspaces showed "Reconnected to your Mac."
with the connection never dropping. The toast decision lived in an
onChange(of: store.connectionState, initial: true) inside the workspaces
tab's content, while its hasHeldConnection guard lived on the shell view:
every return to the tab remounts the content and re-fires the initial
onChange with the guard already primed, so a plain tab switch while
connected re-toasted.
MobileReconnectedToastGate now also requires a genuine disconnected ->
connected edge (previous != current), so the synthetic equal-value edge
from initial/remount calls can never toast, and the presenter is mounted
once at the always-mounted shell root (MobileReconnectedToastPresenter),
so transitions observed while the user sits on another tab still toast.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: UI test pins connection toasts on the Notifications tab
Covers the presenter mount-point regression: the mock host dies and is
revived on a fixed port while the Notifications tab is selected, and the
status capsule plus the "Reconnected to your Mac." toast must present
there. With the presenter mounted inside the workspaces tab (as before
this PR), it is out of the hierarchy on that tab and neither presents.
MobileSyncMockHostServer gains an optional fixed port with local
endpoint reuse so a revived listener can rebind the paired address.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: read the reconnect toast through the combined MobileToast label
ToastCardView combines its children into one accessibility element, so
the success message never appears as a descendant static text; wait on
the MobileToast element's label instead.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: harden the Notifications-tab toast test queries
Match the capsule by identifier across any element type (the failure
variant combines an action Button, so the combined element's type is not
stable), lengthen the loss-detection wait, and dump the accessibility
tree into the log when either wait times out.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: pair the toast lifecycle test manually so the dead Mac stays visible
The loopback debug attach drops the only visible Mac with its
workspaces when the host dies, and the workspace-list policy then
deliberately reads connected (no visible reconnect target), so no
capsule can present regardless of the presenter mount. Manual pairing
persists the Mac, keeping it visible through the outage like a real
pairing, which is the scenario the presenter serves. The revived host
keeps serving attach tickets for the redial's re-mint.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: open the pairing sheet from the toolbar when it is not auto-presented
The no-computers shell lands on the empty state without presenting the
Add Computer sheet, so the manual-pairing helper taps
MobileShowAddDeviceToolbarButton before waiting for the form.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: drop the Notifications-tab toast UI test; coverage moves to a follow-up
Four dispatch iterations showed the mock-host harness cannot produce a
visible connection-status transition deterministically: the loopback
debug attach drops the only visible Mac (the list policy then
deliberately reads connected), and even with persisted manual pairing
the recovery layer keeps the visible status untouched for the whole
test window, so no capsule presents regardless of the presenter mount
point. The mount fix stays verified by the tagged-build simulator runs
recorded on the PR; behavior-level coverage needs a harness that can
drive the recovery phases and is tracked in a follow-up issue.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Keep online Mac control connections warm
* Test warm multi-Mac role changes
* Show live Mac pool roles in iOS settings
* Test retaining prior Mac during full switch
* Retain control connection during full Mac switch
* Test coalescing multi-Mac retry backoff
* Coalesce control pool retry outages
* Fix multi-Mac pool lifecycle and focus boundaries
* Make multi-Mac focus handoffs transactional
* Keep pool membership and focus state scoped
* Scope pool retry ownership by Mac
* Enforce pool scope at focus handoff
* Fence control streams during focus promotion
* Validate control stream ownership and acknowledgements
* Fence initial control activation and anonymous adoption
* Bound pool retries and promotion freshness
* Fence pool promotion and retry ownership
* Repair recreated control subscriptions
* Close promotion and catch-up failure windows
* Fence control pool freshness races
* Sequence pool routes and focus repair
* Bound control pool and repair Stack auth
* Restore promoted stream recovery
* Fence cancelled Mac handoffs
* Close control pool lifecycle races
* Fence staged focus ownership
* Preserve legacy aggregate fallback
* Serialize pooled Mac refresh ownership
* Bound promotion and presence recovery
* Classify pooled refresh failures
* Fence pooled teardown and capacity
* Bound control refresh and role metadata
* Fence multi-Mac workspace freshness
* Close multi-Mac handoff races
* Fence pooled actor handoffs
* Fence terminal role transitions
* Preserve Mac authority across role changes
* Bound per-Mac presence reconciliation
* Drain coalesced route sync before aggregation
* Separate state-sync and legacy refresh events
* Serialize connection establishment and transport drain
* Drain abandoned connects across role fallback
* Retain same-peer reservations through full drain
* Hide retired clients behind drain reservations
* Close remaining multi-Mac transport ownership gaps
* Bound multi-Mac retry ownership and presence authority
* Track physical route cleanup debt explicitly
* Isolate peer cleanup and drain authority replacements
* Fail timed-out handoffs without cancelling cleanup
* Reconcile pooled retry and transport role races
* Bound global cleanup and control pool admission
* Make promotion and transport cleanup atomic
* Unify physical transport cleanup ownership
* Track cancellation close under route cleanup
* Join teardown and retry cleanup-blocked controls
* Reuse reserved Mac drains and validate identity first
* Test anonymous same-route foreground repair
* Sequence same-route foreground replacement
* Test manual same-route ticket reprobe
* Release same-route focus before ticket probe
* Test autoreview connection liveness findings
* Close autoreview connection liveness gaps
* Bound cleanup registration and feed catch-up
* Test targeted offline alias reconciliation
* Reconcile presence across physical Mac aliases
* Test physical alias ownership handoffs
* Handoff physical alias control ownership
* Align cleanup tests with physical route ownership
* Satisfy multimac autoreview findings
* Finish multimac policy cleanup
* Resolve final multimac review findings
* Exclude focused Mac physical aliases
* Preserve foreground during ticket probe failure
* Retire discarded focus before teardown
* Bound notification feed refresh retries
* Harden multi-Mac keepalive and alias recovery
* Bound feed recovery and canonicalize cleanup peers
* Preserve feed cooldown and normalize URL ports
* Retire stale physical Mac alias snapshots
* Prune deleted Mac aggregate snapshots
* Preserve pool state across store load failures
* Retry all transient paired store reads
* Canonicalize legacy IPv4 route aliases
* Preserve pooled Macs across authority read failures
Two verified findings from the structured review of 1472990921 (#9071):
1. Upgrade-path device identity rotation (iOS). resolveDurableDeviceID's
.absent branch deleted the legacy UserDefaults device-id mirror and minted
a fresh id. On an in-place upgrade from a pre-Keychain build the mirror IS
the id of the phone's active iroh binding and the endpoint identity
survives the upgrade, so registration targeted a new (user, device, tag)
slot while the endpoint still owned the old one -> endpoint_already_bound,
iroh disabled for every upgrading install. The mirror could not be adopted
blindly because encrypted backups restore UserDefaults onto different
hardware. Disambiguate with ThisDeviceOnly evidence: the iroh
endpoint-identity Keychain item (kSecAttrAccessibleAfterFirstUnlock-
ThisDeviceOnly, non-synchronizable) cannot cross hardware, so its presence
proves same-device continuation -> adopt the mirror via createOrAdopt;
absence -> mint as before; unreadable (locked) -> fail closed and defer,
mirroring the store's own .unavailable behavior. New SameDeviceEvidence
probe + full matrix tests.
2. Challenge ordering tie (web). The register gate rejects only strictly-older
challenges (createdAt < registeredAt) and createdAt is a millisecond wall
clock, so serialized mints could tie; a delayed older twin then passed the
gate and could clobber newer state. issueChallenge now assigns each
challenge a createdAt strictly above the slot's latest prior challenge
(mints serialize under the per-user advisory lock), making the strict gate
exact. Regression test covers the equal-millisecond reversal.
Verification: CmuxMobileShell DeviceRegistry suites 41/41 (full suite has 2
pre-existing failures on main, unrelated: terminalReplay/staleReplay); web
typecheck clean; iroh-route-handler + trust-broker suites 41/41; the new db
regression test runs under CMUX_DB_TEST=1 in CI.
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: keep Mac discovery alive through onboarding so the connect page is ready on arrival
During first-run onboarding, automatic same-account Mac discovery ran once at
an auth edge (the root startup one-shot) and then not again until the final
connect page appeared, so the page opened into a fresh multi-second search.
Add OnboardingMacDiscoveryKeepAlive, owned by CMUXMobileRootView as @State:
while onboarding's pre-connect pages are visible and the user is Stack-
authenticated but unconnected, it re-runs the full stored-Mac reconnect pass
(backup refresh + registry + zero-touch discovery + dial) with a growing
delay (4s up to 15s). Attempts claim the shared
MobileStartupConnectionCoordinator, so they serialize with the startup
one-shot and injected-attach launches, and they use
reconnectActiveMacIfAvailable so automatic iroh backoff is respected.
Lifecycle: hard-cancels on sign-out or account/team change (and restarts
under the new scope), gracefully stops re-arming without killing an in-flight
dial when the connect page takes over, the app connects, or the app
backgrounds. The loop also pulls a live eligibility check before every
attempt and re-arm, so a dropped SwiftUI onChange push can never leave it
searching after the page took over.
Sim-verified: keep-alive connects a Mac that comes online mid-tour ~12s
later, and the connect page renders "Your Mac is connected" in under 0.5s
of arrival instead of starting a fresh search.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Re-key onboarding discovery when the user ID changes without an auth edge
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
CLAUDE.md told every agent to launch a background $autoreview loop at
handoff, which made routine PRs run multi-round review loops and slow
sessions badly. Review agents are now explicit user opt-in; agents let
required checks and the automatic review bots run asynchronously and
address only concrete failures and actionable findings before merge.
Co-authored-by: Claude Fable 5 <[email protected]>
* Guard debug divider-routing log against non-finite event coordinates
The DEBUG-only left-mouse-down diagnostics monitor in
SidebarDividerTrackingView formats the event's window x coordinate with
Int(_:), which traps when the coordinate is NaN or infinite. On macOS
26.5 AppKit can deliver such events, so any Debug build crashes with
'Double value cannot be converted to Int because it is either infinite
or NaN' the moment one arrives (EXC_BREAKPOINT in
installDiagnosticsIfNeeded, observed reproducibly on macOS 26.5.2).
Render non-finite coordinates as 'non-finite' instead of trapping.
No regression test: the trap lives in a DEBUG-only NSEvent local-monitor
closure with no runtime seam to inject a synthetic NSEvent carrying a
NaN location; per the test-quality policy this ships without a fake
source-shape test.
* Format the divider-routing coordinate without any trapping conversion
Review follow-up: isFinite still lets a finite value beyond Int's range
trap. %.0f formats any Double safely.
* Add frame pacing to the workspace-list scroll probe and a timestamps live-update fixture mode
The DEBUG scroll-metrics probe now records display-link frame pacing
during its sweep (hitch frames, hitch ms/s, max frame ms) so workspace
list scroll work is quantifiable before and after changes. The layout
preview fixture gains CMUX_UITEST_WORKSPACE_LIST_PREVIEW_LIVE_UPDATES=timestamps,
which restamps previewAt/lastActivityAt sub-minute without visible
changes - the exact update shape the Mac emits while agents stream.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Stop live workspace-list updates from re-running the diffable apply and re-rendering unchanged rows
Two measured main-thread costs ran on every workspace-list emission
while agents stream (the iOS workspace-list scroll stutter):
1. Any workspace field delta reconfigured the row, but the Mac restamps
preview_at/last_activity_at from the latest notification on every
emission while the row renders that time at minute granularity.
Reconfigure now decides by render equivalence: full struct equality
(fail-closed for future fields) with same-minute timestamps
normalized out.
2. Payload-only updates rode NSDiffableDataSourceSnapshot.apply, which
runs the diffable apply queue plus UITableView's whole batch-update
pass per tick (~1.3ms on an M-series simulator, more on device) with
nothing to diff. When no changed row's height key moved, the visible
changed cells are now re-configured in place and offscreen rows pick
up the payload on dequeue; height-changing payloads keep the
snapshot path so UITableView re-queries heights.
30s fixture window, 400 rows, updates every 80ms, M-series simulator:
timestamp-only churn 3.27s -> 2.59s CPU, visible churn 3.33s -> 3.00s;
the diffable-apply subtree disappears from the sample profile. Sweep
invariants hold: 0 contentSize corrections, 1 distinct draw height.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: isolate the DEBUG route probe, per-frame hitch budgets, in-bucket fixture restamps
The apply-route test hook moves out of the production coordinator into a
dedicated DEBUG file (extension + registry) with #if DEBUG call sites, so
Release builds also stop allocating the changed-id array. The scroll
probe judges each sweep frame against the expected interval captured at
the callback that started it instead of the median, so a mid-sweep
refresh-rate change is not misclassified as a hitch. The timestamps
fixture mode restamps relative to each row's own clock so the first tick
no longer jumps seeded hours-old timestamps across a rendered minute.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review round 2: in-bucket fixture restamps, weak-key route probe storage
The timestamps fixture bump now wraps to the start of the row's current
minute instead of crossing into the next one, so every tick honors the
mode's zero-work contract. The DEBUG route registry keys weakly through
NSMapTable so entries die with their coordinator and a reused address
cannot return a predecessor's route.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test(ios): cover verified replay viewport restore math
* fix(ios): preserve viewport across verified replay
* test(ios): expose verified replay anchor leak
Reproduce the len 54 capture / len 53 restore cycle that makes targetTop climb 3990, 3991, ... while preRowsFromBottom counts down on each replay.
* fix(ios): anchor verified replay from viewport top
Exclude flickering visible-row counts from the stored anchor so repeated verified replays preserve the captured top row.
* Fix verified replay viewport operation races
* fix(ios): close replay viewport restore race
* fix(ios): fence restored replay viewport before reveal
Re-arm the ready fence after presenting the restored viewport and hold the interaction clock through the revision-matched scroll call.
No new test: the presentation path has no pure seam, and the atomic interaction gate is race-free by construction.
* fix(ios): gate replay viewport restoration
Label viewport anchors from the queued Ghostty snapshot and claim restore tickets without holding the gate across renderer calls. Invalidate queued restores when deadlines or recovery resume their continuations.
No new tests: the gate has no pure seam; the existing verified replay viewport suite covers the compiled behavior.
* fix(ios): serialize replay viewport scrolling
* fix(ios): coalesce applied viewport scrolls
* Add multi-selection sidebar drag blocks
* Fix block drop gap resolution and group-boundary membership
The drop plan's targetIndex is removal-adjusted for the dragged row
(SidebarDropPlanner.resolvedTargetIndex), so the block reference row must
be resolved in the row space without that row; resolving against the full
order landed the block one row early whenever the grab row sat above the
drop gap and could never express the bottom gap. Past-the-end targets now
append.
Ambiguous group boundaries (one grouped neighbor, one not) now preserve
each member's membership like the single-drag inference instead of
stripping the whole block to top-level, and anchors never receive
membership writes.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add failing within-group block drop tests
The accepted-no-op case is red: dropping an adjacent selected block at
its own boundary gap inside a group resolves to no movement, and the
block API reports refusal instead of a handled drop, so the AppKit
table animates a snap-back (the sidebar.drop.perform performed=0 repro).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Report handled no-op for block drops resolving to their own position
A block dropped at a gap that resolves to no movement (typical inside a
group section: the painted gap is the adjacent block's own boundary)
went through the batch machinery, changed nothing, and returned false.
The AppKit table treated that as a rejected drop and animated a
snap-back, so within-group multi-drags read as broken. The single-drag
path already returns true for from == to; the block path now does the
same, publishing an order change only when order or membership actually
changed.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Support multi-select group header drags
Keep sidebar selections kind-exclusive through a shared foundation policy. Expand selected anchors into tier-aware top-level blocks so whole group sections reorder together without changing membership, including handled no-op drops.
* Address review: legacy drop path moves blocks, headers clear on toggle
Route the legacy SwiftUI sidebar drop through SidebarWorkspaceDragBlockResolver
so multi-selection drags move the whole block on that surface too, matching the
painted-plan path. Let modifier-clicking the last selected group header clear
the selection instead of pinning it to the clicked anchor, and assert the
accepted in-group no-op emits no order-change publication.
* Give header clicks a single selection owner
The AppKit group header cell installed its own click recognizer that
called onFocusAnchor while the table view's action already routes the
same click through didClickTableRow. Two invocations per click cancel a
modifier-click toggle (add then remove), so header multi-selection never
accumulated. Remove the cell recognizer; the table action is the sole
selection owner, matching workspace rows.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add notification-feed scroll-perf stress fixture
CMUX_UITEST_NOTIFICATION_FEED_PREVIEW_COUNT=<n> seeds the DEBUG preview
harness with n deterministic synthetic items (day spread, three Macs,
mixed read/connection/body variants).
CMUX_UITEST_NOTIFICATION_FEED_PREVIEW_AUTOSCROLL=1 drives one animated
scroll pass down and back up, hopping ten rows per step, bracketed by
OSSignposter intervals for Instruments comparison.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add frame-pacing monitor to feed scroll stress driver
A CADisplayLink tick monitor (stress-harness-only, env-gated DEBUG code)
counts frames arriving 1.5x past the frame interval and logs
rows/frames/hitches/hitchTotalMs/worstHitchMs at the end of the run, so
baseline and fix builds compare on hitch metrics without Instruments.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Build feed row strings off-main and skip no-op section publishes
NotificationFeedRowPresentation moves out of the row body into
NotificationFeedRowModel, built per item inside the projection's
detached rebuild: string trimming, case/diacritic folding, localization
lookups, and the accessibility value (including its relative-date
format) no longer run on the main thread per row materialization.
Row equality still compares the item alone, so diffs stay cheap.
NotificationFeedProjection now publishes sections only when the rebuilt
output differs, so redundant source recomputes (per-Mac connection
churn producing identical items) no longer force the List to re-diff
every row.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Window the mounted notification feed rows with progressive reveal
Profiling 2,000 mounted rows showed cell self-sizing and far-jump
layout resolution dominating the main thread (34s cumulative hitch
time over a 66s scripted scroll, worst stall 2.7s). The projection now
mounts the newest 300 filtered rows and appends 300 more whenever the
load-more sentinel row becomes visible, so initial publish, whole-list
diffs, and scroll-to-top layout spans stay proportional to what the
user can reach instead of the full 2,000-item retained history.
Feed refreshes preserve the extended window (background updates never
collapse scroll depth); filter and search changes reset it. Also drops
the unread indicator's invisible Color.clear overlay, which cost a
layout node in every cell sizing pass, and teaches the stress driver
to follow window growth.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Collapse feed row icon lines into single interpolated Texts
Cell self-sizing dominated the remaining scroll cost (StackLayout
arithmetic and per-cell SF Symbol image nodes). The headline icon,
workspace line, and computer line now render as one interpolated Text
each instead of HStack{Image, Text} pairs, roughly halving the layout
nodes each materializing cell measures (twice, via the provenance
ViewThatFits trials). The row ignores child accessibility, so the
interpolated symbols never reach VoiceOver.
Stress run (2,000 rows, scripted fling, same sim): worst frame stall
2,696ms -> 298ms, cumulative hitch time 34.1s -> 22.6s, frames
delivered +27% vs baseline.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: monitor defer-stop, single pending extension, render-time a11y date
The stress driver's frame monitor now stops via defer so task
cancellation mid-pass cannot leak the display link. extendRowWindow
accepts one extension per publish (hasMoreRows only flips after the
rebuild lands, so repeated sentinel appearances stacked increments).
The precomputed accessibility details no longer bake in the relative
date; the row appends a render-time date so VoiceOver never reads a
timestamp frozen at model-build time.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use localized interpolation instead of String(format:) in row rebuild
NotificationFeedRowPresentation runs per row inside the detached
whole-window rebuild; C-varargs String(format:) is banned in concurrent
hot paths (the PR 5347 regression class flagged by autoreview). The
catalog values keep their positional placeholders and interpolation
arguments bind in order, so rendered strings are unchanged in both
locales.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address policy review: one type per file, file-scope helpers
NotificationFeedRowPresentation moves to its own file with its pure
helpers as file-scope private funcs (matching the projection file's
convention), NotificationFeedLoadMoreRow and the stress harness's
frame monitor move to their own files.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
App Store Connect's per-app upload quota was exhausted by per-merge
internal uploads. cmux INTERNAL now uploads hourly and cmux DEMO every
12 hours, both from current main. Scheduled runs skip when the variant
already shipped the current head or when the delta touches no
iOS-relevant paths. The variant is resolved once in the decide job
(cron string or dispatch input) and build metadata artifacts are
variant-specific so skip logic and notes ranges stay independent.
Co-authored-by: Claude Fable 5 <[email protected]>
The sign-in view renders a GameOfLifeHeader background, but the
onboarding flow used a flat system background. Layer the same
GameOfLifeHeader into OnboardingBackdrop so all onboarding pages
(agents, notifications, connect/sign-in bridge) share the sign-in
backdrop.
Co-authored-by: Claude Fable 5 <[email protected]>
* Make iOS onboarding tour a swipeable horizontal pager
The onboarding page track previously moved only when the footer buttons
changed the committed stage. Replace the offset-driven HStack with a native
paging ScrollView (scrollTargetBehavior(.paging) + scrollPosition(id:)) so
the user can swipe between the agents, notifications, and connect pages in
both directions, with the existing header dots as page indicators.
The committed OnboardingFlowView stage stays the single source of truth:
swipes report back through onNavigate into the same navigate(to:) path the
buttons use, so scene analytics and onReachedConnection fire identically.
Completion, sign-in, permission, and pairing actions remain button-only on
the last (connect) page, and the pager clamps at the track ends, so a swipe
can never skip a gated step. The pageOffset track-math tests are removed
with the extension they covered.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Disable page scroll bounce when content fits
Each tour page's vertical ScrollView now uses
scrollBounceBehavior(.basedOnSize): with no vertical overflow it neither
scrolls nor rubber-bands, so vertical and diagonal drags on short pages
reach the horizontal pager instead of being eaten by an empty scroll.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix pager initial position when onboarding resumes at connect
Seeding scrollPosition alone is dropped on first layout when the initial
stage is a later page: the app resumed at the connect stage with connect
chrome but page 1 content. defaultScrollAnchor expresses the initial stage
as a content fraction (rawValue over lastIndex), which survives the first
layout pass; scrollPosition still tracks swipes and button navigation.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Derive pager anchor index from allCases ordering
The ForEach renders OnboardingStage.allCases, so the initial anchor now
uses firstIndex(of:) instead of rawValue; the two only agree while raw
values stay contiguous, zero-based, and ordered like allCases.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Route iOS connection statuses through toasts behind the beta flag
With the Toasts beta flag on, the floating "Connection lost / Retry"
pill and the fullscreen "Disconnected" terminal overlay stop rendering.
Every connection state surfaces through one toast capsule instead: a
shared coalescing key makes reconnecting -> disconnected -> reconnected
replace each other in place rather than stacking. Disconnected and
connection-lost toasts carry Reconnect/Retry actions; only the
account-mismatch toast persists (it needs acknowledgement), everything
else auto-dismisses so the toast queue never starves. The top-left
status pill stays and becomes tappable to reconnect while the flag is
on, and sign-out now clears all toasts. Adds
ToastCenter.dismiss(coalescingKey:) so a stale status capsule clears
the moment the connection is back. Flag off keeps legacy behavior.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Guard status toasts against reauth replacement and stale idle
Review fixes from PR feedback:
- WorkspaceDetailView no longer presents transient unavailable/reconnecting
toasts while reauth is required; they share the coalescing key with the
never-dismissing account-mismatch toast and would replace it, then
auto-dismiss, losing the sign-out affordance.
- Enabling the Toasts flag while a workspace is already disconnected now
presents the current status toast (the status onChange doesn't re-fire on
flag flips).
- Recovery overlay dismisses the toast on lost/recovering -> idle when the
store isn't connected, so recovery state that evaporates without a
connection (mac switch, disconnect-and-hide) doesn't leave a stale toast.
The connected path still belongs to the shell to protect the success toast.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Present success toast for workspace-scoped recovery
markMacConnectionHealthy() flips macConnectionStatus back to .connected
while connectionState is already .connected and unchanged, so the shell's
connectionState observer never fires for a same-session workspace recovery:
the "Reconnecting..." toast went stale and no "Reconnected" success showed.
The detail view now tracks the previous status via onChange and presents
the success toast when a workspace recovers from unavailable/reconnecting.
Presenting (never bare-dismissing) cannot kill the shell's success toast,
since a later present on the shared coalescing key replaces in place; the
initial fire has previous == status, so mounting stays silent.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scope reconnected toast to workspace identity
Split layout reuses WorkspaceDetailView across selection changes, so the
onChange previous value could compare statuses of two different
workspaces: selecting a connected workspace right after viewing a
disconnected one falsely toasted "Reconnected to your Mac." The status
onChange now observes a (workspace.id, status) pair and treats a
cross-workspace diff as an initial attach rather than a recovery.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Centralize connection-status toasts in one shell-owned presenter
Three rounds of review kept finding instances of the same defect class:
ephemeral views (workspace details retained by parallel TabView stacks,
the recovery overlay, the shell) raced as independent producers of the
single coalesced connection-status capsule, each with partial knowledge.
An inactive detail could replace the visible toast and aim Reconnect at
the wrong Mac, a same-client probe recovery left "Reconnecting..." stale
because neither transport state nor workspace status transitioned, and
the swipe-dismissable reauth toast was the only reauth surface.
ConnectionStatusToastPresenter is now the only producer. It mounts once
from the always-mounted WorkspaceShellView and derives one display state
from the authoritative signals together: reauth/lost/recovering flags,
transport connectionState, and the selected workspace's Mac status.
Transitions are decided by pure, unit-tested logic scoped to the selected
workspace identity, so selection changes dismiss stale capsules instead
of toasting false recoveries.
Reauth returns to the durable compact banner even when Toasts is on: it
is a blocking action, not a transient status, and a toast can be swiped
away with nothing left to re-present it. The never-dismissing reauth
toast factory is gone, which also removes the only .never toast that
could starve the queue.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Make blocking recovery states durable and silence startup restoration
Review round-4 fixes on the centralized presenter:
- Failed recovery joins reauth on the durable banner: its Retry was a
dismissible 6s toast, and in the same-client failure case the workspace
status stays connected so even the pill was hidden, leaving no visible
retry control. The failed state now dismisses the capsule (banner owns
Retry) and recovering back to connected still toasts success.
- First-attach-silent is restored, centralized in the presenter: startup
restoration passes through disconnected snapshots, so before the session
has ever held a connection nothing presents. This was lost when the
shell's hasHeldConnection moved out.
- Sign-out can no longer strand a capsule on the sign-in screen: the
snapshot now derives from isSignedIn, so the presenter converges to
dismiss even though store.signOut() changes connection state before the
auth flags flip.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Block dead-terminal input and stop transport overriding per-Mac status
Round-5 review fixes:
- With the fullscreen overlay gone, a disconnected terminal stayed
hit-testable and keystrokes were silently discarded by the disconnected
drain path. The terminal content now disables hit testing while toasts
are enabled and the workspace isn't connected; the pill and toast
overlays attach after that modifier and stay tappable.
- Display derivation no longer consults the foreground transport
connectionState. It describes only the foreground RPC connection, so a
selected workspace on a healthy secondary Mac read as disconnected
(workspaceListConnectionStatus documents the same trap). The per-Mac
workspace status the pill already displays is the single display truth.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Resign live terminal input on disconnect and scope recovery flags
Round-6 review fixes:
- allowsHitTesting only blocks new touches; a terminal focused before the
drop keeps its keyboard and keystrokes drain silently. The detail view
now calls GhosttySurfaceView.resignActiveInput() when the workspace
leaves .connected while toasts are enabled.
- connectionRecoveryFailed / isRecoveringConnection describe the
foreground RPC connection. New store-owned
selectedWorkspaceUsesForegroundConnection scopes them, so a workspace on
a healthy secondary Mac no longer shows a false "Reconnecting..." or
"Reconnected" while the foreground connection cycles.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Route reconnect through a store entrypoint and resign input on mount
Round-7 review fixes:
- switchToMac's already-foreground fast path returns true without dialing,
so the pill/toast Reconnect no-oped when the unavailable Mac was already
foreground (live event stream dead, RPC transport object alive). New
store-owned reconnectToMac(macDeviceID:) switches only when the target
isn't the foreground Mac and otherwise runs the recovery redial; the
detail helper and the toast presenter both route through it.
- The keyboard resign now fires with initial: true and on Toasts flag
flips, covering a detail that mounts already disconnected and the
window-attach autofocus that ignores connection status.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scope disconnect keyboard resignation to the selected workspace
resignActiveInput() acts on the process-wide active input surface, while
retained hidden details (parallel TabView stacks) observe their own
connection status. A hidden workspace's disconnect could therefore steal
the visible healthy terminal's keyboard. The resign now requires the
detail's workspace to be the store's selected workspace, the same
authoritative identity the toast presenter uses.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Redial the requested foreground Mac directly in reconnectToMac
reconnectToMac fell through to reconnectOrRefresh for a foreground-Mac
target, but that path gates on the aggregate workspaceListConnectionStatus.
With any healthy secondary Mac the aggregate reads connected, so the
pill/toast Reconnect merely refreshed (stale surviving RPC client) or
switched to the secondary Mac instead of redialing the requested one. The
entrypoint now applies the disconnected-branch recipe directly to the
supplied Mac: clear the automatic-retry backoff, tear down a stale live
client so switchToMac cannot fast-path, dial it, and fall back to
reconnectActiveMacIfAvailable. A healthy target short-circuits to a
workspace refresh so a stray gesture cannot tear down a live connection.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Preserve secondary Macs on redial, resign on selection, align nil target
Round-10 review fixes:
- The targeted foreground redial now tears down the stale client with
preservingOtherMacWorkspaceState: true; the default teardown dropped
healthy secondary-Mac subscriptions and their workspaces if the redial
failed.
- A nil/empty target in reconnectToMac now means the foreground
connection (the status the caller displayed) instead of aggregate
recovery, so a Disconnected toast for the foreground can't switch to a
healthy secondary Mac on tap.
- The disconnect keyboard resign also observes selectedWorkspaceID: a
detail that went unavailable while hidden re-checks when it becomes
selected, since neither status nor flag changes at that moment.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fold foreground recovery into the detail view's effective status
Same-client foreground recovery flips the store recovery flags while the
per-workspace status stays .connected, so the input protection added for
the toast mode never engaged in that state (and the pill kept claiming
Connected). The detail view now derives an effective status matching the
presenter's derivation, scoped to the selected workspace on the
foreground connection, and uses it for hit testing, keyboard
resignation, and the flag-on pill. Flag-off surfaces keep the raw status
byte-for-byte.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Retain the recovery-target Mac identity across connection teardown
Automatic recovery calls clearRemoteConnectionContext(), which nils
foregroundMacDeviceID before the bounded redial begins, so
selectedWorkspaceUsesForegroundConnection went false for exactly the
workspace being redialed: the presenter and the detail view's effective
status stopped scoping isRecoveringConnection/connectionRecoveryFailed to
it and showed an actionable "Disconnected" mid-dial. The store now
retains recoveryTargetMacDeviceID (updated whenever the foreground
identity is set, cleared on sign-out) and the ownership check falls back
to it while the foreground identity is torn down.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Follow the list status policy when no workspace is selected
Hiding the last visible Mac after connecting leaves the shell mounted
with no selected workspace and macConnectionStatus unavailable, so the
presenter's raw fallback toasted an actionable "Disconnected" whose
Reconnect could not reach any visible Mac. workspaceListConnectionStatus
already encodes that policy (hidden-only reads connected); use it as the
no-selection fallback.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Clear toasts on automatic sign-out too
Session expiry/revalidation reaches store.signOut() through
syncShellAuthentication, unmounting the workspace shell (and its
connection presenter) before anything can dismiss, so visible or queued
connection toasts stayed actionable over the sign-in screen. The root
view's sync wrapper now dismisses all toasts on the same condition the
auth gate uses to issue the sign-out, mirroring the manual path.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Rebuild secondary state after failed redial; keep flag-off path legacy
Round-15 review fixes:
- A fully failed targeted redial runs the dial paths' own cleanup with
the default non-preserving teardown (foreground id already nil, so the
aggregate filter keeps only the anonymous key), stranding healthy
secondary Macs. reconnectToMac now rebuilds secondary aggregation via
refreshSecondaryMacWorkspaces() when both dial attempts fail.
- The shared reconnect helper had leaked the new targeted entrypoint into
the flag-off TerminalDisconnectedOverlay; flag off now keeps the
original switchToMac-then-reconnectOrRefresh sequence byte-for-byte,
and only flag-on surfaces use reconnectToMac.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use explicit workspace selection for toast status and reconnect target
selectedWorkspace falls back to workspaces.first, so after a cleared
selection (e.g. a failed cross-Mac open) the presenter derived status
from and reconnected an arbitrary first row. The presenter and the
foreground-ownership check now use explicitlySelectedWorkspace (made
public); with no explicit selection the capsule follows the aggregate
workspaceListConnectionStatus and its Reconnect runs the list recovery
policy, which fails closed with multiple candidate Macs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Route retry of the recovery target as foreground; keep keyboard on probes
Round-17 review fixes:
- reconnectToMac compared the target only against foregroundMacDeviceID,
which automatic recovery nils; retrying the just-failed foreground Mac
therefore took the cross-Mac branch, whose failed-switch cleanup uses
the non-preserving teardown and whose reconnectOrRefresh fallback skips
the secondary rebuild. The comparison now includes the retained
recoveryTargetMacDeviceID so that retry takes the foreground-redial
branch with preserved secondary state.
- Input gating no longer keys off the displayed effective status: a
same-client probe reads "Reconnecting" while the transport still
carries keystrokes, so blocking there dismissed a working keyboard
mid-typing. New terminalInputIsBlocked blocks/resigns only when the
workspace status itself is disconnected or foreground recovery actually
failed; the pill keeps the recovery-aware display.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Gate chrome-return terminal refocus on the input-block policy
Returning from chat/browser chrome refocused the terminal input proxy
unconditionally; with the connection down, the blocked predicate had not
changed, so nothing resigned the keyboard opened by that path and
keystrokes drained silently despite disabled hit testing. The refocus now
shares terminalInputIsBlocked (widened to internal) so every focus
entrypoint follows one policy.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Split the connection-status state machine into its own file
Pure code motion for the one-major-type-per-file policy:
ConnectionStatusDisplayState, ConnectionStatusSnapshot, and
ConnectionStatusToastTransition move to ConnectionStatusTransition.swift;
ConnectionStatusToasts.swift keeps only the Toast factory extension.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add a dev target to the presence deploy workflow
The shared cmux-presence-dev worker could only be redeployed with a
personal Cloudflare login on the org account, which most of the team
does not have (and local wrangler OAuth tokens rot). presence.yml
already holds the org's deploy token as repo secrets for prod, so a
`target` dispatch input (prod default, dev = wrangler.dev.toml) lets
anyone keep the shared dev baseline current with
`gh workflow run presence.yml -f target=dev`. Also corrects the README,
which claimed deploys run on push to main; the workflow is manual
dispatch only.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fail closed on unknown deploy targets, pass target via env
CodeRabbit: interpolating inputs.target into the script is a
template-injection pattern (API dispatch is not limited to the UI's
choice list), and unknown values fell through to the prod branch. The
target now reaches the shell as an env var and anything but dev/prod
errors out.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Even with the deferred shrink resize, the keyboard rise still shoved
every terminal row up by the keyboard height: the old render is
bottom-pinned to the live viewport, and for a prompt sitting in the
upper half of the screen the rows the keyboard covers are the BLANK
rows below it. Riding the screen bottom pushed the content rows to
renderRect y=-287 in the captured repro and the settle resize dropped
them back — the "momentary push of all terminal rows".
While the negotiation is unsettled and the viewport is not growing,
the render now slides only as much as needed to keep the cursor row
visible: the blank space below the cursor absorbs the keyboard
intrusion first (zero motion for short content), and a full-screen
prompt keeps the legacy ride so it never hides under the keyboard.
The cursor bottom comes from the same non-blocking
ghostty_surface_ime_point read the cursor overlay uses. The anchor
also holds at live == target while the deferred resize waits on the
grid echo, so the final stretch of the transition cannot snap.
Verified on-device-sim with the same scripted dance as the repro:
renderRect held at 440x714@0 through the entire rise (previously
-45 -> -287) and frame analysis of the recording shows the text top
at the same pixel in all 384 frames across raise and dismiss.
Agents building anything iOS-related must install and launch the tagged
build on the user's connected iPhone in addition to the simulator,
without waiting to be asked, and report explicitly when no phone is
reachable.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add regression test for New Task vs search pill overlap
On iOS 26 the workspace list preview now renders the New Task button the
live shell mounts next to the system search pill, and a UI test asserts
the two controls do not intersect and stay tappable. The fix lands in
the next commit, so this run documents the overlap.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Keep iOS New Task button clear of the bottom search pill
On iOS 26 the workspace list mounted New Task as a bottomBar toolbar
item, but the TabView search-role tab renders its pill in the same
bottom-trailing slot, so the two controls stacked and New Task was
occluded and untappable. Mount the shared TaskComposerButton in the
bottom safe-area bar instead, which the system lays out above the tab
bar chrome, and move the pre-iOS-26 overlay mounting from both shell
layouts into the same WorkspaceListSearchHost so the button has one
shared layout path.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Pipeline iOS terminal input over ordered mobile RPC
* Address review: fold classifier into request, dispose abandoned pipelined handles
Replace the static-only MobileHostOrderedRequestClassifier namespace with an
isOrderedTerminalInput computed property on MobileHostRPCRequest. Add
MobileCoreRPCPipelinedRequest.abandon() and call it from
MobileTerminalInputRPCPipeline.clear() so dropped handles release their session
settlement slots instead of lingering until the request deadline or teardown.
Resume one capacity waiter per settlement to keep enqueue arrival order
self-contained, and stop routing the pipeline's teardown CancellationError into
the operational-error path.
* Close pipeline generation races found by review
Abandon a pipelined handle when clear() lands during makeRequest(), so its
session settlement slot is released instead of retained until teardown. After
the RPC-to-lane settle barrier, revalidate the captured connection generation
and client identity and fail closed, so a barrier resumed by a lifecycle
clear() cannot write the chunk into a lane from the previous connection.
* Decouple ordered input application from response writes; order paste_image
Review round 2: the ordered worker now serializes authorization and
application only and hands each response to a tracked concurrent send task,
so a peer that stops reading (issue 8842's stall) cannot freeze later typing
behind a wedged response write; stalled sends accumulate in responseTasks
until quota admission closes the connection. terminal.paste_image joins the
ordered set because its handler writes the materialized image path into the
PTY. Adds a stalled-response-write regression test.
* Scope the RPC-to-lane barrier per surface
Review round 3: pipeline entries now carry their surface, and both
hasUnsettledRequests and the lane-activation barrier consider only requests
targeting the lane being activated, so one terminal's delayed response can no
longer stall a different terminal's healthy lane. Ordering only matters within
one PTY.
* Refuse the lane after an ambiguous pipelined input failure
Review round 4: a client-side response timeout is deliberately scoped to that
one RPC (connection, client, and generation survive), but the host's ordered
worker may still apply the timed-out input late, so releasing the lane barrier
on such a settlement could deliver later lane bytes first. Settlement failures
without a host-produced response now poison the surface; a poisoned surface
skips the lane and stays on the ordered RPC path, which remains correctly
ordered with a late apply, until the next connection-lifecycle clear. Host
responses (rpcError, authorizationFailed, accountMismatch) prove the input was
rejected and do not poison. Covered by a timeout-driven regression test.
* Order PTY-writing RPCs per surface, include scroll and mouse
Review round 5: the connection-wide FIFO created cross-surface head-of-line
blocking (a slow paste_image on one surface delayed typing on another), so the
ordered queue and worker are now keyed by the request's surface; ordering is a
per-PTY property. scroll and mouse join the ordered set because their handlers
emit mouse-report bytes when mouse reporting is active. Requests without a
surface selection share one conservative bucket, which keeps the existing
serial-order tests meaningful, and a new cross-surface test proves one held
surface no longer blocks another.
* Reap pipelined settlements per surface; keep teardown outcomes claimable
Review round 6: a single FIFO reaper let one surface's stalled response hold
the barrier and capacity slots of every other surface, so entries and reapers
are now keyed per surface (capacity stays shared at 4), with a regression test
proving a held surface no longer blocks another surface's settlement or lane
transition. Session teardown now converts pending pipelined slots to the real
teardown failure and retains settled outcomes until claimed, so unclaimed
handles report connectionClosed instead of a misleading protocol error.
* Add workspace-wide terminal font zoom shortcuts
* Avoid C formatting in font zoom actions
* Add terminal font zoom ownership regressions
* Fix terminal font zoom ownership and migration
* Add terminal font zoom lifecycle regressions
* Fix terminal font zoom lifecycle safety
* Clarify workspace font zoom documentation
* Add equalize shortcut precedence regression test
* Preserve custom binding at equalize default
* Add workspace font shortcut precedence regression
* Add workspace terminal font reset shortcut
* Add workspace font reset safety regressions
* Exercise live workspace font reset regression
* Keep workspace font regression black-box
* Preserve workspace font reset state
* Cover window Dock font inheritance
* Inherit font size from window Dock
* Cover font-only reset and Dock inheritance
* Keep font reset local and inherit Dock zoom
* Import Dock font lineage model
* test: cover font zoom coalescing regressions
* test: cover Ghostty font shortcut collision
* fix: bound repeated workspace font zoom work
* test: cover remaining workspace font zoom regressions
* fix: preserve ordered workspace font zoom runs
* test: cover bounded ordered font zoom draining
* fix: bound ordered workspace font zoom draining
* fix: return configured font zoom lineage
* test: cover terminals created during font zoom drain
* fix: preserve font zoom provenance while draining
* test: seed font zoom fixtures through public config
* test: cover workspace font zoom review regressions
* fix: bound workspace font zoom lifecycle
* test: cover font zoom coalescing and stale Dock fallback
* test: stabilize workspace font zoom timing coverage
* fix: coalesce workspace font zoom repeats
* test: cover transferred and alternating font zoom work
* fix: bound cross-window workspace font zoom work
* test: cover font zoom move ordering and provenance
* fix: serialize font zoom across surface moves
* test: cover Dock lineage and remote pane inheritance cost
* fix: preserve Dock font lineage without remote config churn
* test: cover ordered bounded workspace font lineage
* fix: unify workspace and Dock font event lineage
* test: cover cross-window font event ownership
* fix: serialize cross-window font event ownership
* test: cover entering and fitted font lineage
* fix: preserve entering and fitted font lineage
* test: cover bounded transfer reconciliation
* fix: bound batch transfer reconciliation
* test: cover bounded transfer lifecycle
* fix: drain transfer reconciliation incrementally
* test: cover transfer provenance edges
* fix: preserve ordered transfer provenance
* test: cover font transfer ownership failures
* fix: isolate font-size reconciliation ownership
* test: cover failed transfer request ordering
* fix: preserve ordering after transfer failure
* test: cover font reconciliation lifecycle gaps
* fix: retain failed font reconciliation work
* test: cover font mutation retry state
* fix: reconcile font mutation retry state
* test: cover parked cross-window backpressure
* fix: wake and bound deferred font joins
* test: cover font backpressure and removal wakeups
* fix: bound font work and wake on removal
* test: cover remote removal and foreign cancellation
* fix: close remaining font lifecycle gaps
* test: serialize config refresh with font work
* fix: serialize config refresh with font work
* test: cover config transaction ordering and liveness
* fix: serialize Ghostty config with font work
* test: cover magnification reload and retry retention
* test: keep unrealized font followers config-owned
* fix: reconcile font reloads without retaining panels
* test: cover clamp, queued scale, and backpressure
* fix: preserve bounded font routing across reloads
* test: cover bounded reload and fit ownership
* fix: bound font reconciliation ownership
* test: cover reload scale transaction ordering
* test: cover follower inheritance during reload
* test: promote soft reloads when scale changes
* fix: make font config reload transactional
* test: cover reload reconciliation lifecycle
* test: cover reload registration and rollback
* fix: make font config reload incremental
* test: require fixed registry traversal cutoff
* fix: bound terminal config reload capture
* test: cover font transfer state boundaries
* fix: preserve font state across transfer boundaries
* test: cover clamped font input during reload
* fix: preserve clamped font input ownership
* test: cover late dormant font reload follower
* test: cover rebased late follower inheritance
* fix: rebase late dormant font followers
* test: cover entered dock transfer ownership
Add a red cross-window regression that proves an active panel transfer remains associated with the Dock it entered. Repair current-main test constructors and drop the app-target duplicate of package-level live Ghostty lineage coverage, which referenced test-only C stubs unavailable to the app test bundle.
* fix: retain entered dock transfer ownership
* test: keep reload appearance behind config commit
* fix: stage reload appearance until config commit
Resolve and retain the pending background values without publishing them, restore the previously applied runtime color scheme during bounded surface capture, then apply Ghostty config, swap the owned config, publish appearance, and synchronize the resolved scheme in one main-actor commit.
* test: cover non-FIFO transfer cancellation
* fix: unlink canceled transfer requests by token
* test: require reload reply after config commit
* fix: acknowledge config reload after commit
* fix: index transfer cancellation cleanup
* refactor: clarify workspace font size ownership
* test: cover font mutation lifecycle stalls
* fix: settle font mutations across lifecycle edges
* test: cover bounded font snapshot projection
* fix: project pending font intent into snapshots
* test: cover transferred descendant font inheritance
* fix: inherit pending font work across transfers
* test: cover deferred font ownership edge cases
* fix: preserve deferred font intent ownership
* test: cover projected font replay
* fix: preserve projected font request provenance
* test: cover deferred font reconciliation boundaries
* fix: preserve deferred font reconciliation state
* test: cover Ghostty font action formatting
* fix: encode Ghostty font actions invariantly
* test: cover pre-promotion font provenance
* fix: retain deferred font provenance
* test: cover font drain and fit recovery
* fix: release drains and preserve fit ceiling
* refactor: align font lifecycle with review policy
* refactor: make font dependency wiring explicit
* test: cover absolute font input during reload
* fix: preserve absolute font input during reload
* test: cover asynchronous config reload lifetime
* fix: retain config reload activity through reconciliation
* test: cover queued config reload requests
* fix: serialize reloads and observe native font actions
* test: cover reload config waiter admission
* fix: bound reload config waiters
* docs: record font action GhosttyKit pin
* test: bound coalesced reload completions
* fix: bound coalesced reload completions
---------
Co-authored-by: cmux-lawrence <[email protected]>
* Bump iroh-ffi to 1.0.2-cmux.7: idle-path stall evidence fix
Pulls the path-health detector correction into cmux
(manaflow-ai/iroh#10, merge 4152d81047a6). Structured review of the
detector merge found it counted raw udp_tx datagrams as stall evidence,
which includes the 5s keepalive PING and its PTO probe retransmissions:
on an IDLE selected direct path a transient 2-10s radio gap (WiFi roam,
channel switch) reached the 3-datagram threshold inside the 1s stall
floor and demoted AND quarantined (5s doubling to 300s) a healthy path
with zero application data pending, ratcheting repeat transients onto
the relay. Stall evidence now comes only from application-bearing
frames (STREAM/DATAGRAM/RESET_STREAM/STOP_SENDING); PTO probes
retransmit pending app data whenever any exists, so genuinely dead
paths under load still fail over fast (fork red/green: idle 8s gap no
longer demotes; active-blackhole failover 1.8s, deadline 6s).
Verification: CmuxIrohTransport 494/494, CMUXMobileCore 306/306.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Regenerate SwiftPM lockfiles for iroh-ffi 1.0.2-cmux.7
Updates the remaining lockfiles CodeRabbit flagged so the pin change is
visible in every resolution root: ios/cmuxPackage/Package.resolved
(regenerated with swift package resolve), the root Xcode workspace
lockfile (regenerated with xcodebuild -resolvePackageDependencies on a
fleet Mac), and the iOS workspace lockfile (pin entry set to the same
tool-produced revision 20f0e67cc3cb / version 1.0.2-cmux.7; its
originHash refresh is left to the next Xcode resolution because the
fleet builder ran out of disk mid-resolve and the dedicated builder is
unreachable — the pin is exact, so re-resolution cannot drift).
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
The .searchable modifier sat on the primary TabView, so every tab's
NavigationStack inherited it and rendered a second search field at the
top of the Workspaces and Notifications tabs, on top of the iOS 26
bottom search tab. Attach .searchable (and its .onSubmit) to the search
tab's destination instead, so the only search entrypoint is the bottom
tab-bar search pill.
Verified on an iOS 26.5 simulator: no top search field on Workspaces or
Notifications; tapping the search pill from either tab still presents
the bottom search field with the matching scope prompt and keyboard.
Co-authored-by: Claude Fable 5 <[email protected]>
* test(ios): cover cancellation preserving healthy RPC transport
* fix(ios): preserve healthy RPC transport on cancellation
* test(ios): cover demand-gated cancelled write recovery
* fix(ios): demand-gate cancelled write recovery
* test(ios): remove cancellation timing sleep
* test(ios): cover expired demand recycling stalled write
* fix(ios): recycle stalled write for expired demand
* fix: allow off-main mobile flag reads
* test(ios): queued request behind cancelled stalled write must recycle within grace
A request already queued behind a cancelled stalled active write has
passed the send() recovery gate, so nothing recycles the transport for
it: it hangs until its own deadline and fails with requestTimedOut while
the wedged transport stays installed. Red on this commit; fix follows.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): recycle cancelled stalled write when demand was already queued
startCancelledActiveWriteResolution now starts a grace-bounded watchdog
whenever live queued writes exist at cancellation time. If the cancelled
send has not resolved when the grace expires and queued demand still
exists, the transport is recycled so queued requests fail fast with
connectionClosed instead of hanging until their own deadlines behind a
write their timeout cannot recycle. Demand arriving after cancellation
is unchanged: it is gated in send() and needs no watchdog.
Co-Authored-By: Claude Fable 5 <[email protected]>
* docs(ios): state wire request id uniqueness contract on requestData
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): queued request timing out behind a cancelled write must recycle promptly
When a queued follower's deadline is shorter than the cancellation
grace, its timeout erases it from queuedWriteIDs before the grace
watchdog re-checks demand, so the wedged transport stays installed and
the next request pays for the recycle. Red on this commit; fix follows.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): condemn cancelled write when queued demand times out behind it
A queued request dying at its deadline while head-of-line blocked
behind a cancelled unresolved write now recycles that write's transport
immediately and fails with transportWriteTimedOut. Previously its
timeout only erased it from queuedWriteIDs, so the grace watchdog
mistook the timed-out demand for explicit cancellation, preserved the
wedged transport, and made the next request pay for the recycle.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): time-bound close/teardown wait polling
Bare Task.yield() loops can burn out in under a millisecond under suite
load before the session's async close task is scheduled, flaking
cancelledPostConnectOnlyWaiterClosesTransport about 1 in 8 runs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* refactor(ios): move RPCTaskTimeoutCancellation to its own file with safety argument
Matches the RPCTaskTimeoutRace precedent and documents why the type is
@unchecked Sendable with an NSLock: withTaskCancellationHandler's
onCancel is synchronous on an arbitrary thread and cannot await an
actor; all mutable state is lock-guarded and finish paths must win the
race actor, so the continuation finishes at most once.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): coalesce cancelled-write resolution waiters on the session actor
Each gated request used to spawn a Task awaiting the resolution
observer's value; cancelling it does not detach from Task.value, so a
burst of callers behind a cancellation-ignoring send parked one task
each until the wedged send eventually returned. Waiters are now
CheckedContinuations stored on the actor, resumed when the cancelled
write completes, fails, is recycled, or the session tears down, so
recovery frees them instead of the stalled send. Recycle also disposes
the resolution task it previously orphaned by clearing activeWrite
before tearDown could reach it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): unregister coalesced resolution waiters on caller cancellation
A gated request cancelled while the cancelled write was still pending
left its CheckedContinuation in writeResolutionWaiters until the write
resolved or the session tore down, so repeated cancelled requests
behind a never-resolving send grew the map. awaitCancelledWriteResolution
now wraps registration in withTaskCancellationHandler with a stable
waiter ID and removes+resumes the waiter on cancellation. Covered by a
drain assertion in cancelledWriteResolutionHonorsNextRequestCancellation.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): resume resolution waiters only on real active-write transitions
cancelledActiveWriteDidComplete resumed every coalesced waiter even
when its identity guard made clearActiveWrite a no-op, so a stale
completion callback from an older write generation could spuriously
satisfy the queued-demand watchdog of a newer cancelled write and
degrade queued requests from 250ms recovery to their full deadline.
Waiter resumption now lives inside clearActiveWrite behind the same
connection+request identity guard, so waiters wake iff the current
write actually transitions (complete, fail, recycle, teardown).
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: scroll-reveal the terminal files chip
The chip is now hidden at rest and revealed by scroll activity,
scrollbar-style: touch-down on the scroll surface shows it and holds it
while the finger is down, every movement delta (tracking and momentum)
pushes the idle linger out, and 2.2s after scrolling settles it fades
away. Mount state (whether there are files to show) is unchanged and
orthogonal — the reveal is a visibility gate on top, alongside the
toolbar and zoom-HUD gates. Detach/dismantle reset the reveal.
Linger runs in a cancellable Task on an injectable Clock (no
asyncAfter); a finger resting mid-drag produces no deltas, so the hide
deadline re-arms while the scroll view is still tracking.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: no per-frame task churn in the chip scroll reveal
Review finding: re-arming the linger from every scroll delta cancelled
and allocated a MainActor task per frame (~120/s on ProMotion), even
with the chip disabled. Movement deltas are now guard-only (reveal is a
single bool flip per gesture, gated on mounted chip content and a
user-driven scroll); the fade-out linger is armed once, by the
drag-end/deceleration-end callbacks.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: keep the files chip reachable for assistive tech and first-scroll mounts
Review findings: (1) the scroll-reveal gate removed the only Files
control from VoiceOver and Switch Control at rest (the host hides its
accessibility descendants while invisible) — the transient reveal is
now bypassed whenever either is running. (2) the reveal was only
recorded when chip content was already mounted, so the scroll that
discovers the FIRST file mounted an invisible chip until a second
scroll; the reveal state is now recorded independently of mount state
and applies when content arrives.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: re-run chip visibility when assistive-technology status changes
Review finding: the VoiceOver/Switch Control bypass was only sampled on
incidental visibility updates, so toggling either over an idle terminal
could leave the Files control hidden from (or stuck visible for)
assistive users. The surface now observes both status notifications for
the chip container's lifetime and re-runs the visibility update.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: unregister the chip accessibility observers at dismantle
Review finding: block-based NotificationCenter observers stay
registered until explicitly removed, so each terminal surface remount
leaked two registrations whose closures kept firing on VoiceOver /
Switch Control status changes. Removal happens in prepareForDismantle
(main-actor teardown); Swift 6 forbids touching the non-Sendable token
array from nonisolated deinit.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: stop terminal zoom + push-up during keyboard transitions
Dogfood of the kbpin build showed two glitches synchronized with the
software keyboard: the terminal font visibly zoomed when the keyboard
closed (then snapped back), and rows were shoved off the top of the
screen mid-transition before sliding back down.
Three causes, all in the shared-grid negotiation around a keyboard
container change:
1. The stretch-to-fill auto-fit ran on every geometry pass, including
the pass right after the keyboard target changed, while
effectiveGrid still held the PREVIOUS grant (the phone itself just
invalidated it). It stretched the rendered font toward filling the
new container with the stale row count and decayed one RPC
round-trip later. The fit is now deferred until the negotiation is
settled: no keyboard animation in flight, no report debouncing, the
newest report's echo confirmed, and the pass's capacity equal to
the last reported grid. The settle paths (animation completion,
echo confirmation, retry exhaustion) each schedule one final sync
so exactly one fit runs on the settled grant.
2. The render rect bottom-pinned to the LIVE viewport
unconditionally. During a dismissal the surface is already sized
for the taller target viewport, so pinning to the still-small live
bottom pushed the top rows off screen by renderHeight - liveHeight
(renderRect y hit -344pt in the captured logs) and they slid back
as the keyboard left. TerminalLetterboxGeometry.renderPinnedBottomEdge
now caps the clip at the settled amount, and while the negotiation
is unsettled a provisionally pinned render holds its top edge
instead of riding the departing keyboard down and snapping back up
on the fresh grant. Settled letterbox boxes and the keyboard-rise
path keep the legacy live-edge ride.
3. Capacity reports normalized the measured cell size with the
main-actor liveFontSize read at apply time. A font change queued
between the measurement and the apply broke the base-font
normalization by the zoom ratio, reporting a grid several times too
small and feeding bogus grants back into the loop (the 10-row
grants visible in the field recording). The geometry pass now
captures the font it measured with and the report/fit use that
paired value.
Verified on cmux-kbpin-sim against a live Mac kbpin instance: debug
log shows zoom.autofit.deferred during transitions, renderRect pinned
at y=0 through the dismissal (previously -344), no font change across
the whole cycle, and frame analysis of the recorded dance shows the
text top and pitch constant through both transitions.
* iOS: defer local shrink resize until the grid negotiation settles
The keyboard-rise direction still showed a momentary "all rows pushed
up": the local mirror resized to the smaller container immediately,
and its reflow keeps the bottom of the SCREEN (trailing blank rows
included), so the visible content collapsed to the tail of the old
screen jumped to the top until the remote reflow landed one round-trip
later.
While the negotiation is unsettled and the container shrank at the
same width (keyboard rising), the geometry pass now skips the local
set_size and letterbox fit: the old render keeps its size and the
bottom-pinned render rect slides it up with the keyboard, prompt glued
to the keyboard top. The capacity report is pure container/cell math,
so the negotiation still starts immediately, and the settle pass
(echo confirmed, or retries exhausted) applies ONE resize whose result
matches the remote's reflowed content. Deferred passes also skip
re-stamping the render's source-layout height so the stale-live clamp
cannot snap the old render to the target viewport mid-ride, and the
applied-container tracker resets with the render pipeline.
Width changes (rotation, split) and growth keep the immediate resize.
* Give the Mac a directed presence channel so the server can wake it
The Mac publishes to presence and the broker but receives nothing, so a
server-side change to its iroh binding (revocation, re-key replacement)
only reached it on the next scheduled broker round trip, up to ~45
minutes later. The phone already holds a presence WebSocket; this adds
the Mac-side equivalent as a quiet directed channel.
Presence worker: `?deviceScope=<deviceId>` on the subscribe route turns
the stream into a WebSocket-only nudge channel — no snapshot, no team
presence chatter, no sync — gated by the same first-heartbeat owner pin
as heartbeats (subscribing never writes the pin). A new owner-only
`POST /v1/presence/nudge {deviceId, tag?, kind}` delivers a
`{type: "nudge"}` frame to that device's scoped sockets. Nudges are
never sent to normal subscribers, mirroring how sync frames are gated
on `sync.hello`, so legacy presence decoders that throw on unknown
event types never see one. Kinds are a server-side allowlist
(`iroh-binding-changed`); the frame carries no route or binding data.
Mac app: `PresenceNudgeSubscriber` mirrors `PresenceHeartbeatClient`'s
gating and holds the directed stream with 1s→60s reconnect backoff. A
nudge for this device (and build tag, when given) calls the new
`CmxIrohHostRuntime.requestRegistrationRefresh()` — one immediate
registration/policy round through the existing coalesced refresh path —
plus `retryIfNeeded()` for absent runtimes. Against a pre-nudge worker
the same endpoint serves snapshot/presence frames, which the subscriber
ignores, so old servers degrade to a no-op.
The broker-side hook that fires the nudge on revocation/replacement is
deliberately a follow-up: those mutation paths are being rewritten by
the in-flight binding re-key work (PR 8883), and the endpoint is
independently drivable until then.
Worker: bun test 183 pass (9 new), typecheck clean. Package: 37
CmxIrohHostRuntime tests pass (2 new for requestRegistrationRefresh).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview: wss scheme, replaced-binding rebuild, socket lifecycle, delivery ownership
Five review findings, all confirmed against the code:
- The subscribe URL kept the https scheme; URLSessionWebSocketTask needs
wss, so the channel never connected. Convert https/http to wss/ws,
same as the iOS PresenceClient.
- A nudge-triggered refresh that discovers the binding was replaced
(different binding id) fails closed into the terminal .failed phase
and nothing rebuilt it. requestRegistrationRefresh now awaits the
refresh round settling, and the composition root reads the
post-refresh snapshot and rebuilds through reconcile with
restartActiveRuntime so a fresh activation re-registers under the new
server state. New package test pins the fail-closed contract for a
replaced binding id.
- evaluate() only toggled on/off, so a team or service-URL change rode
the old socket to the 15-minute deadline. The loop is now keyed by a
team+URL scope and restarts when the scope changes.
- URLSessionWebSocketTask.receive() ignores Swift task cancellation, so
disabling presence left the socket suspended in receive until expiry.
The receive loop runs under withTaskCancellationHandler that cancels
the socket, and frames received after cancellation are dropped.
- The DO delivered nudges by deviceScope alone; a subscriber who lost
the first-heartbeat pin race could still receive owner-only frames.
Delivery now also requires the socket's verified user to equal the
device's current pinned owner.
Worker: 183 bun tests pass, typecheck clean. Package: 38
CmxIrohHostRuntime tests pass (replaced-binding case new).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review round 2: refresh await, scope key, nudge coalescing, quiet-close backoff
Four fixes from the second structured review pass:
- requestRegistrationRefresh() now awaits across the coalesced replay
round, not just the in-flight one, so a caller that rebuilds on
`.failed` observes the state AFTER the replay the pending bit
scheduled.
- PresenceNudgeSubscriber's scope key includes the authenticated user id
and requires isAuthenticated, so two solo accounts (nil resolvedTeamID)
can never share a directed stream scope, and auth identity changes
restart the loop via an @Observable tracking re-arm.
- MobileHostIrohRuntime.refreshRegistrationFromServerSignal() is
single-flight with a pending bit: a burst of nudge frames coalesces
into one follow-up refresh instead of fanning out one main-actor
waiter per frame.
- subscribeOnce() treats a normal/going-away close as healthy service:
a directed stream is silent between nudges, so the quiet 15-minute
renewal close must reset backoff instead of doubling it toward 60s
gaps that could swallow a one-shot nudge.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Extract and test the owner-only nudge delivery decision
Review round 3 flagged that the security-sensitive delivery filter (owner
re-check per frame, directed-socket routing) had no behavior coverage.
Following the suite's no-Workers-runtime pattern (checkDeviceOwner), the
per-socket decision moves into a pure shouldDeliverNudge in core.ts, the
DO delivery loop calls it, and tests cover: normal presence subscribers
never receive nudges, wrong-device scopes and expired sockets are
excluded, a subscriber who lost the first-heartbeat pin race is excluded
at delivery despite an accepted subscription, legacy sockets without a
verified user id never match, and a mixed subscriber set delivers to
exactly the pinned owner's directed socket.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Detect legacy presence endpoints instead of decoding their traffic on main
Review round 4 P1: against a pre-nudge worker, ?deviceScope= is ignored
and the directed socket degrades to a full presence subscription — a
team snapshot (megabytes at the service's caps) followed by seen events,
each JSON-parsed on the main actor before being discarded.
The receive loop now classifies each frame before parsing: anything over
a 2 KiB bound (a real nudge is ~200 bytes) is foreign in O(1), so a
snapshot is never parsed. The first foreign frame proves the endpoint is
legacy — a nudge-aware worker sends only nudge frames on a directed
stream — so the subscriber closes immediately and re-probes every 15
minutes instead of pumping team traffic. A legacy worker has no nudges
to deliver, so the slow probe loses nothing; once the worker upgrades,
the next probe holds a normal directed stream.
Also documents the deliberately accepted first-writer pin residual on
the nudge authorization path (do.ts, README): the presence worker keeps
no synchronous registry dependency by design, and a squatted pin only
suppresses the acceleration — the Mac falls back to its pre-nudge
renewal cadence, never to a correctness failure.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Split subscriber pools and require lifetime before trusting a clean close
Review round 5:
- Directed (device-scoped) sockets no longer draw from the shared
64-subscriber presence pool. Every enabled Mac instance holds one, so
a fleet of Macs or tagged dev builds could deterministically 429 the
phones' presence streams. Admission is now a pure, tested decision
(checkSubscriberAdmission): directed sockets get their own bounded
pool of 256 and each pool only rejects its own kind.
- An EMPTY cleanly-closed stream counts as served only after living 60
seconds. The close code alone let an accept-then-close loop
(persistent drain, misbehaving proxy) pin every Mac at one WebSocket
handshake per second forever; the healthy quiet close arrives at the
service's 15-minute deadline, far above the threshold, so normal
renewals still resubscribe promptly.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bound the directed pool per user and the nudge frame at the transport
Review round 6:
- Directed subscribe admits unpinned devices by design (a Mac subscribes
before its first heartbeat), which let one member park sockets on
arbitrary fresh UUIDs until the 256-socket team pool 429'd legitimate
owners. Admission now also enforces a per-user slice (32), so one
member can never reach the team ceiling; the pure decision and its
tests cover both pools and the slice.
- The Mac's 2 KiB nudge bound moved from post-receive classification to
URLSessionWebSocketTask.maximumMessageSize, so a legacy worker's
team snapshot fails the receive (EMSGSIZE) before it is buffered
instead of after megabytes land in memory. That failure classifies as
.legacyEndpoint, converging with the parsed-foreign-frame path on the
15-minute reprobe. The in-classifier length check stays as a second
layer.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Document that a nudge accelerates the renewal round without changing it
Comment-only. Review round 7 flagged that a superseded host answering a
replacement nudge re-registers (mutating the newest-wins slot) before it
detects the changed binding id. That ordering is the pre-existing
renewal path; the nudge deliberately reuses it unchanged, and the
displaced-instance disposition (stand down without re-taking the slot)
belongs to the nudge-emission hook that fires from the authoritative
broker mutation — deferred with it to the follow-up PR behind
https://github.com/manaflow-ai/cmux/pull/8883.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test: pin files chip to gallery row count
* feat: make files chip match gallery rows
* iOS/Mac: address review findings on the gallery-count path
- Count-only scans no longer capture terminal text up front: session
workspaces never use it, and the capture takes the Ghostty surface
lock inside v2MainSync on every settled-output refresh. Only the
no-session fallback re-resolves with viewport-only text.
- Counting is now stat-only via a shared isEligible predicate: no
ChatArtifactGalleryItem construction and no directory child
enumeration for counts; page rows route inclusion through the same
predicate so the rule cannot drift (invariant test unchanged).
- A held authoritative zero now yields to fresh positive local evidence
when a refresh scan fails, so the chip cannot stay unmounted until
the transport recovers; a later successful scan restores authority.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS/Mac: cheap existence-only counting and a corrected hold test
Review findings: counting statted every historical reference through
ArtifactByteReader (which can read file bytes to classify
extension-less files) and page rows statted twice with a TOCTOU window
between decision and construction. The inclusion rule is now one pure
function fed by each caller's own filesystem observation: counts use a
single fileExists syscall per reference; rows use the one
ArtifactByteReader stat for both the decision and the payload. The
hold-across-failure test now seeds a positive gallery total (a held
zero yielding to local evidence is the separately tested drop rule it
previously contradicted).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Shared: match symlink semantics between count and row eligibility
Review finding: fileExists traverses a final symlink while
ArtifactByteReader.stat (attributesOfItem) observes the link itself, so
a dangling symlink counted as missing but rendered as an existing row.
The cheap count path now reads attributesOfItem too, and the invariant
test covers a dangling link.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Shared/Mac: coalesce concurrent row-count sweeps and fix a rebase brace
Concurrent count-only callers that miss on the same (session,
generation, filters) key now await one shared computation inside the
cache actor instead of issuing overlapping sweeps; the helper's manual
miss-compute-store path collapses into it. Also removes a stray brace
introduced while resolving the tri-state rebase conflict.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Production-shape the failed-scan hold test and cover successful no-session clearing
Mirrors the same test fix on the base branch: the failed-scan test now marks
the second completion as an explicit scan failure under a seeded session, and
a sibling test proves a successful no-session response clears the held total.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: add failing test — files chip count regresses on a failed session scan
A transient terminal artifact scan failure (nil session total) makes the
chip fall back from the session total to the viewport-only local count,
which oscillates while output streams. The chip should hold the last
session total until a scan succeeds again.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: stop the terminal files chip from flickering
The files chip unmounted with a fade the moment its count hit zero and
remounted when it went positive again. Zero counts are produced
transiently all the time: the visible-viewport scan re-runs on every
output settle while an agent streams (paths scroll out of the grid), a
reconnect flips the artifact capabilities and resets the count, and a
failed session scan regressed the count from the session total to the
viewport-only count. Each zero crossing played a 0.18s fade-out plus
0.2s fade-in, so the chip flickered continuously during agent output.
Fixes, all at the coordinator seam:
- TerminalArtifactChipVisibilityState turns count updates into mount
transitions: shows are immediate, a zero count only schedules a hide.
- The coordinator waits out a 2s grace period (injected Clock sleep in
a cancellable Task, per the no-asyncAfter rule) before unmounting;
any positive count cancels the pending hide. Disabling the chip and
dismantling the surface still unmount immediately.
- TerminalArtifactChipCountState now remembers the last successful
session total and holds it across a failed scan instead of regressing
to the oscillating local count; reset() forgets it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: report the local files-chip count immediately, refine with the session scan
Live sim verification of the grace-period fix still showed the chip
blinking every few seconds under streaming output. Cause: with session
counts enabled, every report waited on the terminalArtifactScan RPC,
and a completion only survives if no output bumped the surface
generation while it was in flight. Positive counts get scanned right
before the next output burst, so they were dropped systematically;
zero counts get scanned in quiet pauses, so they landed. The standing
count parked at zero long enough for the hide grace to expire.
The local count needs no RPC: report it synchronously (holding the
last known session total once one is known so the number does not
regress), and let the async session scan only refine the number when
it completes. The chip now mounts instantly and stays put while paths
stream through the viewport.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: keep the files chip above the verified-replay freeze layer
Frame-by-frame analysis of the live sim repro showed the chip blinking
fully off for ~40-90ms on every output burst even after the count-side
fixes. The verified-replay frozen presentation mounts a full-bounds
snapshot layer at zPosition 2000 for the length of each freeze/reveal
transaction, and the chip sat at 1050, so every transaction covered it
for a frame or two. With an agent streaming, that is a metronomic
once-per-burst blink — the dominant part of the reported flicker.
Raise the chip to 2050. The zoom HUD conflict that motivated the old
1050 value is already handled by the zoomOverlayShown visibility gate
(the chip hides while the HUD shows), not by z-order.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: widen the files chip hide grace to 3.5s
The round-4 sim run still showed one graceful hide+remount in 41s of
streaming: the positive rescan after a zero can be delayed ~2.7s when
output keeps re-arming the settle window, just past the 2s grace.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: keep provisional chip reports chip-local and cache only accepted totals
Review findings: provisional reports fire on every settled viewport
change during streaming, and each gallery refresh signal makes an open
Files sheet run a session transcript query — so provisional deliveries
(reportAndRequest's immediate report and the new provisionalReport
in-flight case) now update the chip only; authoritative scan
completions and the legacy no-session-support report keep signaling.
And a response dropped for a surface-generation mismatch no longer
seeds the held session total: a generation bump can coincide with a new
agent session binding, so only accepted current-generation responses
are cached (the re-armed request re-fetches).
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: key the held session total to its session and revalidate at hide time
Review findings: (1) a terminal can bind a new agent session without
remounting the coordinator, and the new session's first count-only
responses carry its ID with no total yet — the held total from the old
session was shown for it. The state now remembers which session the
held total belongs to and invalidates it when an accepted response
names a different session; transport failures (no response) still hold.
(2) a positive report can land in the delegate just before the hide
grace deadline and only cancel the hide after its SwiftUI round trip;
the hide task now re-drives the state machine with the fresh count
instead of unmounting.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: invalidate the held session total on any response naming a new session
Review finding: the identity check sat inside the generation-accepted
branch, and during streaming a new session's responses commonly arrive
after the viewport generation advanced — so they were dropped without
clearing the old session's total, which kept seeding provisional
reports. Session identity is generation-independent; the invalidation
now runs before the generation gate while totals are still cached only
from accepted responses.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: distinguish an authoritative no-session response from a failed scan
Review finding: optional chaining collapsed a transport failure and a
successful response whose session binding is gone, so a stale total
could stay attributed to a surface after its session moved elsewhere.
Completions now carry scan success explicitly: a successful nil-session
response clears the held total (when a session was previously known),
while failures keep holding.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Production-shape the failed-scan hold test and cover successful no-session clearing
The failed-scan test previously used the default scanSucceeded: true with no
session ID, so it exercised the success path rather than the transport-failure
hold it claims to cover. Seed the held total under an explicit session, mark
the second completion as an explicit failure, and add a sibling test proving a
SUCCESSFUL no-session response clears the held total instead of holding it.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Pulls two transport fixes into cmux:
- Path failover (manaflow-ai/iroh#8): dead selected direct path detected in
~1-3 RTT and demoted to relay (quarantine + backoff) instead of black-holing
data for the 15s path idle timeout while the host send queue overflows and
kills the session. Removes the ~2s WiFi connect/die metronome. Fork
red/green: 15.1s stall -> 1.55-1.58s relay failover, connection never closes.
- Relay credential rotation without disconnecting (manaflow-ai/iroh#9).
Verification: CmuxIrohTransport 473/473, CMUXMobileCore 305/305 (one
non-reproducing flake on a first run, clean on two reruns).
Co-authored-by: Claude Fable 5 <[email protected]>
* 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 <[email protected]>
* 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.
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* Add Windows and Linux download pages
* Localize and gate Windows and Linux downloads
* Send download telemetry before navigation
* Complete download page discovery contracts
* Keep download release states coherent
* Localize browser download social metadata
* Wrap localized download CTAs on mobile
* Balance wrapped installer labels
---------
Co-authored-by: cmux-lawrence <[email protected]>
* test: add UI tests for goto_split:previous/next cycle navigation
Add tests verifying that goto_split:previous and goto_split:next cycle
through all panes regardless of split direction (horizontal and vertical)
and wrap at the ends. Uses Ghostty's default keybinds (Cmd+]/[).
Extends the goto_split test infrastructure with a three_pane_terminal
layout mode (CMUX_UI_TEST_GOTO_SPLIT_LAYOUT=three_pane_terminal) and
a cycle navigation recorder for test observability.
These tests are expected to FAIL without the accompanying fix, because
goto_split:previous/next currently map to directional left/right
navigation which skips vertically-split panes.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: goto_split:previous/next now cycle through all panes with wrapping
Previously, goto_split:previous and goto_split:next were mapped to
directional left/right navigation in Bonsplit, which only found spatially
adjacent panes and skipped vertically-split panes entirely.
This adds cycle-based navigation that traverses all panes in tree order
(using Bonsplit's allPaneIds) and wraps around at the ends, matching
Ghostty's intended behavior for these actions.
Changes:
- Workspace.cycleFocus(forward:) traverses allPaneIds with wrapping
- TabManager.cycleSplitFocus delegates to Workspace.cycleFocus
- GhosttyTerminalView.handleAction routes PREVIOUS/NEXT through cycle
navigation instead of mapping to directional .left/.right
- focusDirection() no longer handles PREVIOUS/NEXT cases
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* test: wait for terminal focus before signaling three-pane setup complete
The setupThreePaneTerminalLayout helper was writing setupComplete
immediately after creating splits, before a terminal surface became
first responder. Ghostty keybinds only fire when GhosttyNSView has
focus, so early keystrokes could miss.
Now waits for .ghosttyDidFocusSurface and verifies a terminal panel
is focused before signaling readiness, matching the pattern used by
the existing browser split setup.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: resolve TabManager by tabId for cycle navigation
Use tabManagerFor(tabId:) instead of AppDelegate.shared?.tabManager
so that goto_split:previous/next routes to the correct window's
TabManager in multi-window scenarios, rather than biasing toward
the active window.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* test: add resolved guard to prevent duplicate setupComplete writes
The checkAndSignal poll and .ghosttyDidFocusSurface observer could
both fire and write setupComplete twice. Add a resolved flag so the
first successful path short-circuits subsequent invocations.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* fix: record cycle state from routed workspace, not active window
recordGotoSplitCycleMoveIfNeeded now accepts tabId and resolves the
workspace via tabManagerFor(tabId:), consistent with how cycleSplitFocus
itself is routed. Previously it used the active window's tabManager,
which could snapshot the wrong workspace in multi-window scenarios.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
* Fix goto split cycle shortcut routing
---------
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
recordGotoSplitCycleMoveIfNeeded now accepts tabId and resolves the
workspace via tabManagerFor(tabId:), consistent with how cycleSplitFocus
itself is routed. Previously it used the active window's tabManager,
which could snapshot the wrong workspace in multi-window scenarios.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The checkAndSignal poll and .ghosttyDidFocusSurface observer could
both fire and write setupComplete twice. Add a resolved flag so the
first successful path short-circuits subsequent invocations.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Use tabManagerFor(tabId:) instead of AppDelegate.shared?.tabManager
so that goto_split:previous/next routes to the correct window's
TabManager in multi-window scenarios, rather than biasing toward
the active window.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
The setupThreePaneTerminalLayout helper was writing setupComplete
immediately after creating splits, before a terminal surface became
first responder. Ghostty keybinds only fire when GhosttyNSView has
focus, so early keystrokes could miss.
Now waits for .ghosttyDidFocusSurface and verifies a terminal panel
is focused before signaling readiness, matching the pattern used by
the existing browser split setup.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Previously, goto_split:previous and goto_split:next were mapped to
directional left/right navigation in Bonsplit, which only found spatially
adjacent panes and skipped vertically-split panes entirely.
This adds cycle-based navigation that traverses all panes in tree order
(using Bonsplit's allPaneIds) and wraps around at the ends, matching
Ghostty's intended behavior for these actions.
Changes:
- Workspace.cycleFocus(forward:) traverses allPaneIds with wrapping
- TabManager.cycleSplitFocus delegates to Workspace.cycleFocus
- GhosttyTerminalView.handleAction routes PREVIOUS/NEXT through cycle
navigation instead of mapping to directional .left/.right
- focusDirection() no longer handles PREVIOUS/NEXT cases
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add tests verifying that goto_split:previous and goto_split:next cycle
through all panes regardless of split direction (horizontal and vertical)
and wrap at the ends. Uses Ghostty's default keybinds (Cmd+]/[).
Extends the goto_split test infrastructure with a three_pane_terminal
layout mode (CMUX_UI_TEST_GOTO_SPLIT_LAYOUT=three_pane_terminal) and
a cycle navigation recorder for test observability.
These tests are expected to FAIL without the accompanying fix, because
goto_split:previous/next currently map to directional left/right
navigation which skips vertically-split panes.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-06 11:44:58 -07:00
3203 changed files with 731755 additions and 49567 deletions
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR}" \
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR} and sentry=false" \
- Fix a crash seconds after launch on Intel Macs; cmux is now the only process-wide crash handler, and embedded GhosttyKit no longer links Ghostty's native Sentry initializer ([#9436](https://github.com/manaflow-ai/cmux/pull/9436))
- Fix `cmux ssh <host>` failing immediately with a shell syntax error from the generated startup script ([#9425](https://github.com/manaflow-ai/cmux/pull/9425)) -- thanks @KousukeUchiyama for the report!
- Clear Dock notifications when you focus the pane that raised them ([#9418](https://github.com/manaflow-ai/cmux/pull/9418))
- Keep a restored Claude agent on its own account instead of falling back to the ambient one ([#9419](https://github.com/manaflow-ai/cmux/pull/9419)) -- thanks @seanyoungberg for the report!
- Stop bash shell integration printing `cannot overwrite existing file` on every prompt under `set -o noclobber` ([#9420](https://github.com/manaflow-ai/cmux/pull/9420)) -- thanks @8bit-void for the report!
- Fail closed when `close` or `respawn-pane` is given an explicit `--surface` that no longer exists, instead of acting on a different live surface ([#9422](https://github.com/manaflow-ai/cmux/pull/9422)) -- thanks @PhilipPinckaers for the report!
- Native iPhone and iPad Simulator panes, with their own commands and automation ([#7857](https://github.com/manaflow-ai/cmux/pull/7857))
- First-class Mosh transport for remote workspaces ([#8442](https://github.com/manaflow-ai/cmux/pull/8442))
- Workspace-wide terminal font zoom on Cmd+Ctrl+= / Cmd+Ctrl+- / Cmd+Ctrl+0 ([#8791](https://github.com/manaflow-ai/cmux/pull/8791)), and per-tab zoom now persists across restarts ([#8543](https://github.com/manaflow-ai/cmux/pull/8543))
- Cmd+Shift+T reopens the last closed item ([#9132](https://github.com/manaflow-ai/cmux/pull/9132))
- Cmd+[ and Cmd+] traverse global workspace focus history, and pane cycling becomes rebindable ([#9329](https://github.com/manaflow-ai/cmux/pull/9329)) -- thanks @azooz2003-bit! -- alongside a workspace-only focus history setting ([#8654](https://github.com/manaflow-ai/cmux/pull/8654))
- Move active surfaces between panes with automatic directional splits ([#8764](https://github.com/manaflow-ai/cmux/pull/8764)); `goto_split:previous` and `goto_split:next` cycle through every pane with wrapping ([#2639](https://github.com/manaflow-ai/cmux/pull/2639)) -- thanks @mykmelez!
- Dock panes persist across session restore ([#8690](https://github.com/manaflow-ai/cmux/pull/8690)), with full Dock surface runtime parity ([#8782](https://github.com/manaflow-ai/cmux/pull/8782))
- Reopen closed workspaces with sticky repo identity ([#8841](https://github.com/manaflow-ai/cmux/pull/8841))
- Target browser profiles from the CLI ([#8874](https://github.com/manaflow-ai/cmux/pull/8874)), and Command-clicked HTML files render in browser panes ([#9096](https://github.com/manaflow-ai/cmux/pull/9096))
- Sidebar account and mobile pairing controls ([#8354](https://github.com/manaflow-ai/cmux/pull/8354)); sidebar metadata renders Markdown links ([#8663](https://github.com/manaflow-ai/cmux/pull/8663)) -- thanks @djova!
- Notification feed read state is a leading swipe with mark-unread ([#8868](https://github.com/manaflow-ai/cmux/pull/8868)) -- thanks @azooz2003-bit!
- Idle background agents hibernate under critical memory pressure even when routine Agent Hibernation is off ([#9090](https://github.com/manaflow-ai/cmux/pull/9090))
-`cmux restore` runs without a shell ([#9265](https://github.com/manaflow-ai/cmux/pull/9265))
- iOS (beta): stream Mac browser panes to the phone, interactive and pixel-perfect, with dialogs mirrored ([#8298](https://github.com/manaflow-ai/cmux/pull/8298)) -- thanks @azooz2003-bit!
- iOS (beta): haptic feedback setting ([#8797](https://github.com/manaflow-ai/cmux/pull/8797)), Open Folders on Tap ([#8524](https://github.com/manaflow-ai/cmux/pull/8524)), unified animated toasts ([#8376](https://github.com/manaflow-ai/cmux/pull/8376)), and workspace identity customization ([#8636](https://github.com/manaflow-ai/cmux/pull/8636)) -- thanks @azooz2003-bit!
### Changed
- Workspace initial commands launch through your login shell ([#8801](https://github.com/manaflow-ai/cmux/pull/8801)) -- thanks @azooz2003-bit! -- and auto-resume uses the normal terminal shell ([#8837](https://github.com/manaflow-ai/cmux/pull/8837))
- iOS (beta): the phone-to-Mac transport is rebuilt on one connectivity authority, with authenticated discovery, named disconnect reasons, and relay-credential rollover ([#9284](https://github.com/manaflow-ai/cmux/pull/9284), [#8840](https://github.com/manaflow-ai/cmux/pull/8840), [#8716](https://github.com/manaflow-ai/cmux/pull/8716), [#8494](https://github.com/manaflow-ai/cmux/pull/8494)) -- thanks @azooz2003-bit!
- iOS (beta): terminal scrolling is local and smooth on screen-anchored render grids ([#8860](https://github.com/manaflow-ai/cmux/pull/8860)) -- thanks @azooz2003-bit!
- iOS (beta): state sync v2 replaces the invalidate-and-refetch loop with per-record deltas ([#8284](https://github.com/manaflow-ai/cmux/pull/8284)) -- thanks @azooz2003-bit!
- iOS (beta): onboarding is rebuilt around a live agent handoff ([#8418](https://github.com/manaflow-ai/cmux/pull/8418)), as a swipeable tour ([#9158](https://github.com/manaflow-ai/cmux/pull/9158)) with a Game of Life backdrop on every page ([#8880](https://github.com/manaflow-ai/cmux/pull/8880)) -- thanks @azooz2003-bit!
- iOS (beta): removing a Mac from a phone hides it for that phone only, instead of deleting it everywhere ([#8760](https://github.com/manaflow-ai/cmux/pull/8760), [#8778](https://github.com/manaflow-ai/cmux/pull/8778)) -- thanks @azooz2003-bit!
### Fixed
- Fix leaked `openThread` loops burning ~90% of cmux idle CPU ([#8851](https://github.com/manaflow-ai/cmux/pull/8851))
- Fix workspace-switch renderer freezes ([#8793](https://github.com/manaflow-ai/cmux/pull/8793)), reclaim hidden Ghostty renderer memory ([#8998](https://github.com/manaflow-ai/cmux/pull/8998)), and fix the Vault sidebar beachball at large session counts ([#8680](https://github.com/manaflow-ai/cmux/pull/8680))
- Fix Vim Mode cursor and selection rendering ([#8995](https://github.com/manaflow-ai/cmux/pull/8995))
- Fix TextBox IME composition rendering ([#8688](https://github.com/manaflow-ai/cmux/pull/8688))
- Fix zsh prompt wrap spacer lines by letting Ghostty own prompt layout ([#8964](https://github.com/manaflow-ai/cmux/pull/8964))
- Fix Settings and main window zombies under AeroSpace ([#8513](https://github.com/manaflow-ai/cmux/pull/8513)) -- thanks @fml09!
- Fix a Debug-build crash on macOS 26.5 from non-finite sidebar divider coordinates ([#9156](https://github.com/manaflow-ai/cmux/pull/9156)) -- thanks @oscarbrey!
- Fix Mermaid diagrams double-scaling under viewer zoom ([#8914](https://github.com/manaflow-ai/cmux/pull/8914)), restore the focused-read indicator after a surface-scoped mark-read ([#8927](https://github.com/manaflow-ai/cmux/pull/8927)), keep Pi launch arguments when resuming a restored session ([#8912](https://github.com/manaflow-ai/cmux/pull/8912)), and import appearance at Settings store init instead of live-applying it ([#8913](https://github.com/manaflow-ai/cmux/pull/8913)) -- thanks @ejc3!
- Notify only after the Pi agent settles ([#8574](https://github.com/manaflow-ai/cmux/pull/8574)) -- thanks @mrohan-sq!
- Tear down remote daemon PTY sessions once ([#8643](https://github.com/manaflow-ai/cmux/pull/8643)) -- thanks @ejc3! -- and support `respawn-pane` in the Go relay tmux compatibility layer ([#8660](https://github.com/manaflow-ai/cmux/pull/8660)) -- thanks @bencollins2!
- Stop the sidebar PR poller from re-downloading every repo's full PR list on each poll ([#8521](https://github.com/manaflow-ai/cmux/pull/8521)) -- thanks @joshfree!
- Restore Codex ([#9370](https://github.com/manaflow-ai/cmux/pull/9370)), Kimi Code ([#8584](https://github.com/manaflow-ai/cmux/pull/8584)), Grok ([#9382](https://github.com/manaflow-ai/cmux/pull/9382)), and Pi ([#9399](https://github.com/manaflow-ai/cmux/pull/9399)) sessions across relaunch, and stop duplicate agent resumes ([#8619](https://github.com/manaflow-ai/cmux/pull/8619))
- ssh-tmux: fix focus after single-pane promotion ([#9020](https://github.com/manaflow-ai/cmux/pull/9020)), named-key encoding for the remote `TERM` ([#9273](https://github.com/manaflow-ai/cmux/pull/9273)), and terminal replies leaking into reattached panes ([#9272](https://github.com/manaflow-ai/cmux/pull/9272)); fix workspace shortcuts from hosted tmux terminals ([#8621](https://github.com/manaflow-ai/cmux/pull/8621))
- Fix SSH relay deadlock after app restart ([#9105](https://github.com/manaflow-ai/cmux/pull/9105)), stale SSH workspace connection status ([#9085](https://github.com/manaflow-ai/cmux/pull/9085)), remote PTY `PATH` inherited from cmuxd ([#8677](https://github.com/manaflow-ai/cmux/pull/8677)), and login-shell resolution before terminal spawn ([#8681](https://github.com/manaflow-ai/cmux/pull/8681))
- Fix sidebar reopen cutoff render ([#8626](https://github.com/manaflow-ai/cmux/pull/8626)), row clipping during height-changing reorder ([#9189](https://github.com/manaflow-ai/cmux/pull/9189)), idle layout livelock ([#8532](https://github.com/manaflow-ai/cmux/pull/8532)), and status URL clicks ([#8528](https://github.com/manaflow-ai/cmux/pull/8528))
- Fix Dock paste routing to the selected terminal ([#9112](https://github.com/manaflow-ai/cmux/pull/9112)), Dock terminal working-directory inheritance ([#8691](https://github.com/manaflow-ai/cmux/pull/8691)), and Cmd-click link opening in Dock terminals ([#8594](https://github.com/manaflow-ai/cmux/pull/8594))
- Browser: fix navigation for terminal-wrapped URL pastes ([#8601](https://github.com/manaflow-ai/cmux/pull/8601)), automation recovery after load failures ([#8548](https://github.com/manaflow-ai/cmux/pull/8548)), partial blank screenshots ([#9281](https://github.com/manaflow-ai/cmux/pull/9281)), and blurred Google Sheets canvas rendering ([#8697](https://github.com/manaflow-ai/cmux/pull/8697))
- Fix inline code escaping in the Markdown viewer ([#9274](https://github.com/manaflow-ai/cmux/pull/9274)) and composer attachment thumbnail re-rasterization ([#8817](https://github.com/manaflow-ai/cmux/pull/8817))
- Fix renderer presentation for background-created surfaces ([#8540](https://github.com/manaflow-ai/cmux/pull/8540)) and stale semantic prompts duplicating inline TUI frames ([#9275](https://github.com/manaflow-ai/cmux/pull/9275))
- Fix workspace group anchor numbering ([#9176](https://github.com/manaflow-ai/cmux/pull/9176)); closing a group's anchor keeps the group instead of scattering its members to the root ([#8925](https://github.com/manaflow-ai/cmux/pull/8925))
- Preserve workspace IDs across session restore ([#8695](https://github.com/manaflow-ai/cmux/pull/8695)) and restored resume workspace titles ([#8687](https://github.com/manaflow-ai/cmux/pull/8687)); fit same-display restored windows to visible bounds ([#8675](https://github.com/manaflow-ai/cmux/pull/8675))
- Fix a `DispatchWorkItem` chain stack overflow ([#8615](https://github.com/manaflow-ai/cmux/pull/8615)) and subprocess pipe descriptor leaks ([#9187](https://github.com/manaflow-ai/cmux/pull/9187))
- iOS (beta): preserve terminal input ordering under fast typing ([#8682](https://github.com/manaflow-ai/cmux/pull/8682)), scroll position across mid-stream verified replays ([#9032](https://github.com/manaflow-ai/cmux/pull/9032)), and keyboard focus after the photo picker ([#9287](https://github.com/manaflow-ai/cmux/pull/9287)) -- thanks @azooz2003-bit!
- iOS (beta): fix a startup crash from sentry-init racing environ mutation ([#9238](https://github.com/manaflow-ai/cmux/pull/9238)) and TestFlight crash paths ([#9034](https://github.com/manaflow-ai/cmux/pull/9034))
- iOS (beta): fix workspace-list scroll stutter from live updates ([#9139](https://github.com/manaflow-ai/cmux/pull/9139)), and make the notification feed scroll fast with thousands of items ([#9141](https://github.com/manaflow-ai/cmux/pull/9141)) -- thanks @azooz2003-bit!
The helper refuses to run without `CMUX_TAG`, targets `/tmp/cmux-debug-<tag>.sock`, and uses the matching tagged CLI from DerivedData. It scrubs ambient cmux terminal context (`CMUX_SOCKET`, `CMUX_SOCKET_PASSWORD`, workspace/surface/tab/panel IDs, cmuxd socket, debug log), then sets `CMUX_SOCKET_PATH`, `CMUX_BUNDLE_ID`, and `CMUX_BUNDLED_CLI_PATH` for the tag.
## iOS builds open on the iPhone by default
Any work verified by opening the iOS app installs BOTH an isolated-simulator build AND the same build on the user's iPhone. Never stop at simulator-only. Use `ios/scripts/reload-cloud.sh --tag <tag>` (or `ios/scripts/reload.sh --tag <tag>`); with a default iPhone configured (`CMUX_IPHONE_DEVICE_ID` or `~/.config/cmux/iphone-device-id`) the device leg is automatic, and `--device-id <id>` still overrides (`xcrun devicectl list devices`). Auto sign-in and auto-pair apply as usual; launch the app so it is immediately open on the phone. The simulator leg uses the tag's own isolated device `cmux-dev-<slug>`, created on demand; do not target a shared or user-visible simulator.
Every phone build requires the same-tag Mac dev build (the iOS app is unusable without its Mac). The reload scripts build the Mac tag first when it is missing and refuse to ship a phone-only build if that fails; do not bypass this with `CMUX_IOS_SKIP_MAC_BUILD_CHECK` in normal work.
If the iPhone is unreachable at build time, the reload still completes: the signed build is parked in the offline install queue (`scripts/iphone-install-queue.sh`, persistent under `~/Library/Application Support/cmux-dev/iphone-install-queue`), and a LaunchAgent auto-installs and launches it within seconds of the phone being plugged back in or reappearing on the network, then sends a `cmux notify` with the installed tags. The LaunchAgent is a one-time per-Mac setup: `scripts/install-iphone-queue-agent.sh install`; it runs a stable copy of the queue script, so re-run the installer after changing that script. In the handoff, report the queued state (`scripts/iphone-install-queue.sh list`) instead of treating an unreachable phone as a failure; `drain` retries manually, `clear` abandons a queued build.
## iOS dev auth
`ios/scripts/reload.sh` and `scripts/mobile-dev-launch.sh` auto-sign-in from `~/.secrets/cmuxterm-dev.env`. If the phone lands on the login screen or the helper reports missing credentials, do not ask the user to authenticate every build. Tell them to run `scripts/setup-team-dev.sh` once; it verifies their Stack login and writes the file chmod 600. Manual fallback: create it with `CMUX_DOGFOOD_STACK_EMAIL=...` and `CMUX_DOGFOOD_STACK_PASSWORD=...`.
@@ -52,13 +60,13 @@ Two commits, so CI proves the test catches the bug: commit 1 adds the failing te
## First pass, then dogfood
A first pass ends when the change is implemented, the tagged build succeeded on the pushed HEAD, focused tests ran, and the PR is open (for `web/` PRs, also the live Vercel preview URL). Then hand off to the user. Do not fix CI failures, merge conflicts, or review findings inline in the main conversation after that point.
A first pass ends when the change is implemented, the tagged build succeeded on the pushed HEAD, focused tests ran, and the PR is open (for `web/` PRs, also the live Vercel preview URL). Then hand off to the user. Do not sit in the main conversation watching CI or running speculative review passes after that point.
At handoff, launch one background `$autoreview` subagent with a bounded prompt (PR URL, worktree, base ref, allowed write scope, required verification), never a vague "make it green". That loop owns CI and spawns a bounded repair subagent only when a check actually fails. One writer per worktree: do not run a second CI repair agent against the same worktree, and if dogfood feedback needs main-agent edits while the loop runs, stop the loop first or give it a sibling worktree.
Do not launch a background review agent (`$autoreview`, `codex review`, `claude review`, or a judge loop) by default. Second-model review is explicit user opt-in in the current conversation; an implementation request, open PR, CI failure, closeout, or handoff is not that opt-in. Let required GitHub checks and the automatic review bots run asynchronously, then return to address only concrete check failures and actionable findings before merge.
The loop may commit and push scoped fixes but never merges and never rebuilds the user's tagged build. The main agent inspects every pushed commit, rejects out-of-scope edits, and owns dogfood, approval, and merge. Merging app/runtime/UI changes requires the user's explicit approval after dogfood; if a pushed fix changes runtime behavior mid-dogfood, rebuild the tag and re-notify, since the earlier verdict covers only the build the user tested.
The main agent owns dogfood, approval, mergeability, and every pushed fix. Merging app/runtime/UI changes requires the user's explicit approval after dogfood; if a fix changes runtime behavior mid-dogfood, rebuild the tag and re-notify, since the earlier verdict covers only the build the user tested.
Notify through `cmux notify` so the user can leave and return. Handoff: `--title "Dogfood ready: <short task>" --subtitle "<branch> · <tag>" --body "Was: <prior bad behavior>. Now: <expected behavior>. <concrete check>. PR: <pr-url>"`. The loop reports its own outcome, e.g.`"CI green: <branch>"` or `"CI blocked: <branch>"` with a one-line cause and the next decision. Titles carry outcome and branch, bodies carry the single next action. Skip notify if there is no cmux socket.
Notify through `cmux notify` so the user can leave and return. Handoff: `--title "Dogfood ready: <short task>" --subtitle "<branch> · <tag>" --body "Was: <prior bad behavior>. Now: <expected behavior>. <concrete check>. PR: <pr-url>"`. Later closeout notifications use`"CI green: <branch>"` or `"CI blocked: <branch>"` with a one-line cause and the next decision. Titles carry outcome and branch, bodies carry the single next action. Skip notify if there is no cmux socket.
scriptLines.append(" if \(establishedBridgeFailed); then cmux_ssh_reconnect_delay=\"$cmux_ssh_reconnect_initial_delay\"; fi")
}
ifhasOneTimeCommand{
scriptLines+=[" if [ \"$cmux_ssh_status\" -eq 255 ]; then cmux_ssh_reauth_required=1; fi"," fi"]
@@ -420,8 +437,8 @@ extension CMUXCLI {
scriptLines+=[
" cmux_ssh_retry=$((cmux_ssh_retry + 1))",
" cmux_ssh_note '\\n\\033[33m[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).\\033[0m\\n\\033[2m[cmux] close this pane or press Ctrl-C to stop reconnecting.\\033[0m\\n' \"$cmux_ssh_status\"\"$cmux_ssh_retry\"\"$cmux_ssh_reconnect_limit\"",
" if [ \"$cmux_ssh_reconnect_delay\" -gt 0 ]; then sleep \"$cmux_ssh_reconnect_delay\"; fi",
]
scriptLines+=backoffBuilder.waitLines
ifretryPTYAttachStatus{
scriptLines.append(" if [ \"$cmux_ssh_reconnect_delay\" -lt \"$cmux_ssh_reconnect_max_delay\" ]; then cmux_ssh_reconnect_delay=$((cmux_ssh_reconnect_delay * 2)); if [ \"$cmux_ssh_reconnect_delay\" -gt \"$cmux_ssh_reconnect_max_delay\" ]; then cmux_ssh_reconnect_delay=\"$cmux_ssh_reconnect_max_delay\"; fi; fi")
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.