`openBrowser(insertAtEnd:)` passed a final position to `reorderTab`, which is
addressed in bonsplit insertion gaps. The end of the strip is `count`, not
`count - 1`, so the old value asked for the gap in front of the last tab and
left the new browser one slot short of the end.
It looked correct whenever exactly one tab followed the insertion point, since
the position and the gap agree there, which is why the existing two-tab test
did not catch it.
Before: TabManagerSurfaceCreationTests, 11 tests, 1 failure
After: TabManagerSurfaceCreationTests, 11 tests, 0 failures
Co-authored-by: ejc3 <[email protected]>
* sidebar-git: give the PR refresh run-loop test something real to observe
testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to
a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran".
The refresh stopped spawning that process in #2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The checks after it were worse than failing: with no discovery observed, they held
whether or not anything happened at all.
Repository discovery is the blocking filesystem work the refresh does before it
reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service accept the protocol instead of the
concrete type, which every existing call site already satisfies.
The test injects a discovery that counts and sleeps. It resolves no slugs, which
keeps the refresh off the GitHub transport and away from `gh auth token`.
The test now makes two claims rather than three. The invocation count is the one
that can fail for a product reason, and it is the one that was broken. The
run-loop tick gap stays as a coarse guard against a seconds-long stall.
The old "discovery did not run on the main thread" check is gone, along with the
observation box that fed it. `repositorySlugs` is a nonisolated async requirement,
so SE-0338 runs it off the caller's actor however the refresh schedules it: the
check passed no matter what the product did, including if discovery were rewritten
to be awaited inline. A test that cannot fail is not evidence, and keeping it
would have implied coverage the test does not have.
The run-loop tick gap stays as a coarse guard, and its comment now says why it is
loose: 45 seeds times 30ms of injected blocking is 1.35s against a 2.0s ceiling, so
this test's own work cannot trip it. It fires only if the product adds a
multi-second main-thread stall on top.
The counter is renamed to RepositoryDiscoveryInvocationCounter, since it counts
discovery calls rather than command-runner calls, and the new TabManager parameter
carries a note that it overrides discovery for the pull-request refresh only.
* chore: prepare PR 8724 origin transfer
* test: transfer deterministic PR refresh coverage
---------
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
One test in this suite has been killing the xctest host, which is worse than a red suite:
the host dies with no verdict and every suite batched with it loses its results too.
The evidence names the test. scripts/ci/cmux-unit-test-timings.json was generated from a
green main run by scraping per-test completion lines, and it holds 247 entries for this
suite. testWelcomeWindowSidebarShortcutsUseSharedToggleCommands is the only declared test
absent from it. A test that neither passes nor fails nor skips is one the host died inside.
That test is also the only place in this 12,000-line file that calls performClose on a
window it constructed, and the only closed window here that leaves AppKit's close-time
release enabled; the other twenty disable it, and the product does the same for its own
windows. The test holds the window through ARC while the delegate's window context and the
focus-capture swizzle hold weak references to it, so the deferred close drops the last
retain a runloop turn later and the process aborts rather than failing a test.
Separately, the one test that constructs a second AppDelegate restored AppDelegate.shared
but not the surface registry's route retirer, which init had pointed at the temporary
delegate and which the registry holds weakly. That left the retirer nil for the remainder
of the host, so later tests ran against a registry that never sweeps retired routes.
A third latent host kill stays for its own change: a key-event helper calls fatalError
instead of failing, and converting it needs a throwing signature at fourteen call sites,
which does not belong in the same diff as the crash it would obscure.
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
* file explorer: show git status for repos reached through a symlink
GitStatusProvider compared git's physical repo root (/private/var/...) against
the caller's explorer root spelled logically (/var, /tmp, or a symlinked project
dir) by raw string prefix, so every entry was dropped and the file explorer showed
no git status for any workspace behind a symlink. Resolve both roots to one spelling
for the containment check and emit keys under the caller's spelling so
FileExplorerStore lookups match. The ssh path keeps the caller's spelling on both
axes, so remote paths are never resolved against the local filesystem.
* file explorer: say when the root == "/" key branch is reached
---------
Co-authored-by: ejc3 <[email protected]>
BrowserPortalFirstRevealScrollTests declares 16 tests. Run alone it completed 10
of them and restarted the app host three times, so the suite had no verdict and
anything sharing its host lost one too.
makeWindowFixture builds an NSWindow and three tests close it. AppKit releases a
window on close unless the owner opts out, and ARC still holds a strong reference,
so each of those closes over-releases and takes the process down. The count lines
up: exactly three tests call close(), and there were exactly three restarts. The
one test that builds its own window already sets the flag, so this was an omission
in the shared fixture rather than a deliberate difference.
The product does this everywhere it owns a window (BrowserPanel, the prewarmed
pool, the popup controller, ReleasingWindowController); only this fixture missed it.
Before: 3 restarts, 10 of 16 tests ran, ** TEST FAILED **
After: 0 restarts, 16 of 16 tests ran, ** TEST SUCCEEDED **
Both arms ran on the same worktree and the same warm derived-data path, one suite
per app host, with only this change between them.
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
The HostBrowserSignInFlow harness waits spun on Task.yield() until their
condition held. Under CPU contention that is a bet on when the awaited task
gets scheduled, and the spinning loop competes with it for the same cores.
Running the package suites a few at a time was enough to lose the whole
target to
HostBrowserSignInFlowTestSupport.swift:102: Fatal error: Timed out waiting
for 1 host-browser session(s); got 0
since the timeout aborts the process and takes all 167 tests with it.
Raising the deadline does not fix that. With 48 busy loops on 16 cores, a
ten-second budget aborted the same way, only later. So each wait now suspends
until the fake it waits on resumes it: the session factory resumes session
waiters as it appends a session, the fake client resumes them as a currentUser
read parks on the closed user gate, and the gateable client resumes them once
an exchange has written its tokens or a clear has emptied the store. The
condition wait registers with the observation system instead, since the flow
and the coordinator are both @Observable. FlowFakeAuthClient's
storedAccessTokenDidPark() and ManualTestClock already worked this way.
The deadlines stay on as a net, so a genuine hang still reports by name rather
than suspending the run forever. They no longer bound a passing run.
Co-authored-by: ejc3 <[email protected]>
* Default test-process windows to releasedWhenClosed = false
AppKit defaults a code-created NSWindow to releasedWhenClosed == YES, so under ARC every close()
in test teardown sends an extra release. The window deallocates while still in the test's
autorelease pool, and the post-test pool drain then over-releases it: EXC_BAD_ACCESS in
objc_release, which kills the shared app host. xcodebuild relaunches the host and its summary
covers only the last launch, so verdicts pending in the dead host go missing rather than red.
A constructor in the test bundle swizzles NSWindow's two designated initializers so every window
created in the test process defaults to releasedWhenClosed == NO. A subclass's super.init reaches
the swizzled implementation, so NSPanel and every test-local subclass are covered without being
touched. Nothing in cmux sets releasedWhenClosed = YES deliberately, and production already sets
NO at its own call sites. The tradeoff is that AppKit-internal self-releasing windows leak in the
test process, which is harmless there.
Rebased onto current main from #7768; only the two source files are carried over, and the project
file entries are re-added against main's copy.
Co-authored-by: ejc3 <[email protected]>
* cmuxTests: also disable the window appearance animation in the guard
Greptile's review asked for this and trackTestWindow already does both: a window's appearance
animation is its own object and can outlive the window, committing CoreAnimation transactions off
the main thread for the rest of the run. Setting animationBehavior alongside releasedWhenClosed
means AppKit never creates the animation for a test-process window.
Nothing in the test targets asserts on animationBehavior, and the three production sites that
choose one deliberately assign after init returns, so an init-time default cannot override them.
Measured before pushing: the guard tests plus BrowserDeveloperToolsVisibilityPersistenceTests
produce the same verdicts with and without this change — same 11 pre-existing failures, nothing
added or removed, 0 restarts both ways.
* cmuxTests: cover guarded window animation defaults
* cmuxTests: type Swift Testing failure comment
---------
Co-authored-by: lawrencecchen <[email protected]>
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
* tests: drain all paneRects in programmaticMirrorReorder… (broken by #7315)
#7315 (exact feed-forward sizing / verified pane geometry) changed a mirror
window to publish only when its own paneRects reply lands, and those fetches are
enqueued incrementally — window @2's fetch appears after @1 resolves. The test
replied to a single snapshot of pending paneRects, so @2 never published, the
mirror built one tab instead of two, and the reorder + windowOrder assertions
failed (panelIds.count == 1, not 2).
The product is correct — the sibling mirror suites and the multiplex fuzzer build
multi-window mirrors green. This is a stale test setup: drain every paneRects
fetch (bounded loop) so both windows publish, then the two-tab reorder holds.
Red/green: on clean main the test fails with panelIds.count → 1 == 2; with the
drain it passes (1 test). Test-only change; no product code touched.
* tests: drain post-#7315 follow-up commands in RemoteTmuxWindowReorderTests
#7315 (verified pane geometry) and the pane-border-status work changed the
control-command stream the reorder/close state machine emits: a window-list
publish now also enqueues a per-window paneRects refetch, and closing a window
issues a border-status unsubscribe (a plain send(), kind .other). The suite
drives the connection with positional commandNumber:0 replies, so an undrained
follow-up sits at the FIFO head and swallows the reply meant for the reorder/
close list-windows recovery — the batch never recovers, the connection never
reconnects, and retained panes never release. All 33 assertions across 9 tests
failed on clean main for this one reason.
Fix is test-only: publish helpers drain every follow-up (paneRects + .other), a
drainLeadingOther helper clears them ahead of each correlated reply, and the
exact-pending assertions compare with those incidental follow-ups filtered out.
The product is correct — the multiplex fuzzer and the sibling mirror suites build
multi-window mirrors and reorder/close them green.
Red/green: clean main fails the suite with 33 issues; with this it passes 14/14.
No product code changed. Broke in #7315.
* tests: address review findings on the mirror/reorder test fixups
From the CodeRabbit/Greptile pass:
- drainLeadingOther replied to every paneRects with a hardcoded `%0`; a
re-published @2/@3 needs its own pane id (the `windowId * 10` convention
publishWindows stages), or its pending layout can't publish.
- reorderPending filtered incidentals globally, so a paneRects landing BETWEEN
two list-windows (an ordering anomaly) would be elided and the equality
assertion would still pass. Trim only TRAILING incidental follow-ups; an
interleaved one now survives and fails the assertion.
- The mirror-targeting rects drain iterated a stale snapshot while each reply
consumes the FIFO head, so an incidental preceding a fetch could mis-correlate
pane data. Drain strictly from the head and stop at the first correlated command.
* tests: stop the reorder drains from swallowing correlated commands
Both drain helpers replied to whatever sat at the FIFO head, so a `listWindows` or
`windowReorder` arriving early was consumed with an empty reply and its later
positional result mis-correlated — the failure the drains exist to prevent. Each now
answers only the incidental follow-ups (`paneRects`, `.other`) and stops at the first
correlated command. `drainLeadingOther` also gains the bounded guard the other drains
already had.
---------
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
* tests: update the remote-tmux resolver assertion to the shared builder's argv
RemoteTmuxAuthTests/controlModeArgumentsUseRemoteTmuxResolverAfterDestinationGuard
fails on main. It asserts the remote command ends with
'cmux-remote-tmux' '-CC' 'attach-session' '-t' 'work session'
but #8442 generalized the tmux-specific resolver into RemoteExecutableCommandBuilder,
which passes the executable name and not-found sentinel as arguments:
'cmux-remote-executable' 'tmux' 'cmux-remote-tmux: tmux not found' '-CC' ...
The test's intent still holds — a destination that looks like an SSH flag is still
passed after `--` and the remote command still routes through the resolver — so only
the asserted literal was stale. Pin both halves instead: the command goes through the
resolver, and what it forwards is the tmux attach for this session.
* tests: pin the resolver's not-found sentinel in the control-mode argv
The resolver argv is 'cmux-remote-executable' <name> <sentinel> followed by the
forwarded arguments, so asserting the executable name and the tmux attach suffix
left the sentinel between them unpinned. Derive it from
RemoteTmuxHost.tmuxNotFoundSentinel so the assertion cannot drift from the
constant the resolver actually emits.
---------
Co-authored-by: ejc3 <[email protected]>
* cmuxTests: stop a fake WKNavigation from killing the test host
BrowserDiscardRestorePolicyCancelTests logs that it started and then produces no verdict at
all, which is what a dead host looks like rather than a failing assertion. That is why the
suite reads as consistent with the product when you go through it test by test: it does not
fail, it dies, and it takes every suite sharing the host down with it.
The cause is constructing WKNavigation directly. WebKit builds the embedded C++
API::Navigation itself, so a bare WKNavigation() carries unconstructed storage. Allocating one
is harmless; releasing it is not. Reproduced outside the test bundle, deterministically:
EXC_BREAKPOINT inside CFRetain from -[WKNavigation dealloc] with WebKit initialised, and
SIGSEGV through WebCoreObjCScheduleDeallocateOnMainRunLoop from the same dealloc without it.
The first death needs no window: the fake is stored as the pending restore navigation, the
next call clears that reference, and the release traps mid-assertion before anything prints.
That also explains why no output survives. The probe reproduced the missing-log signature
too: with stdout on a pipe, the crash discards the buffer, so even the line printed just
before it never reaches the log.
The bookkeeping under test only ever compares these by identity, so the fakes are minted
through a helper that keeps them retained for the run. No assertion changes. WKNavigation()
appears nowhere else in the repo.
Whether the eight tests then pass is a separate question this crash has been hiding.
* cmuxTests: the same suite closes a test-owned window AppKit also releases
Retaining the fake navigations got this suite far enough to run and pass several tests where
it previously produced nothing, which confirmed the first cause and exposed a second one in
the same file. One test builds an NSWindow, holds it through ARC, and closes it in a defer
without disabling AppKit's close-time release, so the last retain goes away underneath the
live references and the host aborts instead of a test failing. That is the same defect already
proven in the shortcut routing suite, and about forty other closing sites in cmuxTests
already guard against it.
---------
Co-authored-by: ejc3 <[email protected]>
closeWorkspace checks only that more than one tab is open, then runs its whole
teardown. It frees every panel's Ghostty surface, which SIGHUPs the child
processes, empties the workspace's panels and titles, clears owningTabManager,
and publishes a workspace-closed event. Membership in `tabs` is only enforced at
the very end, when the array element is removed; the recordHistory block does
look the index up earlier, but only to decide where in the history to record.
So handing a manager a workspace from another window kills that workspace's
terminals, strips its panels, and announces a close for a workspace that is still
open on screen.
#889 added this teardown and, directly above it, a `tabs.firstIndex(where:)`
guard, along with the test that covers this. A later "Reapply" merge kept the
teardown and dropped the guard, so the destructive half outlived its
precondition.
Two call sites already make this check themselves rather than relying on
closeWorkspace: AppDelegate re-checks `sourceManager.tabs.contains` before
closing a source workspace, and TerminalController records `existedBefore` and
skips candidates that fail it. Both predate #889, so they are not compensating
for the lost guard — they are evidence that callers have always needed this
precondition and have been paying for it individually.
One path does change. Workspace.swift resolves a manager as
`owningTabManager ?? tabManagerFor(tabId:) ?? AppDelegate.shared?.tabManager`,
and that last fallback is reached precisely when no manager owns the workspace.
Previously such a call tore the workspace down through an unrelated manager;
now it returns early, which is the intent of the guard.
testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager covers this and has been
failing: it hands the manager a foreign workspace and checks that the workspace
keeps its panel, which is the terminal that would otherwise be killed.
Co-authored-by: ejc3 <[email protected]>
* CMUXProjectModel: find the worktree root instead of counting directories
Every project-loading test in XcodeProjectAdapterTests fails under `swift test`:
Caught error: unreadable(file:///.../Packages/cmux.xcodeproj)
The suite locates cmux.xcodeproj by stepping up five parent directories from
its own #filePath. That was right when packages sat directly in Packages/, but
they now live under a group folder (Packages/macOS/CMUXProjectModel), so five
steps land on Packages/ and the adapter is handed a path that does not exist.
Seven of the fifteen tests in the package fail as a result.
Search upward for the directory that actually contains cmux.xcodeproj. Packages
are expected to move between the Shared, iOS and macOS group folders, so a fixed
depth breaks again on the next move while a search does not.
The search stops after eight directories rather than running to the filesystem
root. An unbounded walk out of a checkout that is nested inside another checkout
would find the outer checkout's cmux.xcodeproj and quietly test that project
instead. It also resolves symlinks on #filePath first, so a symlinked package
path still lands on a directory the walk can compare against.
When nothing is found the initializer throws and names what happened, instead of
handing the adapter a path it invented. Copying the package somewhere with no
cmux.xcodeproj above it now reports
cmux.xcodeproj is not in /.../Tests/CMUXProjectModelTests or in any of the 7
directories above it, so these tests have no project to load. Run them from a
cmux checkout, or point CMUX_PROJECT_FIXTURE at a directory that contains
cmux.xcodeproj.
and all nine tests in the suite fail. Two of them used to pass against the
missing path without loading anything: canLoad only inspects the path extension,
and the workspace test returns early when the file is absent.
* CMUXProjectModelTests: derive fixture siblings from the containing directory
CMUX_PROJECT_FIXTURE pointing at cmux.xcworkspace produced a projectURL nested inside
the workspace bundle, and a .xcodeproj override had the symmetric bug. When the override
names either bundle, its siblings now come from the directory that holds it.
---------
Co-authored-by: ejc3 <[email protected]>
loadChildren only checked cancellation after provider.listDirectory, so a
root reload triggered during an SSH provider swap left the cancelled local
load free to still call listDirectory, now through the freshly swapped SSH
transport, listing the old local path. Bail at the top of loadChildren when
the task is already cancelled, before any listing.
Co-authored-by: ejc3 <[email protected]>
controlSurfaceReorder passed the CLI's requested final tab position straight
to reorderSurface, but that API takes a bonsplit insertion gap. Moving a tab
to a higher slot needs index + 1 so the gap lands after the tab currently in
that slot; otherwise a move to sourceIndex + 1 is a silent no-op that still
reports success. Mirror the final-to-insertion conversion the other reorder
call sites already do.
Co-authored-by: ejc3 <[email protected]>
Three suites created an NSWindow and later closed it without opting out of
AppKit's close-time release, so each close over-released a window ARC still
held and took the whole test host down with it.
A dead host is worse than a failing test: the suite reports no verdict, and
every suite sharing that host loses its verdict too.
TerminalNotificationSocketActionTests 2 restarts, 0 of 7 tests ran -> 0 restarts, 7 pass
FilePreviewPanelTextSavingTests 2 restarts, 2 of 27 tests ran -> 0 restarts, 27 run
FilePreviewReviewFeedbackTests 1 restart, 11 of 17 tests ran -> 0 restarts, 17 run
FilePreviewPanelTextSavingTests closes a window in fourteen tests, all through
one private windowHosting helper, so the guard goes there rather than at each
call site. The other twenty-two suites in that file build windows and only ever
orderOut them, which does not release, so they need nothing.
Two of these suites still have assertion failures behind the crash that nobody
could see while the host was dying: three in FilePreviewPanelTextSavingTests
(24 of 27 pass) and one in FilePreviewReviewFeedbackTests (16 of 17 pass).
Those are separate bugs and get their own change.
The product already sets isReleasedWhenClosed = false everywhere it owns a
window; only these fixtures were missing it.
Co-authored-by: ejc3 <[email protected]>
* tests: pin the restore model these four unread tests were written against
Four session-restore tests in WorkspaceManualUnreadTests still describe the
pre-#2797-era restore model, where an unread notification present at snapshot time
was dropped and came back as a purely visual "restored unread indicator" with a
count of zero.
e4856922b0 changed that on purpose. Snapshots now carry the notifications
themselves, restore re-inserts them still unread, and the restored-unread
indicator is set only when a snapshot claims unread with no unread notification to
back it. Both gates are live: the workspace level checks
`snapshot.notifications?.contains { !$0.isRead }` before setting the indicator, and
the panel level does the same. Setting both would count one notification twice.
So these tests asserted an indicator that the product deliberately no longer sets,
and a count of zero for a notification the product deliberately keeps unread. They
now assert the restored notification directly and leave the indicator false, which
is the behavior the product implements. The independence the last two tests are
named for still holds: manual unread and a restored notification each contribute,
so the count is two until the manual half is cleared.
The assertions after markPanelRead and markRead are untouched, because marking read
clears the notification and the old expectations there were already correct. The
test names are unchanged; the CI shard timings key on them.
* cmuxTests: assert the combined unread count, not only its two flags
The independence test checked the manual indicator and the notification separately but
never the number they add up to, so a regression in either contribution could not move
a count this suite looks at. unreadCount(forTabId:) is the notification total plus one
for any workspace-level indicator, so this setup must read 2 before the panel is marked
read and 0 after.
---------
Co-authored-by: ejc3 <[email protected]>
A remote that runs tmux under a pty sends CRLF, because ONLCR rewrites every
newline on the way out. The session-list parser split on "\n" and then tried
to strip a trailing "\r", and neither step works: Swift treats CRLF as a
single Character, so the split finds no separator and `line.last == "\r"`
never matches, since the last Character of `...crlf\r\n` is `"\r\n"`.
The whole listing therefore parsed as one session whose name swallowed the
rest of the output, so such a host showed a single bogus workspace instead of
its sessions.
Split on any newline instead, which is what the sibling parser for the same
transport's stdout already does in RemoteTmuxVersion.swift. The strip and the
empty-line guard both go away, because split(whereSeparator:) omits empty
subsequences.
Before: RemoteTmuxSessionListParserTests, 8 tests, ** TEST FAILED **
After: RemoteTmuxSessionListParserTests, 8 tests, ** TEST SUCCEEDED **
Co-authored-by: ejc3 <[email protected]>
Two test files define a file-private `waitForCondition` that polls by hopping
through DispatchQueue.main under XCTWaiter, so the main queue keeps draining while
a test waits. The test target also has a module-scope one that polls with
Thread.sleep and runs no run loop at all.
Swift prefers the overload that fills in fewer defaulted parameters, so
`waitForCondition(timeout: X) { ... }` binds to the module-scope blocking helper --
it only defaults pollInterval, where the file-private one would also default file
and line. A call with no timeout: argument cannot bind to it and gets the pumping
helper. So adding a timeout silently changed which helper ran, and blocked the main
thread for the whole budget.
That is fatal for anything waiting on main-actor work. Three call sites pass an
explicit timeout and all three are red on main:
- testRemoteSplitSkipsInitialGitMetadataProbe and
testUnrelatedDefaultsChangeDoesNotRestartGitMetadataRefreshes wait for the
initial sidebar git probe to drain. That probe is registered synchronously at
schedule time and only clears once the ladder task, the snapshot, and its
MainActor.run apply get main-actor turns, so a blocking wait denies the very work
it waits for. Both fail on their first assertion, before reaching what they mean
to check, with the run wedged long enough that the crash reporter logged an ANR.
The product is correct in both cases.
- testFocusedPanelTitleRefreshesAutoWorkspaceTitleInSplitWorkspace waits for a
panel title to propagate. The .ghosttyDidSetTitle observer is registered with
queue: .main, so it runs as a queued main-queue operation rather than inline with
the post, and the apply is deferred again by the panel title coalescer's default
1/30s delay. A second of Thread.sleep starves both hops. Unlike the other two it
fails at the behavior the test is named after, with earlier assertions passing.
Renaming the blocking helper to waitForConditionBlocking makes all three resolve to
their file-private helpers again, with the same budgets and poll intervals. Its
three existing callers wait on a socket accumulator filled off the main thread, so
they keep the blocking form and are unaffected. A future waitForCondition(timeout:)
in a file without a pumping helper now fails to compile instead of quietly
blocking.
Co-authored-by: ejc3 <[email protected]>
* tests: expect composited terminal background colors
GhosttyBackgroundThemeTests and PanelAppearanceBackgroundTests still expect
GhosttyBackgroundTheme.color to hand back the configured color with the
opacity in its alpha channel. That was true until #3166, which routed the
helper through WindowAppearanceSnapshot.compositedTerminalColor: the color is
now blended over the window background, so the opacity lands in the RGB
channels and the result is always opaque. The tests were never updated, so all
three background-theme tests and one panel test have been failing since.
Compositing is the behavior we want -- chromeColorScheme derives a luminance
from this color, which only means something once it is opaque -- so the
expectations move to the composited values.
The expected blend is computed in the test rather than by calling the app's
resolver. Asserting that the resolver equals itself would agree by
construction and could never catch a compositing regression; recomputing the
blend keeps the assertions honest. Restoring the pre-#3166 withAlphaComponent
behavior locally turns all nine tests red, which is what we want from them.
* tests: enable the agent-chat flag for the menu grouping test
renderedContextMenuGroupsCreateLayoutsAndManagementTail requires a New Agent
Chat item in the new-workspace menu, but #7705 put that item behind the
agent-chat-ui-enabled-release flag with a default of off, so the item is absent
and the test fails looking for it. The two changes landed within hours of each
other -- the menu reorganization (#7709) went in first and the flag PR did not
pick up its new test.
Default-off is the intent of #7705, and this test is about where the create
entries sit relative to the Layouts section, so turn the flag on for the body
using the helper the suite already has for exactly this.
---------
Co-authored-by: ejc3 <[email protected]>
* Skip an inherited terminal surface the registry no longer owns
WorkspaceSplitWorkingDirectoryTests has two tests named for what they are meant to
prove — testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer and its split-path
twin — and both currently prove the opposite by killing the test host:
_os_unfair_lock_corruption_abort
_os_unfair_lock_lock_slow
ghostty_surface_inherited_config
Workspace.inheritedTerminalConfig(preferredPanelId:inPane:)
Workspace.newTerminalSurfaceLocal(...) / Workspace.newTerminalSplitLocal(...)
inheritedTerminalConfig guarded on `surface.surface != nil`, but a non-nil wrapper
pointer is not proof the native surface is alive. Teardown unregisters the runtime
surface and only then frees it, so a wrapper still holding the pointer after an
out-of-band free hands its caller freed memory. libghostty then locks an
os_unfair_lock inside that freed allocation, the kernel detects lock corruption, and
the process is SIGKILLed — so an unrelated suite sharing the test host dies too, and
its verdict is lost with it.
The registry owns exactly the pointers that have not been freed, which makes it the
liveness oracle a nil-check cannot be, and TerminalSurfaceRegistering already exposes
runtimeSurfaceOwnerId. TerminalSurface gains liveRuntimeSurface, which returns the
pointer only while the registry still owns it and otherwise clears the wrapper, so
every caller that reads through it sees nil instead of freed memory. Clearing goes
through the existing setter, so it advances runtimeSurfaceGeneration exactly as a
normal teardown does and pointer-backed caches invalidate for the same reason.
inheritedTerminalConfig now reads liveRuntimeSurface, so a stale candidate is skipped
like a torn-down one. That is also the behaviour the tests' second assertion asks for:
XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be
quarantined").
* Use liveSurfaceForGhosttyAccess for the inherited-config liveness check
Review pointed out that the new liveRuntimeSurface accessor checked only that
the registry owner was non-nil, so a recycled pointer owned by a different
surface would pass. liveSurfaceForGhosttyAccess already does the full check —
owner id equality plus an allocation-liveness probe — and quarantines a stale
wrapper the same way. Delete the weaker duplicate and call the existing
accessor from inheritedTerminalConfig; a stale candidate is still skipped in
favor of the font-lineage fallback.
---------
Co-authored-by: ejc3 <[email protected]>
Seven test call sites constructed a bare SavingTextView(). The product never
does: makeFilePreviewTextView() builds an explicit TextKit 1 stack, because a
default NSTextView is TextKit 2 and its selection path pegged the main thread on
large documents (#4576, #5255). A bare init therefore has no configured text
container, and it also skips applyFilePreviewTextEditorInsets(), which the
factory applies.
So these tests exercised a view the app refuses to ship, and failed on it: the
save-shortcut tests read back an empty string instead of the saved text, and the
inset test read nil where it expected a value.
The files already disagreed with themselves. FilePreviewReviewFeedbackTests used
the bare init at line 44 and the factory at line 408, CanvasShortcutContextTests
uses the factory at three sites, and FilePreviewTextEditorTextKitTests exists to
assert the factory yields a pure TextKit 1 view.
FilePreviewPanelTextSavingTests 27 tests, 3 failures -> 0 failures
FilePreviewReviewFeedbackTests 17 tests, 1 failure -> 0 failures
Both suites need the window-release guard in #8701 to reach these assertions at
all; without it they crash the test host first. Both arms above were measured
with that guard applied.
Co-authored-by: ejc3 <[email protected]>
* tailscale: reject non-canonical (leading-zero) IPv4 when classifying peers
parseIPv4 accepted "0100.64.1.2"-style leading-zero octets as decimal on
Darwin versions with a lenient inet_pton, so a host the dialer's inet_aton
reads as octal was classified as a Tailscale peer. Require the parse to
round-trip through inet_ntop to canonical dotted-decimal and refuse any
spelling that does not. IPv6 is unchanged.
* tailscale: reject non-canonical (leading-zero) IPv4 when classifying peers
parseIPv4 accepted "0100.64.1.2"-style leading-zero octets as decimal on
Darwin versions with a lenient inet_pton, so a host the dialer's inet_aton
reads as octal was classified as a Tailscale peer. Require the parse to
round-trip through inet_ntop to canonical dotted-decimal and refuse any
spelling that does not. IPv6 is unchanged.
---------
Co-authored-by: ejc3 <[email protected]>
branchTokensForSearch split a branch ref on the metadata delimiters (which
include "-"), so "feature/cmd-palette-indexing" tokenized to feature/cmd/palette/
indexing and the hyphenated short name never appeared in the search index; typing
the branch short name found nothing. Emit the part after the last "/" as a whole
token too, the way a directory basename already is.
Co-authored-by: ejc3 <[email protected]>
* sidebar-git: fix four unsatisfiable tests in the sidebar git suites
Two of them describe a git index that git cannot produce. The index trailer is the
SHA-1 of the index content, so rewriting only the trailing checksum while leaving
the entry table byte-identical is not a state a real repository reaches. The
product reads that shape deliberately: the index content signature covers the
entry count, path, mode and object id but not the trailer, so an index whose
content signature is unchanged is rebaselined as clean, which is exactly what
testCleanIndexSignatureRebaselinesWhenIndexRewriteKeepsTrackedContentClean pins.
The v4 and empty-index tests asserted the opposite for the same input, so one of
the two had to fail. They now stage a real change -- a new object id, and an added
entry -- whose stat still matches the worktree, so the dirty verdict comes from the
content signature the way it does for a real staged change.
The predicates are unchanged; the empty-index test's scenario and message move
from a staged delete to a staged add, because staging the first entry out of an
empty index is what actually moves the content signature.
writeGitIndexVersion4 gains the objectIDBytes parameter that
writeGitIndexVersion2EntryFromStat already had. It defaults to the zero id, so the
test call sites that do not stage a change need no edit; the convenience overload
threads it through. writeGitIndexVersion3SkipWorktreeEntry still
hard-codes a zero object id; it has no need to express a staged change.
testDisablingGitWatchClearsCachedPullRequestBadgesWhenPullRequestsAreShownByDefault
sent a scoped report_git_branch to a TabManager that only TerminalController knew
about. That path resolves its workspace through AppDelegate's main-window
contexts, so the report was dropped and the seeded badge survived. The test now
registers a windowless context like the other socket-routing tests, and asserts
the workspace is resolvable before sending, so a future wiring break reports there
instead of at the far assertion.
testSameDirectoryInitialGitMetadataProbesShareOneSnapshotRead waited with a helper
that blocks the main thread while pumping the main queue. That works in a
synchronous test, but this body is async: it runs as a main-actor job, inside a
main-queue drain that libdispatch will not re-enter, so the nested run loop ran
neither the helper's own poll hops nor the snapshot's MainActor.run apply. The
wait could only expire. It now awaits a suspending sibling helper with the same
timeout and interval.
The suspending helper propagates cancellation rather than swallowing it, so a
cancelled test unwinds instead of spinning the condition until its deadline.
* test: use a monotonic clock in the suspending wait helper
---------
Co-authored-by: ejc3 <[email protected]>
`swift test` in Packages/macOS/CmuxNotifications does not build:
NotificationDismissalModelTests.swift:47:5: error: missing return in
instance method expected to return 'UUID?'
A test double's panelId(forSurfaceOrPanelId:in:) bumps a counter and then
leaves its lookup as a bare expression. Two statements means no implicit
return, so the whole target fails to compile and all 66 tests in its 5 suites
are silently skipped.
Return the lookup. Nothing else in the target changes.
Co-authored-by: ejc3 <[email protected]>
* test: full unacked browser-stream window must recover, not deadlock
A full window whose acknowledgements never arrive (subscriber not yet
wired at start, connection route swap) currently parks the stream in
.flowControlled forever: the phone shows 'Waiting for Browser' with no
recovery path. Red on purpose; the fix lands in the next commit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: make the mobile browser stream self-healing
Three liveness fixes for the iOS browser mirror, all Mac-side:
Ack-stall recovery: a full unacked window now converts to a bounded
wait and, after 3s with no ack progress, is abandoned in favor of a
fresh capture. Frames are self-contained images, so recapture is a safe
retransmission. Previously lost in-flight frames (subscriber not wired
yet, connection route swap) deadlocked the stream and the phone sat on
'Waiting for Browser' forever.
Synchronized first capture: the first snapshot for a (re)hosted web
view now waits for WebKit's next committed render (bounded by a 1s
timeout with a 3-attempt fallback) instead of capturing the blank white
uncommitted buffer that used to be the phone's first frame. All other
captures get a 2s timeout so an occluded render host cannot wedge the
drive loop on a synchronized settle snapshot.
Idle reconciliation: an idle stream emits one lossless settle frame
after 10s of quiet. Page-driven dirty signals are lost whenever WebKit
suspends requestAnimationFrame for the occluded offscreen host, which
killed the injected beacon and froze the mirror on stale or blank
content; this bounds any such staleness to 10s.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: scope capture commitment to the captured web view
A capture that raced a web view replacement must not mark the
replacement's synchronized first capture as done (its first frame would
be the blank bitmap again), nor consume its bounded retry budget.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add regression test for cancelled last-terminal close
* Restart last terminal when close is cancelled
* Allow quit-cancel flow in debug UI tests
* Keep terminal recovery in the close transaction
* Always confirm quit after the last terminal exits
* Preserve terminal recovery with window docks
* Keep quit recovery inside the close request
* Document last-terminal quit confirmation invariant
* Clear close state only after cancellation
* Join terminal recovery to active quit decisions
* Test the last-terminal quit decision end to end
* Preserve notifications until quit commits
* 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]>
The Browser* suites in cmuxTests had 18 failures across 9 suites under a local
headless `xcodebuild test`. Three were real product bugs the tests had been
catching all along; the rest were tests asserting behavior the product had
deliberately moved away from, or waiting on the wrong signal.
Product fixes:
- A panel constructed with a URL but `renderInitialNavigation: false` kept the
`.newTab` lifecycle state it was born with. The deferred path returns from
`init` before any visibility or navigation transition runs, and nothing else
seeds the state, so a restored deferred tab reported itself as a new tab.
Seed it in `init` for both the request and URL paths.
- The legacy `browserForcedDarkModeEnabled` migration could never run. Fallback
registration goes into the process-wide registration domain, so once any panel
bootstrapped defaults, `browserThemeMode` always resolved to a value and
`BrowserThemeSettings.mode(defaults:)` took its early return instead of
migrating. Users upgrading with forced dark mode on silently lost the setting.
The key does not need a registered fallback: the accessor already falls back to
`defaultMode` and the SwiftUI binding carries its own default.
- A visible portal slot whose anchor was removed outright kept rendering against
the dead anchor. The off-window-reparent branch already distinguished an anchor
that is still parented (drag churn, keep it on screen) from one that is not,
but the following line preserved the slot unconditionally, so the orphaned case
never reached the hide-while-retrying path.
Test fixes:
- Under-page background and hidden-discard-delay expectations predated the
behavior they assert: the terminal color is composited over the window
background rather than alpha-blended, and an out-of-range stored delay is
rejected in favor of the default rather than clamped to the maximum.
- The discard tests waited on `webView.isLoading` while the discard gate also
reads the panel's own `isLoading`, which stays set for the minimum indicator
duration after WebKit finishes. Wait for the condition the gate actually reads,
and report the blockers when a discard is refused.
- `waitForBrowserPanel` accepted the omnibar URL, which the panel publishes as
soon as a navigation is requested and before `isLoading` rises, so it could
return before the page loaded at all. Wait for the web view's committed URL.
- The remote-store tests assumed the built-in default profile was ambient, but a
panel without an explicit profile adopts the last-used one, and that selection
is persisted. Pin the default profile, and delete temporary test profiles so
they stop accumulating in the shared defaults.
- The portal reveal test still required a visibility change to cycle WebKit's
`_exitInWindow`/`_enterInWindow` pair, which was removed on purpose because
cycling it fires visibilitychange and broke the DevTools pane across workspace
switches. It now asserts that invariant instead.
- The omnibar suggestions hit test built its point by flipping y by hand, but
`hitTest` takes superview coordinates and the flipped hosting view disagrees
with its unflipped slot about y. Convert through AppKit and host the slot in a
window so the SwiftUI overlay answers hit tests.
- `testBackgroundPreloadIsConsumedByInitialNavigation` built an NSWindow with
AppKit's default `isReleasedWhenClosed` and closed it, so the window was
over-released and XCTest's memory checker walked the freed object at teardown
and took the test host down with a SIGSEGV in `objc_release`. The host restart
was also hiding tests: the suites now report 54 tests instead of 34.
* 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]>
Publish SDK contents through the one npm workflow already authorized for the shared cmux package name, with protected-main, version, verification-run, source-drift, and explicit-confirmation gates.
* Bump ghostty: fix os/open stderr drain spin and zombie leak
Points at manaflow-ai/ghostty fix-open-stderr-spin (8f31fb57c).
openThread drained a child's stderr with takeDelimiterExclusive, which
advances only up to the delimiter and then returns a zero-length slice
forever. The loop spun at 100% CPU after the first stderr line, emitting
empty 'open stderr=' records until macOS throttled the process-wide logging
firehose, and never reached wait(), so the child was never reaped.
Measured live on 0.64.20: 11 zombie children matched one-for-one by 11
threads at ~12.4% CPU each, ~95% of the process's total (500-600% observed),
each 94-97% inside zig_os_log_with_type.
* Pin GhosttyKit checksum and document the os/open fork fix
tests/test_ci_ghosttykit_checksum_present.sh requires an entry for every
pinned ghostty SHA; without it ensure-ghosttykit.sh falls back to a local
zig build, which fails on the macos-26 runners used by the iOS lanes.
* test: closing a group's anchor should keep members grouped
Adds a failing regression test proving that closing a workspace group's
anchor currently scatters the group's remaining members out to the
ungrouped root tier instead of deleting only the closed workspace and
keeping the group intact.
The scaffold `promoteAnchorOrRemoveGroupsAnchoredBy` still delegates to
the old `dissolveGroupsAnchoredBy` behavior, so the "keep group" test is
red here and goes green with the fix in the next commit.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Keep group intact when closing its anchor workspace
Closing a workspace that anchored a group previously dissolved the group
and scattered its remaining members out to the ungrouped root tier. From
the user's view, deleting a workspace inside a group left workspaces
sitting at root instead of deleting cleanly.
Closing a group's anchor now promotes the group's next member (in tabs
order) to be the new anchor and keeps the group intact, so closing one
workspace only closes that workspace. A group with no members left after
the anchor's removal is dropped. The cross-window detach path keeps the
old dissolve semantics (a workspace moving to another window has no group
there to belong to).
Because closing the anchor is no longer destructive to grouping, the
special "Closing this workspace will ungroup and release N workspaces"
confirmation and its anchorCloseSuppressed opt-out are removed; the normal
running-process close confirmation still applies.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address autoreview: fix wired test, refresh promoted-anchor title
- Update the wired app-target WorkspaceGroupTests: replace
closingAnchorWorkspaceDissolvesGroup (asserted the old scatter-to-root
behavior) with closingAnchorWorkspacePromotesMemberAndKeepsGroup and add
closingSoleAnchorWorkspaceRemovesGroup, so the CI-executed unit target
covers the new behavior.
- promoteAnchorOrRemoveGroupsAnchoredBy now returns the promoted anchor ids;
closeWorkspace refreshes the selected window/toolbar title when the
selected workspace was promoted (its resolved title switches to the group
name), which the imperative title chrome would otherwise miss on a
non-focused anchor close (Close Others / socket close).
- Correct the AppKit sidebar middle-click comment: a group header is
intentionally not a workspace-close target (parity with the SwiftUI
sidebar), and group lifecycle runs through the header menu, not middle
click. Behavior unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address autoreview round 2: full title invalidation + doc sync
- closeWorkspace now publishes both workspaceGroupNameDidChange and
workspaceOrderDidChange when an anchor close promotes a member, so the
cached title consumers (custom titlebar, WindowToolbarController label,
notification popover) refresh, not just NSWindow.title. Fixes stale titles
on non-focused anchor closes (Close Others / socket close).
- Sync the behavior docs that still described the removed dissolve+confirm
flow: CLI `workspace-group` help, docs/workspace-groups.md, the
WorkspaceGroup DocC comment, the SessionPersistence anchor-field comment,
and the web docs anchorClose string (en + ja, the maintained web catalogs).
The detach-path comments keep the dissolve wording (that path is unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address autoreview round 3: batch close ordering + doc contracts
- Batch close (Close Others / range close) now drains non-anchor members
before group anchors, mirroring group deletion's ordering. Iterating in raw
tabs order (anchor first) re-promoted and renormalized the whole
tabs/groups collection once per targeted member — O(k x totalTabs) main-actor
work plus a burst of title/order invalidations. Members-first leaves at most
one promotion per group (or none when the anchor was the last member).
- Fix the anchorWorkspaceId property doc (still said closing dissolves the
group) and scope the web anchorNew copy (en + ja) to creation time so it no
longer reads as contradicting the new anchorClose promotion behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address autoreview round 4: shared anchor-last batch helper + CLI l10n
- Factor the anchor-last close ordering into TabManager.anchorLastCloseOrder
and route both batch-close entrypoints through it: the menu/shortcut path
(closeWorkspacesWithConfirmation) and the socket path
(TerminalController v2WorkspaceAction close_others/close_above/close_below,
via its closeWorkspaces helper). Previously only the menu path was fixed, so
socket range closes kept the O(k x totalTabs) promote-and-rescan-per-member
regression.
- Localize the workspace-group CLI overview paragraph via
cli.workspaceGroup.help.overview (English + Japanese in Localizable.xcstrings),
matching the adjacent already-localized help fragments instead of leaving the
updated anchor-close explanation English-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address autoreview round 5: close-plan title + drop stale locale overrides
- closeWorkspacesPlan now lists each target by its resolved display title
(group name for an anchor) instead of the raw Workspace.title, so a
destructive multi-close confirmation labels a promoted anchor by the header
the user sees ("Build") rather than its underlying title ("API").
- Remove the now-false anchorNew/anchorClose overrides from the 18 non-en/ja
web locale catalogs. loadMessages deep-merges each locale over the English
base, so those explicit overrides masked the corrected English fallback and
showed users the removed dissolve+confirm behavior. Dropping them makes each
locale fall back to the corrected English until native translations land
(tracked in issue 8934).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address CodeRabbit: pin deterministic anchor-promotion order in docs + tests
Make the "which member is promoted" contract explicit everywhere it is
described, and assert it in tests instead of accepting any member.
- WorkspaceGroup.swift / TabManager.swift / web docs (en+ja): the anchor
close promotes the group's FIRST remaining member in tabs order, not an
arbitrary "next member".
- cmuxTests: assert the promoted anchor equals the first remaining member id.
- CmuxWorkspaces package test: use two members (a before b) and require `a`
is promoted, proving the ordering rather than membership alone.
No runtime behavior change (doc comments, docs-site copy, test assertions).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Address autoreview round 7: drain live anchor last in confirmed group deletion
deleteWorkspaceGroup(confirmed:) sorted its batch close against the
confirmation snapshot's anchorWorkspaceId. Because the confirmation runs a
nested modal loop, another entrypoint can close the snapshot anchor first,
promoting the next member to live anchor. On acceptance the batch then closed
that live anchor mid-sequence, which re-promotes and renormalizes the whole
collection on every step — the O(k x totalTabs) main-actor churn the sibling
anchorLastCloseOrder helper exists to prevent.
Resolve the group's live anchor at acceptance (fall back to the snapshot when
the group is already gone) and sort it last, so each non-anchor close stays
cheap and at most one promotion runs before teardown. Adds package coverage
proving the live anchor closes last after the snapshot anchor is closed during
confirmation.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
mobile-workspace-changes-enabled-release (PostHog, registry in
CmuxFeatureFlags) now gates the feature at its single host-side choke
point: when off, mobile.host.status omits workspace.changes.v1 and the
five mobile.workspace.changes.* RPCs answer capability_disabled through
one shared router guard. Every iOS entry point (workspace-row chip,
toolbar button, one-time hint, Changes sheet, summary polling) already
feature-detects on that capability, so the phone UI turns off with no
iOS change and a stale cached capability list cannot call through.
Status payloads are served off the main actor, so the flag publishes a
lock-protected off-main snapshot from the shared instance; readers fall
back to the per-flag compile-time default before it exists. Release
defaults off until the PostHog flag enables it; DEBUG defaults on for
dogfood, matching the cloud-vm-ui and pro-upgrade-ui convention.
Co-authored-by: Claude Fable 5 <[email protected]>
* Compact CLAUDE.md and skills docs
Cut ~2k lines of duplication without dropping actionable rules.
CLAUDE.md (267 -> 103): `reload.sh --tag` was explained four separate
times; now once. Dropped the Ghostty submodule and Release sections,
which restated the cmux-ghostty and cmux-release skills, and removed the
file:// deeplink block, which contradicted the rule that chat output
uses http://127.0.0.1:17320/<tag> and never a file:// URL. Pitfalls
compressed from paragraphs to one line each, pointing at the owning
skill.
skills/ (4849 -> 2819 across 20 skills): the dominant waste was SKILL.md
files restating their own references/ verbatim. Kept one canonical
statement with expansion in references/.
Three rules the root file carried had no skill that covered them, so
they moved into cmux-architecture rather than being lost: SPM package
group folders with check-workspace-package-groups.py, the
Package.resolved tracking policy, and "feature flag means a remote
PostHog runtime flag" via CmuxFeatureFlags. The cmuxTests pbxproj wiring
requirement was promoted from a reference file into cmux-testing, and
the shortcut policy moved into cmux-keyboard-shortcuts with its
duplicate removed from cmux-localization.
Stale references fixed:
- `cd cmuxd && zig build` referenced a directory with zero tracked
files; the daemon is Go at daemon/remote/cmd/cmuxd-remote.
- Changelog page is web/app/[locale]/(landing)/docs/changelog/page.tsx,
and configuration is under the same (landing) segment.
- Package CmuxSocketControl does not exist; the real one is
CmuxControlSocket, cited twice as the exemplar to copy.
- Two rg commands in the localization audit were double-escaped and
passed `--` as if it were a glob flag, so they matched nothing and
silently passed the audit.
Left untouched: the auto-generated cmux-settings reference files, which
would drift from their generator.
* Dedupe release slash commands
release.md, release-local.md, and release-nightly.md each restated the
same version-bump and changelog procedure (450 -> 192 lines total).
release.md is now the canonical command doc holding the shared prep,
changelog guidelines, and contributor-credit format; the other two state
only their delta (local build-sign-upload.sh path, and no-PR direct-to-
main path with the homebrew-cmux submodule pointer commit).
Stale and incorrect instructions fixed:
- All three pointed at docs-site/content/docs/changelog.mdx. There is no
docs-site/ in the repo; the changelog page renders from CHANGELOG.md.
- release.md said to hand-edit 'typically 4 occurrences' of
MARKETING_VERSION in project.pbxproj. That leaves
CURRENT_PROJECT_VERSION stale, which Sparkle requires to be monotonic
and which release-pretag-guard.sh rejects. Unified on
scripts/bump-version.sh, which bumps both.
- Documented build-sign-upload.sh --allow-overwrite, which matters
because pushing a v* tag also fires release.yml, so a local upload can
race CI for the same assets.
* Address review findings on submodule remotes and build links
- tagged-builds.md still told contributors to build chat links from the
absolute .app path with a file:// URL, contradicting the rule in
CLAUDE.md that chat links use http://127.0.0.1:17320/<tag>.
- cmux-ghostty said 'origin is upstream and manaflow is the fork' and
pushed to a 'manaflow' remote. .gitmodules points every submodule at
manaflow-ai/*, and no checkout has a 'manaflow' remote, so those
commands would fail. Both the skill and submodule-safety.md now tell
you to check git remote -v, and document adding an explicit 'upstream'
remote for syncing from ghostty-org.
- submodule-safety.md verified ancestry against <remote>/main even when
a feature branch was pushed. Now checks the branch actually pushed.
- release.md credited @lawrencechen; the account is @lawrencecchen.
Skipped, with reasons: the ~/.agents/skills vs ~/.codex/skills split in
cmux-customization is the documented convention (normal install vs
skills.sh install), matching cmux-diagnostics. Adding per-entry
attribution to the un-credited changelog example entry would contradict
the policy three lines above it, which exempts core-team work.
* test(tui): cover Linux package portability
* fix(tui): ship portable Linux packages
* ci(tui): gate package publishes on Linux tests
* ci(tui): test Linux packages on ARM64
The focused-read indicator marks notifications that arrived on the
focused surface and were read immediately; it is designed to outlive
mark-read and only clearFocusedReadIndicator dismisses it, which
FocusedNotificationIndicatorTests has pinned since March. 26cf12eaa2
(the manual-unread jump flash) added an unconditional clear to both
markRead overloads while its stated scope was the jump flash; the
surface-scoped clear broke that contract and the suite has been red
since May 26 behind dispatch-only CI. Bisected to a verified edge:
green at 26cf12eaa2^, red at 26cf12eaa2, same assert as today's main.
Whole-tab mark-read (surfaceId == nil) still dismisses every indicator
kind, matching markRead(forTabId:); only the surface-scoped path keeps
the indicator.
33e5380de8 sized mermaid SVGs up by the viewer zoom because pageZoom
only scaled text metrics and left fixed-px SVG bounds alone. Newer
WebKit scales the whole CSS viewport under pageZoom, so that
compensation renders diagrams zoom-squared - at 2x zoom the SVG paints
at 4x and clips. The shell now detects the engine from the
viewport-width edge across a zoom change (the native side sets
pageZoom immediately before syncing the shell): on viewport-scaling
engines it pins the zoom-1 fitted size and lets the engine multiply,
which keeps the wide-diagram contract (overflow and scroll at the
enlarged size) instead of re-fitting to the shrunken viewport; on
older engines the manual sizing is unchanged.
The zoom test measured raw CSS rects, which only grow under the old
engine's model. It now multiplies by the viewport shrink factor, which
is the on-screen size under either engine, and keeps the
diagram-tracks-prose ratio assertion unchanged.
70bcbda20e decoupled settings appearance replay from Ghostty reload:
the file store imports appearanceMode into UserDefaults and the
app-lifecycle observer owns live application, so the store cannot
re-enter Ghostty while this singleton initializes. e788e56063 (Agent
Hibernation) re-added the pre-decoupling machinery verbatim in a
rebase, without touching the tests that pin the decoupled contract.
This restores the decoupling, and the startup suite now silences the
global appearance-defaults observer so it measures only what the
store itself does.
f4f420fd3c migrated pi snapshots to decode as the custom "pi"
registration but never gave the custom branch a pi bridge, so resume
fell through to the registration template and dropped every
sanitizer-preserved launch argument (--session-dir, --model, and the
rest). Campfire and kimi already have this exact bridge; pi now gets
the same one, guarded so a user-customized resume template stays
authoritative.
- CLIRemoteShellStartupPerformanceTests: the fake ssh only recognized a
positional remote command spelled literally /bin/sh -c, but since
98a701ffd9 the staged installer arrives per-word quoted
('/bin/sh' '-c' ...), which real remote shells parse fine. The fake
silently skipped staging, the main hop failed, and the marker never
appeared, so the test read as the shell blocking on relay warmup when
nothing on the startup path waits on it. The fake now accepts both
spellings.
- GhosttyBackquoteRegressionTests: without an active input context,
interpretKeyEvents consumes the synthetic ESC as insertText, filling
the key accumulator and starving the textForKeyEvent tilde fallback
under test. Stub debugTextInputEventHandler like the focus-reassertion
suite, and restore the key-event observer the test previously leaked.
Three single-assertion repairs, each pinned to the product change that
made the old expectation stale:
- BrowserWindowPortalLifecycleTests: a visibility-only portal reveal
refreshes presentation but deliberately skips the enter/exit-window
reattach lifecycle (c52bd24e85), so the reveal assertion now expects
the reattach count to stay put.
- GlobalSearchShortcutSettingsTests: recording the configured show/hide
hotkey is now rejected as a conflict with that action rather than the
vaguer reservedBySystem (64ab9f1ebb).
- NotificationDockBadgeTests: delivery resolves its target from live
identity (d64f6056a1), so the click-action round-trip test now
installs a TabManager and addresses a workspace it owns instead of a
random UUID no manager knows.
Follow-up cleanup on the mock control-socket rework.
The accept loops had no way to stop. Closing the listener FD does not wake a
thread already parked in poll/accept on Darwin, so every server leaked its
thread for the life of the test process — worse than the old bounded loops,
which at least self-terminated once they had accepted their quota. The registry
now owns each loop: it pairs the listener with a private stop pipe, and
`stop(listenerFD:)`/`stopAll()` signal the loop and join it. Both suites reap
their loops in tearDown.
Registry hardening:
- Hold the lock across retire-old and register-new. Two concurrent starts on one
FD could each observe the same predecessor, each wait for it, then each spawn a
loop, putting two loops back on one listener — the stealing bug returning by
another door. The loop threads never take the lock (they only signal
completion), so holding it across the join can't deadlock.
- The registry, not the loop, owns the stop pipe for the loop's whole life, so a
stop byte can never land in an unrelated descriptor that reused the number.
- Check pipe(); without a stop pipe a loop would be unstoppable, so fail loudly
rather than start one. A loop that ignores its stop byte now fails the test
instead of being left running.
Consolidation:
- One `cliMockServeLineFramedConnection` reader replaces four copies of the
read/frame/respond loop, and one `cliMockWriteAll` replaces the duplicated
partial-write handling. `CLIMockOnceFlag` replaces the two identical latches.
- Drop `connectionCount`/`connectionLimit` from the servers that no longer bound
connections, along with the dead default, and rewrite the comments that still
described a fixed pool. Same-named helpers owned by other suites keep their
live parameters.
The CLI hook integration suites drive the bundled cmux helper as a subprocess
against a mock control socket. Headless (piped stdio, no controlling TTY) the
helper always falls back to a `system.top` agent-process lookup on a second,
dedicated control connection because caller-TTY resolution can't succeed. The
mocks accepted only one connection, so that extra connection was starved: hooks
stalled for the 2s socket timeout, resolution fell back to unresolved routing,
and assertions saw the wrong RPC sequence (or empty output after a 5s process
timeout).
Rework the mock accept path so every connection the helper opens is serviced:
- A single poll-based accept loop per listener FD dispatches each connection to
its own handler, and a new server on the same FD supersedes (stops and joins)
the previous one so a leftover loop can't steal the next hook's connection and
fulfill the wrong expectation.
- The loops run on raw threads instead of GCD queues. A blocking accept() parked
on a GCD worker ties it up for the whole test; a suite that opens a server per
hook drained the shared GCD pool that runProcess needs for its stdout/stderr
readers and exit waiter, which looked exactly like the helper hanging.
Also:
- Bind the tmux-compat-env test's control socket under a short /tmp path. The
AF_UNIX sun_path limit is 104 bytes and this machine's temp dir alone overflows
a socket nested under it.
- Update the default-freestyle vm-new tests to expect vm.attach_info: `vm new`
uses forceSSH:false, which resolves through vm.attach_info (already covered by
the SSH startup suites), not the older vm.ssh_info path.
FileDropOverlayViewTests builds a real ContentView but hands it only some of the
environment objects ContentView declares. SidebarUnreadModel is missing, and
SwiftUI treats a missing @EnvironmentObject as a fatalError rather than a nil, so
the process dies while the view is being made.
That does not read as a failing test. The app host exits, xcodebuild restarts it,
and every verdict still pending in that host is discarded, so the suite reports
three tests where it has more and the count looks like a fact.
ContentView declares the model at ContentView.swift:818 and again at :10413, and
TerminalNotificationStore owns the instance at TerminalNotificationStore.swift:376,
so the test passes that store's model rather than constructing a detached one.
testSessionsListTreatsTranscriptBackedClaudeRecordAsRestorable failed on a loaded
builder with:
Expectation failed: !(result.timedOut)
ProcessRunResult(status: 0, stdout: "{ ...complete sessions JSON... }", timedOut: true)
The command had succeeded. It exited 0 and emitted the full, correct JSON the rest of the
test then parses; it just took 6.0s against a 5s budget while sixty other suites were
running on the same machine. The same commit passed on an idle box, so the suite's verdict
was decided by machine load.
These cases fork the real cmux binary and wait for it to emit JSON, so their wall-clock
cost is a property of the host, not of the behaviour under test. The timeout is there to
catch a hang, and the assertions that follow each call already grade correctness: status,
then a JSON parse, then the field checks. A process killed at the deadline cannot satisfy
those, because its output is truncated.
So the budget moves to a named constant set far above any healthy run, with the reasoning
next to it. Same seven call sites, no behavioural assertions changed.
Note the rest of cmuxTests carries 490 more `timeout: 5` call sites across 30 separately
copy-pasted runProcess helpers. That is the same trap thirty times over and wants one
shared helper, but it is a refactor of its own rather than a rider on this fix.
* Make iOS terminal scrolling local with screen-anchored render grids
Scrolling on the phone previously round-tripped every scroll frame to the
Mac: the RPC scrolled the Mac's real (shared) viewport, the row diff saw
every row shifted and emitted a full-viewport clear+repaint delta, and the
phone applied each one through the serialized freeze -> VT apply -> Metal
fence -> reveal pipeline. Fast flicks outran that pipeline (late, chunky
rendering), rejections opened replay barriers that dropped deltas until a
full replay, and 2s timeouts rebuilt the surface blank - the visible blink.
This makes the primary screen scroll like the Mac app: shared screen state,
per-device viewport.
Ghostty fork (render-grid-screen-anchor): the render-grid export gains an
active-area anchor plus history_rows / row_space_revision metrics, so frames
are independent of the Mac's scroll position.
Mac producer: connections negotiate render_grid_anchor at subscribe time and
the observer emits per-anchor payloads (old clients keep viewport mirroring
byte-for-byte). Screen-anchored deltas turn history growth into exact
scrolled-row counts via a shifted row-signature diff; bursts re-export the
missed history rows; fulls carry deep scrollback (4000 rows) so replay
resets preserve the phone's local history. mobile.terminal.replay honors
anchor + max_scrollback_rows.
iOS consumer: primary-screen scrolling applies only to the local mirror
(no RPC; alt screen still forwards wheel input). Scroll deltas replay as a
scroll prologue - line feeds at the bottom row push rows into the mirror's
own scrollback, bursts flow missed rows through the grid - then repaint only
changed rows. Screen-anchored primary deltas skip the per-frame verified
fence (fulls keep it); scrolling deltas are never queue-superseded; and each
delta chain-links the producer's previous history count so any missed frame
triggers a full replay instead of silent misalignment. Mirror scrollback
limit raised 2MB -> 8MB to hold the hydration budget at wide grids.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix frozen iOS presents and make replays history-preserving
Second slice of the smooth-scroll work, on top of the screen-anchored
protocol commit.
The frozen-pixels root cause: iOS render_now produces frames on one serial
queue, so ghostty's first-free swap-chain scan handed every frame the SAME
IOSurface, and Core Animation dedupes same-object contents assignments -
the terminal state scrolled (scrollbar callbacks marched at 60Hz) while the
screen kept stale pixels. Fixed in the ghostty fork by rotating swap-chain
slot selection (frame_lease.zig) so consecutive frames present distinct
surfaces. This also explains long-standing 'renders too late' complaints:
any render burst without other main-thread layer activity could freeze.
Screen-anchored fulls without scrollback now replay as history-preserving
in-place repaints: no ESC[3J, no line-feed flow, so mid-stream resets
(theme changes, replay barriers, resyncs) no longer destroy the phone's
accumulated scrollback or yank a locally scrolled viewport. Hydrating fulls
(scrollback rows present) and v1 fulls keep the reset+flow. Event-lane
fulls stay scrollback-free; only replays for a mirror that lost its history
(cold attach, rebuilt-blank surface) request the 4000-row hydration window,
tracked per surface, so replay-barrier churn during streaming stays cheap.
The Mac adopts each screen-anchored replay as the new delta-emission
baseline, so the next delta's history chain links from exactly the state
the phone applied, even mid-stream. Verified read-back for screen-anchored
frames exports the active area (v2 export) so a locally scrolled viewport
cannot spuriously fail verification.
Adds a DEBUG-only scripted flick harness (Darwin-notification triggered)
that drives the production scroll pipeline below the gesture recognizer,
used to verify 4000-row fast-scroll sweeps render at 60fps on a simulator.
Known follow-up: the pre-existing shared-grid autofit negotiation can
oscillate (rows 53<->54 with zoom.viewport.noEffective retries), keeping
replay barriers armed while it lasts; scrollback growth from live deltas
only engages once the grid settles.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Make terminal scrollback depth user-configurable
Adds a Terminal Scrollback picker (1k/4k/10k/20k rows, default 4k) to iOS
Settings > Display. The depth is stored under a shared UserDefaults key in
MobileTerminalScrollbackPreference (CMUXMobileCore) so the Settings writer,
the shell composite's hydration request, and the Mac's replay clamp share
one definition. The Mac now clamps requested rows to the 20k maximum
instead of the fixed 4k budget, and the iOS mirror's byte cap doubles to
16MB so 20k wide rows fit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Make the anchor registry a constructable class, not static state
Greptile flagged the caseless-enum registry as ambient global mutable
state. It is now a Sendable final class with OSAllocatedUnfairLock state
and a shared instance, matching MobileHostConnectionRegistry next door;
the DEBUG-only resetForTesting seam is gone since tests can construct
their own instance.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Guard empty XCODE_AUTH_ARGS expansion on bash 3.2
set -u aborts on "${arr[@]}" when the array is empty under macOS's
bash 3.2, which killed every ios/scripts/reload.sh run that reached the
device build without auth overrides. Use the ${arr[@]+...} guard.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: fail closed on missing delta base, gate scroll suppression on confirmed primary, drop flick harness
CodeRabbit: a screen delta without delta_base_history_rows previously
bypassed the continuity check and painted; the producer always sets the
base, so nil now requests a replay like any chain break. Local-scroll
suppression now requires a CONFIRMED primary screen; an unknown or stale
entry forwards to the Mac, which screen-anchored frames ignore, instead
of eating alternate-screen TUI wheel input. Both bots: the DEBUG flick
harness and its stored-state seam are removed from the production
surface view (scrollMechanicsView is private again); XCUITest drag
synthesis covers future scroll-regression rigs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Snapshot active anchors once per flush
CodeRabbit: emitRenderGrid rescanned the connection registry for every
surface in a flush, O(surfaces x connections) on the hottest update
path. The flush loop now computes the anchor list once and passes it
through the per-surface emission.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Pin prebuilt GhosttyKit archive for submodule f11159ff4
Built and published via build-ghosttykit.yml. Provisioning steps in CI
and the blacksmith builder download this pinned archive instead of the
from-source path, which currently fails on GitHub runners with
undefined libSystem symbols for any unpinned SHA.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Form the owned-userdata release callback from a literal closure
The GitHub runner's updated Swift toolchain rejects forming a C
function pointer from a static-method reference, which broke the iOS
simulator build (GhosttySurfaceView.swift:3485). A literal closure
wrapping the same call is accepted by every toolchain. Main carries the
identical pattern and will hit this on its next image roll.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Pass anchor in VerifiedReplaySurfaceRead test constructor
VerifiedReplaySurfaceRead gained the anchor field for screen-anchored
readback; the normalization test still called the old memberwise init,
breaking the CmuxMobileTerminalTests build in CI.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test: active pairing customization must win the shared device alias
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): scope device-keyed Mac state to the exact pairing
Sibling builds of one Mac share a macDeviceID, so four spots leaked
state across builds. The computer detail page now refines the
device-keyed connection status through a shared
exactPairingConnectionStatus helper (the same rule the Computers list
already applied), so the not-connected sibling no longer shows
Connected. Workspace avatar customization resolves deterministically
with the active pairing first instead of last-write-wins. Update-hint
dismissals are keyed per pairing (legacy untagged pairings keep the old
device key; a hint dismissed before this change may reappear once).
Sibling rows also get unique status-dot accessibility ids, and the
macOS-fallback saved-Mac reconnect buttons pass the instance tag they
already had.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Demote QR pairing from primary surfaces
Make same-account iroh discovery the primary path by hiding the macOS titlebar pairing button by default, stopping iOS from auto-presenting Add Computer, and renaming the disconnect-and-hide action to Forget This Computer.\n\nKeep QR pairing available as a manual fallback through explicit controls, and update the macOS/iOS copy and English/Japanese localizations to lead with automatic discovery.
* test: cover forgetting all paired computers
* Size the pairing window to its content and move forget to Computers
The Pair iPhone window now grows to its content's ideal height (clamped
to the screen's visible frame, scroll kept for short displays) so the
legacy Tailscale code link below the QR is discoverable without
scrolling. Settings and the workspace overflow menu lose the
active-Mac-only forget item; the Computers sheet gains a destructive
Forget All Computers action (confirmation dialog) backed by a new
forgetAllComputers() that disconnects and hides every stored pairing.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Render Forget All Computers as a quiet footer action
Centered red text with no icon or card background, so the rare
whole-phone action stops competing with the computer rows.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Omit Forget All Computers per owner decision
Per-computer hide on the Computers sheet rows remains the way to drop
a Mac; the whole-phone destructive action, its confirmation dialog,
forgetAllComputers(), its test, and its strings are removed.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Clamp programmatic pairing-window resize to contentMinSize
setFrame(_:display:) bypasses contentMinSize, so a short in-flight
content measurement (the loading spinner) could shrink the window below
the 480x320 floor.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Commit f41507ece2 passed the static method
GhosttySurfaceBridge.releaseRetainedOpaque directly to
ghostty_surface_new_with_owned_userdata, but Swift forms C function
pointers only from global funcs or capture-free closure literals, so
every iOS build of CmuxMobileTerminal fails, including the internal
TestFlight lane. Wrap the call in a capture-free literal that forwards
to the same method; behavior is unchanged.
Co-authored-by: Claude Fable 5 <[email protected]>
* Hide one computer row without hiding sibling build instances
Swiping Hide on a Computers-screen or disconnected-list row previously
expanded through the physical device id with no instance tag, so hiding
the Stable row of a Mac also hid its Nightly row. Row hides now target
exactly the row's alias group through hideStoredPairedMacEntries: raw
stored ids inherit the row's instance tag, matching is by exact pairing
id (device UUID + build tag), and the physical-wide expansion is
removed. Alias duplicates of the same endpoint still hide together so a
stale legacy alias cannot resurrect a hidden row, and the detail view
and host picker keep their existing instance-exact behavior. Hide state
stays indexed by device id, build tag, and account/team scope.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: drop no-op pairing-id prune, test production keying
Workspace state is keyed by physical device id, so the added
pairing-id prune loop never matched anything in production; it is
removed with a comment stating the sharing rule. The workspace-prune
test now seeds physical-id keying and asserts the true semantics: a
per-instance hide leaves the shared state while a sibling remains, and
hiding the last instance prunes it.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test(iroh): pin cancellable-dial behavior at the ConnectAttempt seam
Red-first tests for adopting iroh-ffi's cancellable ConnectAttempt:
- CmxIrohLibEndpointCancellationTests dials CmxIrohLibEndpoint against a
fake uniffi Endpoint that only serves beginConnect; cancelling the
dial task must invoke ConnectAttempt.cancel() across the seam and
surface CancellationError, and the fork's fixed cancelled marker
("outgoing connection cancelled", CONNECT_CANCELLED_MESSAGE in
iroh-ffi src/endpoint.rs) must classify to .cancelled even without
Swift task cancellation. Both fail before the fix: the dial goes
through plain Endpoint.connect and the marker classifies .unknown.
- CmxIrohDiagnosticFailureTests gains the marker mapping case (fails:
.unknown).
- CmxIrohClientSessionPoolCancellationTests pins the drain contract
from https://github.com/manaflow-ai/cmux/pull/8840 with a
cancellation-responsive dial fake and a parking clock: a retired
dial settles via cancellation in milliseconds (the 10s dead-man can
never fire in-test), and a burst of cancelled dials plus one live
dial yields exactly one delivered connection with no supersede-close.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(iroh): adopt the fork's cancellable ConnectAttempt for client dials
CmxIrohLibEndpoint now dials through Endpoint.beginConnect and awaits
ConnectAttempt.connect() inside withTaskCancellationHandler whose
onCancel runs the attempt's synchronous idempotent cancel(). Swift task
cancellation therefore crosses the FFI boundary: the fork's biased
select fails the dial immediately, and a cancel racing completion drops
the completed connection, so the app-level admission handshake can
never run for a cancelled attempt.
The pool's retire path (pending.task.cancel(), added in
https://github.com/manaflow-ai/cmux/pull/8840) now settles retired
dials in milliseconds through that seam. The drain set and its 10s
dead-man bound stay exactly as-is as a never-hit safety net for any
dial implementation that ignores cancellation.
IrohError classification gains the fork's fixed cancelled marker
("outgoing connection cancelled") so a cancelled dial that surfaces the
raw error classifies .cancelled instead of .unknown.
Design: cmuxterm-hq out/iroh-fork-program/DESIGN-A-cancellable-dials.md
(slice A1).
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
The new manual demo TestFlight lane (variant=demo in ios-testflight.yml)
ships dev.cmux.app.demo. Without this allowlist entry every cmux DEMO
phone fails device-token registration with invalid_bundle_id and pushes
never arrive, same failure mode the internal lane hit before
dev.cmux.app.internal was added here.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add demo variant to the iOS TestFlight lane
Manual workflow_dispatch variant=demo ships the current main head to a
separate cmux DEMO app (dev.cmux.app.demo) with a DEMO-badged app icon,
isolated from the internal lane's per-app upload limit. The demo
provisioning profile is fetched from the ASC API by name at build time
(asc_download_profile.py) instead of a repository secret, the AppIcon
PNGs are swapped in the CI checkout to avoid a workspace-wide
ASSETCATALOG_COMPILER_APPICON_NAME override breaking SwiftPM resource
bundles, and the assignment job targets the cmux DEMO internal group.
Push-triggered internal uploads are unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: surface openssl stderr, close temp fd on write failure
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing focus history shortcut rebind regression test
* Route configured shortcuts before Option text input
* Fix restored Dock lifecycle module import
* Preserve IME composition during Option shortcuts
* Fix current main Swift type inference failures
* Preserve IME input in direct shortcut matchers
* Return cached sidebar shortcut match
* Fix Dock transfer test fixture compilation
* Exercise sidebar IME guard with active context
* Isolate TextBox IME shortcut test state
* Serialize TextBox IME fixture coverage
* Keep TextBox IME layout suite isolated
* Add failing shifted shortcut collision regression test
* Canonicalize recorded shortcut keys across settings paths
* Add failing international shortcut regressions
* Match recorded shortcuts by physical key
---------
Co-authored-by: cmux reload-cloud <[email protected]>
PanelShellActivityState lives in CmuxWorkspaces; the new extension file from
https://github.com/manaflow-ai/cmux/pull/8690 only imported Foundation, so
every macOS app build from main fails (cannot find type in scope). Sibling
files using the same type already import CmuxWorkspaces.
Co-authored-by: Claude Fable 5 <[email protected]>
The feed row's swipe action was trailing and only offered Mark as Read on
unread rows. Move it to the leading edge and make it a read/unread toggle,
matching the workspace list rows: unread rows get Mark as Read
(envelope.open), read rows get Mark as Unread (envelope.badge).
Co-authored-by: Claude Fable 5 <[email protected]>
* Fix main build: import CmuxWorkspaces in DockSplitStore+RestoredAgentLifecycle
PanelShellActivityState moved into the CmuxWorkspaces package, and
https://github.com/manaflow-ai/cmux/pull/8690 merged a file that references it
with only a Foundation import, so current main fails to compile the macOS app
(cannot find type 'PanelShellActivityState' in scope). Sibling users of the
type such as Workspace+AgentLifecycle.swift already import CmuxWorkspaces.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix main build: explicit return for trailing switch expression
surfacePromptForResumeApproval ended with a bare switch statement whose
cases are contextless member expressions (.auto/.prompt/.manual), which
does not compile as a statement. Use 'return switch' so the cases get
their contextual type from the return type.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix main build: annotate closure result types in DockSplitStore+SessionSnapshot
The Xcode 26.5 toolchain on the reload builder cannot infer result types of
multi-statement closures that return nil-or-value, failing with 'generic
parameter could not be inferred' at the Dictionary(uniqueKeysWithValues:
compactMap) pair builder and the observation.flatMap guard. Annotate both
closures with their concrete result types.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Mac side: mobile.workspace.changes.* RPCs backed by CmuxGit WorkspaceChangesService
Adds a subprocess-backed workspace-changes service (summary/files/file_diff
vs merge-base of the default branch, untracked included, 15s summary TTL
cache, path containment validation, 400KiB/6000-line hunk-aligned diff
truncation) and exposes it to the phone as three mobile data-plane RPCs
with capability workspace.changes.v1.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS data layer: changes DTOs, CmuxMobileChanges parser package, composite integration
Lenient DTOs for the three mobile.workspace.changes RPCs, a Foundation-only
CmuxMobileChanges package (unified-diff parser with line numbering and CRLF
preservation, grapheme-safe intra-line emphasis, clamped diff font
preference), and MobileShellComposite integration: workspace.changes.v1
capability gate, 64-id batched summary fetches with a 15s reuse window,
rpcWorkspaceID-keyed chip snapshots, and a cancellable 250ms debounce off
list refreshes and workspace.updated events.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS UI: Changes sheet — file list, swipe-paged diff viewer, preview route
Value-driven Changes screens in CmuxMobileChanges (GitHub-calibrated
adaptive theme, summary header, status-glyph file rows with mini add/delete
bars, dual-gutter soft-wrapped unified diff with intra-line emphasis, page
TabView with position pill, pinch font sizing, copy line/hunk), the ShellUI
sheet mount with parsed-document cache, and the deterministic
CMUX_UITEST_CHANGES_PREVIEW fixture route (populated/diff/empty/states).
All strings localized en+ja.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS entry points: Changes toolbar button, workspace-list chips, one-time hint
One shared openWorkspaceChanges() action presents the Changes sheet from
the capability/connection-gated toolbar button (badge capped at 99+) and
the dismissible first-time hint banner; workspace rows get an ambient
+A −D chip fed by value snapshots keyed by rpcWorkspaceID. Also renders
'No newline at end of file' markers as dimmed gutter-less rows (parser
emits them; Copy Hunk excludes them). All new strings localized en+ja.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Keep wire DTO types out of ShellUI: map change status in the shell layer
CmuxMobileShellUI never imports CmuxMobileRPC; the status→FileChangeKind
mapping moves into CmuxMobileShell (which gains a CmuxMobileChanges
dependency) so the sheet consumes model values by member access only.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Render an honest not-a-repository state in the Changes sheet
A workspace whose directory is outside any Git repository previously fell
into the generic connection-error state. The composite now maps the
not_a_repo RPC code onto a shell-owned WorkspaceChangesFetchError and the
list renders a dedicated localized state (folder.badge.questionmark, no
summary header, no retry). Verified live against the tagged Mac's home
workspace.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix macOS Debug build broken on main: explicit color capture in NSImage draw closure
Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift from
https://github.com/manaflow-ai/cmux/pull/8034 references the slot view's
color property inside the escaping NSImage draw handler without explicit
capture, which fails to compile (CI is currently advisory, so it landed
unnoticed). Capturing the color value keeps the view out of the image's
retained draw block.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Render the changes chip in the shared WorkspaceRow so the UIKit list shows it
Main's UITableView workspace list (#8186) hosts WorkspaceRow directly,
bypassing WorkspaceNavigationRow where the +adds −dels chip lived, so chips
vanished after merging main. The chip (and its localized accessibility
label) moves into WorkspaceRow itself, both pipelines pass it through, and
the table coordinator reconfigures exactly the cells whose chip value
changed. Also keeps concise changes.summary debug-log lines that made this
diagnosable from the container log.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Calm the file list: text badges instead of a status-icon zoo
Five leading pictograms in four hues made the list read as a barrage of
symbols. The row now leads with the path; magnitude stays on the counts and
mini-bar; and only exceptional states get a quiet capsule badge in the BIN
badge's language: green 'New' (added and untracked collapse into one
concept), red 'Deleted' with the whole path dimmed. Renames keep only their
old → new line. Modified rows, the common case, carry no marker at all, and
the palette drops to green/red (orange and blue status tokens removed).
Badges are localized en+ja; VoiceOver labels keep the full status wording.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Say what the app means: 'Binary' badge and a truncation footer that explains itself
'BIN' was insider shorthand; the badge now reads Binary (ja already said
バイナリ). The truncation footer stops announcing a mechanism and states the
tradeoff: 'Large diff. Showing the first N lines to keep things fast. See
the rest on your Mac.' The 6,000-line/400KiB per-file cap itself is
unchanged; it exists so generated files and lockfiles cannot balloon the
RPC payload or phone memory.
Co-Authored-By: Claude Fable 5 <[email protected]>
* View changed binary files with the artifact viewer, at either revision
Changed images, PDFs, and other binaries stop dead-ending at a placeholder:
the diff page's binary card offers View Before / View After (or a single
View File for added, untracked, and deleted files) and pushes the shared
ChatArtifactViewerDestination — zoomable images, PDFKit, AVKit, QuickLook —
fed by two new data-plane RPCs, mobile.workspace.changes.file_stat and
.file_fetch. Reads are authorized against the workspace's current
changed-file set (rename old paths only for revision=base) plus the path
containment check; base blobs materialize once via git show into an
actor-owned 256 MiB LRU temp cache and serve 3 MiB chunks with honest EOF
math. Loader cache scope keys by workspace + revision + path so before and
after never collide.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Log workspace-changes content failures to the container debug log
One line per failed stat/fetch with method, params, and the underlying
error, matching the changes.summary logging style, so preview failures are
diagnosable from the device log instead of a generic viewer state.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Binary previews render inline with their actions in place
Paging onto a changed image or PDF now shows the content immediately: the
page hosts a chrome-free ChatArtifactInlineViewer (new public component
reusing the pager's per-type hosts and lifecycle), with a Before | After
selector for modified and renamed files. The full-screen hop is gone; the
viewer toolbar's Share / Save to Files / Copy-image actions render in place,
conditional on the loaded content type, through a factored
ChatArtifactActionBar the full viewer now shares. Zoom coexists with page
swipes the way Photos does: a zoomed-out pan falls through to the pager.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Preview actions live in the sheet toolbar, conditionally for the current page
Share / Save to Files / Copy image move from floating pills into the
Changes sheet's top-trailing navigation toolbar, driven by an Equatable
descriptor the inline viewer publishes via a SwiftUI preference (execution
stays in the viewer through a registration-generation host, so a stale
page can't clear a fresh performer). Only the selected pager page mounts a
preview, so the toolbar always reflects the visible file and empties out
on text pages. Includes the fix that attaches the toolbar group to the
pushed pager screen, which owns its own navigation bar, instead of the
sheet root.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Copy image via standard glyph and image long-press menu
The copy-image action now uses the doc.on.doc copy symbol instead of the
gallery-reading photo-on-rectangle glyph, everywhere the shared
ChatArtifactAction metadata is consumed (Changes sheet toolbar and full
viewer). Long-pressing a rendered image presents Share, Save to Files, and
Copy image through a UIContextMenuInteraction on the hosted UIImageView,
routed through the same performers as the toolbar; the full viewer's
copy-image performer now actually copies the rendered image. Adds a
changes.hint debug-log line reporting hint eligibility inputs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add Changes row to the workspace title menu
A second, non-toolbar entry point: the workspace title menu now has a
Changes row (shown whenever the host supports workspace changes) routing
through the same openWorkspaceChanges() action as the toolbar button. The
toolbar keeps the existing +/- icon button unchanged. Also factors the
list chip's +N -M text into a unit-tested WorkspaceChangesChipTextPolicy
that falls back to a localized file count for binary-only change sets.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Tappable list chip, counts in the Changes toolbar button, drop menu row
Three entry-point changes from the placement interview. The +N -M chip on
workspace-list rows is now a button that opens that workspace's Changes
sheet directly over the list (both the SwiftUI List and UIKit-table
pipelines; row selection untouched). The workspace-detail toolbar button
replaces its abstract +/- glyph with the same green/red counts whenever
the tree is dirty, falling back to the glyph when clean; counts resolve
their colors against the terminal theme's chrome scheme rather than the
system scheme so they stay legible on dark chrome. The title-menu Changes
row is removed as redundant.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Stack the toolbar Changes counts vertically
The workspace-detail toolbar button now stacks +N over -M so the counts
cost no more horizontal space than a plain icon button. The shared chip
label gains a stacksVertically variant used only by the toolbar; list
rows keep the horizontal layout.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Failing test: oversized single hunk truncates to nothing
A file whose first diff hunk alone exceeds the 6,000-line/400KiB cap
comes back as a header-only diff, which the phone renders as "Showing
the first 0 lines" with an empty page.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Split oversized first hunk instead of emitting an empty diff
When the first hunk alone exceeds the byte/line cap, emit as much of its
body as fits under a hunk header rewritten to describe the partial body
(start lines preserved, old/new counts recomputed), so the phone shows
the head of the change instead of "the first 0 lines".
Co-Authored-By: Claude Fable 5 <[email protected]>
* Make diff laziness per-line so huge hunks scroll
The diff body lazily rendered per HUNK, so a single multi-thousand-line
hunk (e.g. a truncated 6,000-line rewrite) became one eagerly laid-out
child: seconds of layout and frozen/stuttering scrolling. DiffRowSnapshot
now flattens hunks into per-line rows and the LazyVStack iterates those,
so only visible lines lay out regardless of hunk shape.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Expand hidden unchanged lines GitHub-style in the diff reader
Hidden unchanged regions (above the first hunk, between hunks, after the
last hunk) now show tappable expander bands: 100-line steps, full reveal
when 120 or fewer remain, split up/down bands between hunks. Revealed
lines come from the current working-tree file over the existing
authorized chunked file_fetch path (fetched once per file, 5 MiB cap,
inline retry on failure) and render as per-line context rows with both
gutters mapped through the hunk offsets, preserving per-line laziness.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Progressive Show more past the diff cap and unified short-gap expanders
The 6,000-line/400KiB per-file cap becomes progressive: file_diff accepts
an optional max_lines (clamped 6,000...1,000,000 lines / 64 MiB abuse
guard, byte budget scaled proportionally) and reports diff_total_lines,
and the truncated footer becomes "Showing X of Y diff lines" with a Show
more button that requests 4x the current budget and replaces the document
in place (stable row IDs preserve scroll and expansion state). Expander
bands whose whole run reveals in one tap now render a single unified
button instead of a split up/down pair whose halves did the same thing.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review findings: bounded work, truthful truncation, stable routes
Fixes the six accepted P1 review findings plus bot feedback:
- Clamp progressive diff responses to 6 MiB so they always fit the 8 MiB
RPC frame; the client stops offering Show more when a larger budget
stops growing the loaded window.
- Suppress the trailing context expander on truncated diffs (the region
after the last included hunk is not known unchanged).
- Read git diff output through a bounded incremental reader (terminate
past the budget) and report the diff total as unknown when cut short.
- Size base blobs with cat-file before materializing, stream git show to
the temp cache incrementally, refuse blobs over the cache budget, and
never pin an oversized entry through eviction.
- Parse diff responses off the main actor and cache the flat row
projection in state instead of rebuilding it per body evaluation.
- Apply the 500-file cap before untracked-file inspection and count
untracked additions with bounded in-process reads instead of one git
process per file.
- Decode summary identity fields strictly (lossy batch drops malformed
entries), route the diff pager by stable file path with a fail-closed
missing state, single-pass prefix truncation, read summary-cache
entries after the suspension point, and scrub RPC parameter names from
user-facing error copy.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bound snapshot and inspection work, fail closed on remote provenance
Second review round: snapshot git commands stream through the bounded
reader with a 32 MiB ceiling, 30s wall deadline, cancellation checks, and
explicit truncation; untracked inspection gets a 64 MiB aggregate budget
with cancellation between files; the client clamps Show more progression
at 96,000 lines and builds the parsed document, row projection, and
gutter width together off the main actor as one immutable presentation;
intra-line emphasis is skipped for lines over 4,096 UTF-8 bytes before
any Character materialization; remote-provenance workspace paths never
reach local git (summary reports not a repository, content verbs return
not_a_repo); both process-lifetime caches purge expired entries globally
and hold at most 64 entries with LRU eviction.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Move directory policy into CmuxGit and bound zero-byte cache entries
The workspace-directory provenance policy is consumed by the macOS app
target, so it lives in CmuxGit's changes domain (CmuxMobileRPC is an
iOS-group package the Mac app cannot resolve; its tests move to
CmuxGitTests). The base-content cache adds a 256-entry LRU count bound so
zero-byte blobs, which are invisible to the byte budget, cannot grow the
entry map and temp-file population without limit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scope refresh fanout, decouple pinch from rows, bound caches, pin revisions
Review round 4: workspace deltas schedule changes-summary refreshes only
for the delta's workspace IDs (group-only deltas skip entirely); pinch no
longer rebuilds the row projection (rows depend only on document,
expansion, and current lines; gutter width derives cheaply at render);
the sheet's parsed-presentation cache is a 7-entry LRU around the
selected page; file_diff/file_stat/file_fetch carry an additive stat
fingerprint so expansion fetches from a newer working tree are discarded
and the diff refreshed instead of splicing mixed revisions; Show more
now continues when the host cannot report a total, with loaded-only
progress copy (en+ja).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Harden content reads, cache keys, fingerprints, and refresh coalescing
Review round 5: expansion downloads enforce a cumulative 5 MiB cap and
chunk-count ceiling inside the transport loop; the diff's content
fingerprint stats the working file before and after git runs and returns
a never-matching unstable token when they differ; base blobs are keyed
and fetched by an immutable commit OID instead of the moving HEAD ref;
content reads walk the validated path component-by-component with
O_NOFOLLOW anchored at a repository-root descriptor so a post-validation
symlink swap cannot escape the repository; the summary-refresh debounce
accumulates a union of pending workspace IDs with a dominating
all-workspaces flag instead of dropping scopes on restart.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Recoverable cancellation, pinned transfers, safe inspection, guarded publishes
Review round 6: connection-transition CancellationErrors publish the
error state (silent stop only under real task cancellation); chunked
content transfers resolve scope, authorization, base OID, and base size
once and stay pinned via the authorized-path cache, which is now
revision-keyed so a moved base refreshes the snapshot; artifact
transfers verify every chunk's content fingerprint against the initial
stat; untracked inspection reuses the O_NOFOLLOW component-walk opener
and rejects symlinks and non-regular files with cancellation checks;
diff load, Show more, and expansion publishes are generation-guarded so
a superseded request can never overwrite a newer presentation.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Single-flight summaries, bounded parsing, post-read fingerprint checks
Review round 7: the summary debounce is separate from the fetch, which
is single-flight with a trailing coalesced pass instead of being
cancelled by every workspace delta; expansion line materialization runs
on a nonisolated worker with a 200,000-line bound; snapshot output
parses incrementally keeping at most the 500-entry cap plus running
totals instead of materializing unbounded path collections; content
chunks fstat the descriptor again after reading and fail closed when
identity, size, mtime, or ctime moved.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Literal pathspecs, fail-closed fingerprints, serialized expansion
Review round 8: Git commands taking network-selected paths run with
literal pathspec semantics; a working file that changes across the diff
capture retries once then fails with the retryable error instead of
publishing content with an unstable token; once a fingerprint is
established every subsequent response must carry a matching token (nil
observed fails closed, all-legacy hosts keep working); cached-lines
expansion sets pending state and coalesces reveal intents into one
cancellable rebuild; skipped-fresh summary refreshes arm one trailing
fetch at expiry and an additive force param bypasses the host's TTL
cache; the full image viewer regains Copy Path; the UIKit workspace
table's height caching accounts for chip presence so interactive chips
never clip.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Deadline every git call, typed repo outcomes, leases, identity fingerprints
Review round 9 accepted findings: the plain runner overload gains the
same 30s deadline and cancellation termination as the bounded overloads;
unborn repositories diff against the empty tree so untracked files list
(git failures map to gitFailure, never notARepository); truncated
changed-file snapshots render a bounded-result footer; untracked files
past the scan budget are prefix-probed and classified binary when
unknown; chunked base transfers hold eviction leases on their cache
entries; fingerprints carry device, inode, and ctime so same-size
same-mtime replacement is detectable. Two round-9 findings rejected by
design and documented in place: the default-branch HEAD comparison
fallback, and delta+TTL-driven summary refresh (repo-watching is a
follow-up).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Blob-identity fingerprints, hard git deadlines, budget-honest leases
Review round 10: base-revision fingerprints derive from the immutable
commit and blob OIDs so cache eviction cannot change a transfer's
identity; the git deadline terminates the process group, unblocks pipe
readers, and escalates to SIGKILL after a grace period; the base cache
reserves projected bytes before materializing, rejects when no unleased
victim can satisfy the budget, and leases every returned URL through its
use; missing fingerprints fail closed (this protocol always emits them);
Show more tasks are retained and cancelled with generation invalidation
when a page disappears; read-capped untracked counts are marked partial
and flip the snapshot's truncated flag.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scope legacy refreshes, pin diff base to OID, prompt exits, self-expiry
Review round 11: legacy workspace.updated reloads schedule TTL-respecting
summary refreshes instead of forced app-wide sweeps; the verified base
commit OID is the diff base for every operation (symbolic name kept only
for display); the git deadline path stops waiting out the SIGKILL grace
once the process group is gone; successful summary fetches arm the
trailing expiry so chips self-refresh after the TTL.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Group-liveness deadlines, honest TTLs, pruned state, bounded page memory
Review round 12: git deadline termination and escalation track
process-group liveness so descendants holding stdout are reaped; summary
TTLs stamp at batch completion with a floored trailing delay so slow
hosts cannot loop at zero delay; summary state prunes against the
current workspace set and consumes state-sync removals before rearming;
diff pages hold heavy state only in a selected-neighborhood window, with
other tabs mounting on selection; the workspace table's height cache
keys by digit-count buckets and chip mode instead of exact live totals.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Restore submodule pointers, cache snapshots, gate polling, unpin executors
Review round 14 (Claude engine): the origin/main merge had resolved the
ghostty and vendor/bonsplit gitlinks to older branch-side commits; both
are restored to main's pointers. File diffs and changed-file lists now
serve a 15-second LRU loaded-snapshot cache so pager mounts reuse one
repository walk (force bypasses it); the summary trailing refresh only
re-arms while workspace events are recent, so an idle connected phone
cannot hold the Mac in a perpetual 15-second git poll; blocking git
spawn/poll/reap loops run on a dedicated GCD queue bridged with
continuations instead of pinning the cooperative executor; the
developer-scratch live-repo probe test is removed; SwiftUI list rows
gate the chip tap closure like the UIKit path so chip-less rows keep
combined VoiceOver navigation.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Move base-revision git and large decodes off blocking executors
Review round 15: the base-content cache takes an async materializer so
the actor suspends instead of blocking on git show (post-await collision
adopts the winning entry); the rev-parse and cat-file probes and the
materializer run through the dedicated blocking queue per the service's
own executor contract; multi-megabyte file-diff and content-chunk JSON
payloads decode in nonisolated async helpers so Show more and binary
previews never run their decode pass on the main thread.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Live cancellation on GCD git work, bounded hint keys, injected clock
Review round 16 nits: a thread-bound cancellation signal bridges Swift
task cancellation into the GCD-hosted git loops (Task.isCancelled reads
false there), so unmounted pages and dropped connections stop subprocess
reads and untracked scans early instead of riding out the wall deadline;
the hint-dismissal store keeps seen workspace IDs in one 256-entry FIFO
array key instead of unbounded per-workspace defaults keys; the summary
debounce and trailing-expiry sleeps use an injected Clock so scheduling
is test-drivable.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Assert renamed-file diffs are paired, not add-only
The rename test accepted any non-empty diff, so a full-file addition
(the current behavior) passed. It now requires rename headers and no
content lines as additions for a pure git mv.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Pair renamed-file diffs by including the old path in the pathspec
git applies pathspec filtering before rename detection, so fileDiff's
new-path-only pathspec made -M unable to pair renames: the diff page
showed the whole file as added while the file list's paired numstat
showed the true +/- counts. Tracked diffs now pass both the validated
old and new paths after --, restoring similarity headers and an empty
hunk body for a pure git mv.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Assert CRLF diffs split, count, and truncate per line
Red test: the truncator must count each CRLF-terminated content line
and break at hunk boundaries inside CRLF diffs. Character-based
splitting treats \r\n as one grapheme, so today the whole CRLF hunk
body is one mega-line and these assertions fail.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Split diff lines on literal newlines so CRLF hunks survive truncation
Character-based split treats \r\n as one grapheme, so CRLF diff bodies
collapsed into a single mega-line: totals undercounted, interior hunk
headers went undetected, and an over-cap CRLF diff truncated to
metadata-only text that the phone rendered as an empty diff. The
truncator now splits with components(separatedBy: "\n"), matching the
iOS UnifiedDiffParser's handling of the same pitfall.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Keep content reads and diff truncation off the cooperative pool
fileStat's open+fstat, fileFetch's chunk read (up to 3 MiB), and
fileDiff's decode+hunk-split of up to ~13 MiB of git output ran on
Swift-concurrency cooperative threads; a repo on a network or external
volume could pin one for seconds per call. All three now route through
the same offCooperativePool seam as the service's git subprocess work.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Retire the trailing expander once a fetched file proves it empty
On added files and EOF-touching diffs the trailing "Expand hidden
lines" band was a permanently dead control: the tapped gap resolved to
nothing against the fetched file, and that path cleared pending state
without publishing the fetched lines, so the projection never learned
the line count, the band never disappeared, and every tap re-downloaded
the whole file. The nil-gap path now recomputes the presentation with
the fetched lines, which removes the band and caches the lines.
Covered at the projection level (band present without a line count,
gone with one); a true page-level red/green is not practical because
the page is @State-bound SwiftUI rather than an observable model.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Decode changed-file paths strictly so malformed entries are omitted
File.init decoded every field leniently, so an entry missing its path
became path "" instead of throwing: the batch loop's omission filter
never fired, the list showed a nonsense row whose diff request the host
rejects, and two such entries collided on the path-keyed SwiftUI
identity. The path now decodes strictly and rejects empty strings,
matching the sibling summaries decoder (strict identity, lenient
counts), so identity-less objects are dropped like non-object entries.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: cmux reload-cloud <[email protected]>
* Add failing regression tests for unbounded mobile event emission (#8842)
A stalled, never-draining terminal.render_grid subscriber must keep the
host's pending event queue bounded WITHOUT tearing the connection down:
close-on-overflow churns connection resources (sockets, lanes, tasks)
every few seconds for as long as the subscriber stays slow, which is the
reconnect-churn half of the #8842 field incident (785 -> 3,200 fds,
4.12 GB RSS, jetsam largestProcess, forced hard reset).
Red on this commit:
- testStalledRenderGridSubscriberStaysOpenWithBoundedEventQueue: the
connection currently closes at bounded capacity instead of shedding
recoverable render-grid frames.
Green guards committed alongside (documenting contracts the fix must
preserve):
- testStalledSubscriberOverflowOnNonRecoverableTopicClosesConnection:
non-recoverable topics (mobile.sync.delta) keep close-on-overflow.
- testCloseReleasesConnectionAndTransportResources: every
per-connection resource releases after close, even with a send
stalled mid-flight.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bound mobile event emission with synchronous admission and shed-or-close (#8842)
Root cause (owner boundary): MobileHostService.emitEvent spawned one
unstructured Task per registered connection per event, each retaining
the full [String: Any] payload, and each connection actor re-serialized
the same payload before running its bounded-queue admission check. The
bound therefore applied only to encoded frames; everything upstream of
it — task allocations, retained payload graphs, the actor mailbox — was
unbounded whenever a subscriber drained slower than the producers.
Render-grid and terminal-bytes events are continuous (every Ghostty
tick x every surface, ~130 surfaces in the field incident), so a slow,
paused, or half-dead phone grew host memory and CPU without bound
(4.12 GB RSS, 69,946 s CPU, jetsam largestProcess, forced hard reset).
Overflow policy was connection teardown, so a slow-but-alive subscriber
cycled connect -> fill -> close -> reconnect -> full replay, churning
NWConnection/Iroh lane resources (785 -> 3,200 fds).
Invariant now enforced: memory, tasks, and connection resources
attributable to mobile event emission are O(bounded queue capacity) per
connection regardless of subscriber behavior, and every per-connection
resource is released deterministically when a subscriber stops draining.
Mechanism:
- Encode once, admit synchronously: emitEvent encodes the envelope a
single time and admits it into each connection's new
MobileHostConnectionEventQueue (lock-protected, count- and
byte-bounded) on the emitter's thread. No per-event tasks; at most
one drain task per connection, claimed through the queue.
- Shed instead of close for recoverable topics: render-grid frames are
shed per surface under overflow; the surface is poisoned against
further deltas (the iOS client has no delta-continuity check, so a
silently dropped delta would corrupt its grid invisibly) until the
producer — asked via MobileTerminalRenderObserver's coalesced
full-resync hook — re-emits a full frame that re-bases every
subscriber's chain. terminal.bytes (client detects seq gaps and
replays) and terminal.updated/workspace.updated (level-triggered
pings) shed without extra recovery. Non-recoverable topics keep the
close-on-overflow contract.
- Deterministic teardown for half-dead peers: control-lane event writes
now run under a bounded stall deadline (default 30 s, injectable) —
a peer that accepted the connection but stopped reading previously
pinned the drain, queue, transport, and tasks forever. The Iroh
independent event lane already had its own 3 s deadline.
- Render-grid CPU: the frame is JSON-encoded once and spliced into the
event envelope, replacing the encode -> parse -> re-serialize round
trip that showed up in the incident's cpu_resource stacks.
Turns the commit-1 regression test green and adds fan-out, stall-
deadline, and queue-admission policy coverage.
Fixes#8842.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Drop new ForTesting seam; read the internal event queue directly in tests
Review feedback: the queue is an internal nonisolated let, so the test
reads eventQueue.count/byteCount via @testable import instead of a new
debug wrapper.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Fourth error from the same build:
Sources/ControlSurfaceResumeTarget.swift:282:40: error: reference to member 'auto' cannot be resolved without a contextual type
Sources/ControlSurfaceResumeTarget.swift:283:41: error: reference to member 'prompt' cannot be resolved without a contextual type
Sources/ControlSurfaceResumeTarget.swift:284:19: error: reference to member 'manual' cannot be resolved without a contextual type
surfacePromptForResumeApproval builds an NSAlert over several statements and then
ends with a bare `switch alert.runModal()` whose cases are `.auto`, `.prompt` and
`.manual`. Swift gives a function body an implicit return only when the body is a
single expression, so in a multi-statement body that switch is an expression
statement with nothing to type it, and the leading-dot members have no
SurfaceResumeApprovalPolicy to resolve against.
Returning it supplies the contextual type. Every other trailing switch of this shape
under Sources is the single-expression body of a computed property or a one-statement
function, which is why this is the only one that fails.
Second and third errors from the same build, after the missing import:
Sources/DockSplitStore+SessionSnapshot.swift:44:43: error: generic parameter 'Key' could not be inferred
Sources/DockSplitStore+SessionSnapshot.swift:44:43: error: generic parameter 'Value' could not be inferred
Sources/DockSplitStore+SessionSnapshot.swift:332:47: error: generic parameter 'U' could not be inferred
Both are the same shape: a multi-statement closure whose bail-out is a bare
`return nil`, handed to something generic. At line 44 that is
Dictionary(uniqueKeysWithValues:), which has to solve Key and Value; at line 332 it
is Optional.flatMap, which has to solve U. A bare `return nil` carries no type, so
the closure result and the generic parameters each depend on the other and the
solver gives up.
Naming the return types breaks the cycle. The types are the ones already required
by the surrounding code: SessionSplitContainerSnapshot declares
sourceWorkspaceIdsByPanelId as [UUID: UUID]?, DetachedSurfaceTransfer's
sessionRestoreWorkspaceId is a UUID, and observation is a
RestorableAgentSessionIndex.Entry?. Behaviour is unchanged.
Building main at 4dc00aebb8 fails with a single error:
Sources/DockSplitStore+RestoredAgentLifecycle.swift:13:62: error: cannot find
type 'PanelShellActivityState' in scope
That file arrived with #8690 and declares
updatePanelShellActivityState(panelId:state:), whose state parameter is
PanelShellActivityState. That type is a public enum in the CmuxWorkspaces
package, and Swift imports are per-file, so the app target linking the package
is not enough on its own. Every other file in Sources that names the type
imports CmuxWorkspaces; this one imports only Foundation.
ci.yml is dispatch-only, so no PR build runs and nothing caught it.
* Add regression test for same-display window cutoff
* Fit every restored main window to visible displays
* test: preserve visible frames across display replacement
* fix: preserve visible frames on replacement displays
---------
Co-authored-by: cmux reload-cloud <[email protected]>
* Send founders welcome email for cmux Pro checkouts
The founders-welcome Stripe webhook only sent the welcome email when a
checkout session carried founders_edition=true (the Founder's Edition
payment-link metadata). cmux Pro checkout sessions created by
/api/billing/checkout carry { app: "cmux", plan: "pro" } and no
founders_edition key, so they were skipped as not_founders and Pro
subscribers never received the welcome.
Product decision: Founder's Edition and cmux Pro are the same tier, so
any cmux Pro checkout (monthly or yearly) now triggers the identical
welcome email. The Team plan stays excluded.
- Extract the trigger condition into a pure welcomeTriggerForMetadata
helper next to welcome-email.ts so it is unit-testable; the email
builder, idempotency key, X-Entity-Ref-ID threading, CC/reply-to, and
Resend retry semantics are unchanged.
- Record which condition matched via a cmux.stripe.welcome_trigger span
attribute ("founders_edition" | "pro_plan" | "none") and rename the
skip reason to not_welcome_eligible.
- Add route-level tests covering the Pro send, the founders send, the
team/other-app skips, the missing-customer-email short-circuit,
signature rejection, and the non-2xx Resend failure path.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Freeze the clock in founders-welcome route tests
Address CodeRabbit: signature generation in the test and the freshness
check inside POST previously read the real clock separately, so the
five-minute replay tolerance could not be tested deterministically. Pin
a fixed virtual time with bun:test's setSystemTime (declared in the
repo-local bun-test.d.ts) so both sides share one clock, and add exact
boundary coverage: a validly-signed payload one second past the
tolerance is rejected, one exactly at the tolerance is accepted.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Send the founders welcome for every completed checkout
Product decision from the founder: all customers get the same founders
treatment, so the webhook now sends the identical welcome email for
EVERY checkout.session.completed on this endpoint — Founder's Edition
payment-link purchases, cmux Pro, cmux Team, and anything else — not
just founders/Pro. The only skips left are non-checkout event types,
invalid or stale signatures, and sessions without a customer email.
welcomeTriggerForMetadata now classifies the purchase shape purely for
telemetry (founders_edition | pro_plan | team_plan | other) instead of
gating the send; the not_welcome_eligible skip is gone. Route and
helper tests updated: Team and unrecognized-metadata checkouts now
assert a send with the same per-session idempotency key.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* test: cover dock runtime parity gaps
* test: fix dock parity harness compilation
* fix: give dock surfaces runtime parity
* test: stabilize dock runtime parity coverage
* fix: route dock notification ownership
* fix: preserve dock notification window routing
* test: exercise dock tree through live window routing
---------
Co-authored-by: cmux reload-cloud <[email protected]>
* Add failing regression tests for corpse-redial and overlapping wake dials
Phone rings from 2026-07-22 and 2026-07-23 (issue 8531) show the Iroh
client pool installing and returning sessions whose QUIC connection is
already dead (established -> remoteClosed 166us later, then an instant
ms=0 connectionClosed redial, 3/3 involuntary deaths), and the wake path
running five overlapping dials to one Mac because a generation bump
cancels the in-flight dial while iroh keeps handshaking it, so each late
admission makes the host kill the newer live connection.
deadOnArrivalDialIsNeverInstalledOrReturnedAndRedialsFresh and
generationBumpDrainsCancelledDialBeforeRedialing fail against the current
pool; concurrentAcquirersCoalesceOntoOneInFlightDial guards the existing
pending-dial coalescing. TestGatedDialEndpoint models iroh-ffi's
non-cancellable connect.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Never redial corpse sessions and make Iroh client dials single-flight per peer
Two pool fixes for the recovery churn in issue 8531.
Dead-on-arrival gate: session(for:) previously installed and returned a
freshly dialed session without checking liveness, so a connection that
lost the race with a host-side close was installed, handed to the
caller, and killed microseconds later (established -> remoteClosed in
166us in the 2026-07-22 ring, then an instant ms=0 connectionClosed
redial). The pool now closes a dead-on-arrival session, dials fresh
once, and re-validates a raced-in installed session instead of
returning it unchecked.
Single-flight dials: invalidation (including the runtimeGeneration bump
from CmxIrohClientRuntime.didBecomeActive() on wake) cancelled the
in-flight dial task and forgot it, but iroh-ffi's connect ignores Swift
cancellation, so the abandoned dial completed admission on the host
seconds later and the host then closed the newer live connection for
that device, re-triggering recovery (five overlapping dials, sessions
est/killed within 1-3s each, in the 2026-07-23 wake burst). Cancelled
pending dials are now retired into a drain set keyed by
(identity, deviceID) across generations; a new dial waits for retired
dials to fully settle (their sessions closed unless installed) before
dialing, bounded at 10s via an injected CmxIrohRelayClock so one wedged
dial cannot become a permanent connect outage. Concurrent recovery
triggers keep coalescing onto the one pending dial; supersede semantics
(activate/deactivate/explicit invalidation cancelling the dial) are
unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test: cover TextBox IME composition layout
* test: import app module for TextBox IME test
* fix: lay out TextBox marked text before commit
* cmuxTests: pass the liveness the Entry initializer requires
The cmux-unit test target does not compile on main:
cmuxTests/AgentResumeLivenessTests.swift:25:25: error: missing argument
for parameter 'processLiveness' in call
#8547 added a `processLiveness` field to RestorableAgentSessionIndex.Entry with
no default, which makes it required in the memberwise initializer. This test
helper still builds an Entry without it, so the whole target fails to build and
no suite in it can run.
hasLiveProcess decides from the PID set alone, so the value only has to be
honest: derive it from the PIDs each case asks for rather than pinning one that
would claim a running process for the empty-PID case.
* test: host TextBox IME view for redraw assertion
* fix: bound TextBox IME layout measurement
* fix: preserve TextBox document extent during IME input
* fix: coalesce TextBox IME layout updates
* test: avoid IME layout host teardown crash
* fix: measure IME preedit incrementally
* Revert "fix: measure IME preedit incrementally"
Keep one authoritative TextBox height measurement path. The incremental marked-range cache used approximate line-height deltas that could diverge for middle-of-draft edits, attachments, and variable-height fragments.
* test: inject clock into sidebar release scheduler
* test: synchronize sidebar manual clock
* remote-daemon: tear a PTY session down once
TestTerminateProcessesRunsOnlyOnce has failed since it landed in #8438. The
test asserts teardown is idempotent, but the guard it was written against was
never added, so the foreground-group lookup and both SIGKILLs run again on
every call.
Two paths reach teardown for the same session. waitSessionProcess runs it once
the session leader exits, and the hub runs it on a client close frame, when a
non-persistent attachment's connection ends, on closeAll, and on an idle reap.
The hub paths exclude each other by removing the session from the hub map under
h.mu, but waitSessionProcess sits outside that bookkeeping, so a session that
outlives its leader and is then closed tears down twice.
A second pass can only run after cmd.Wait has returned, so the leader pid it
signals has already been reaped. It also repeats the member scan, which reads
every /proc/<pid>/stat on Linux and forks ps on macOS. Guard the body with a
sync.Once, matching closeTTYOnce and closePTYOnce on the same struct.
* test: bound terminal teardown event waits
* fix: register hibernation teardown test surface
* fix: cancel teardown wait deadlines
* test: signal teardown waiter registration
* Fix sendable shell executable check
* tests: stop BrowserDeveloperTools window suite from crashing the xctest host
BrowserDeveloperToolsVisibilityPersistenceTests crashed the test-host at teardown
(EXC_BAD_ACCESS in objc_release inside XCTMemoryChecker) on a clean checkout — so
the whole suite never ran and its real failures were hidden. The suite creates
NSWindows with AppKit's default isReleasedWhenClosed = true and then close()s them;
under ARC the local strong reference releases the already-freed window, and the
memory checker trips over the zombie at pool drain. Every other window suite in the
repo sets isReleasedWhenClosed = false — this one never did.
Fix (test-only, two files):
- set isReleasedWhenClosed = false at closeWindow() and every NSWindow creation
site in the suite (the crash fix).
- window(withId:) briefly polls NSApp.windows instead of racing the cold app
launch (an app-launch timing bug the crash was masking).
- install the shortcut-routing focused-window capture (same infra
AppDelegateShortcutRoutingTests uses) so close/shortcut routing resolves to the
window the test brings forward, instead of the unreliable headless NSApp.keyWindow.
- add settle waits before acting in the transition-timing tests.
- XCTSkip 3 tests that genuinely can't run headless (SwiftUI NSHostingView layout
migration, WebKit async detached-inspector grace window, and default
nil-target AppKit close routing), each documented with why.
Result: 33 tests, 3 skipped, 0 failures, no host crash (was: crash on the first
test). Pre-existing on main; unrelated to any remote-tmux work.
* tests: keep the skipped devtools tests' bodies live and type-checked
Deleting the bodies of the three headless-skipped tests left empty functions
holding only a skip. That's worse than the `#if false` it replaced: remove the skip
and you get a test that passes while asserting nothing — a silent false green.
Use `try XCTSkipIf(true, reason)` instead of `throw XCTSkip(reason)`. Because the
condition is evaluated at runtime the compiler can't treat the body as unreachable,
so each body stays live and type-checked (it can't rot behind `#if false`, and it
can't vanish), the skip is still honest, and dropping the skip yields a real test
again. Bodies restored from main: 11, 5 and 7 assertions.
Suite: 33 tests, 3 skipped, 0 failures, no host crash.
* terminal/window tests: stop over-releasing test windows and fix the file-open working directory
Three unrelated defects surfaced while running the Terminal and Window/Portal/Titlebar
suites under `xcodebuild test`.
`TerminalDefaultFileOpenRequest` reported a working directory with a trailing slash.
`path(percentEncoded:)` keeps a directory URL's trailing slash, so opening
`/tmp/scripts/run.command` from Finder produced "/tmp/scripts/" instead of "/tmp/scripts".
That string becomes the workspace's working directory, which the window title renders via
{activeDirectory} and which directory comparisons match on, so the stray slash is user
visible. `path` reports the same decoded path without it and still reports "/" for a file
at the root.
Several test windows were created with AppKit's default isReleasedWhenClosed and then
closed, which releases a window ARC still owns. The over-release lands in a later
autorelease pool drain and takes the whole app-host down mid-run, so unrelated suites in
the same batch silently never ran. WindowKeyDownReplayGuardTests was the clearest case: it
reported "Test run with 0 tests in 1 suite passed" after three host restarts. Setting
isReleasedWhenClosed = false at each creation site matches what the rest of the suite
already does, and turns that fake green into a real run of six tests.
The two offscreen-startup tests asserted that a runtime-surface creation attempt had
already happened by the time TerminalPanel.init returned. The first creation for a surface
waits on the Claude command shim install, which hops through a detached Task and a
main-actor continuation, so the attempt cannot land synchronously. The invariant they guard
is that the attempt needs no window attach, so they now assert uiWindow stays nil and poll
for the attempt instead of demanding it inline.
* Test optional Iroh limiter configuration
* test: keep IME layout host window ARC-owned
* fix: drop no-op diagnostic log await from #8716
* test: cover synchronous TextBox IME layout
---------
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: lawrencecchen <[email protected]>
Co-authored-by: cmux reload-cloud <[email protected]>
* test: cover pairing-scoped Mac picker entries
* feat(ios): scope computer pickers to paired builds
* fix(ios): render picker build labels via menu subtitles and persist selected pairing
UIMenu bridging drops any Text wrapped in a stack inside a menu button
label, so the build labels never rendered; menu rows are now bare
Text/Text/Image tuples (title, subtitle, icon) in both the workspace
title picker and the task composer machine menu. The collapsed picker
title appends the build label when sibling builds share a name.
Review fixes: the workspace-list preview pairing fixture moves out of
the production view into the DEBUG-only fixture file behind
UITestConfig; matchesForegroundPairing is shared with the directory
list instead of duplicated; build-label derivation lives in one store
helper; submission snapshots and composer drafts persist macInstanceTag
(legacy drafts decode with nil) and submit reads the tag from the
captured snapshot instead of live state.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test: pin bare noq ConnectionError tokens in the iroh failure classifier
These Debug tokens reach IrohError.message() from connection-level
operations (accept_bi/open_bi) without the ConnectionLost(...) wrapper
and currently classify as unknown (b=255 in the 2026-07-23 host ring).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Name lane-failure and send-queue-overflow session close reasons
Bare iroh::endpoint::ConnectionError Debug tokens (TimedOut,
LocallyClosed, Reset, ApplicationClosed(...), ConnectionClosed(...),
TransportError(...), VersionMismatch, CidsExhausted) now map to honest
DiagnosticFailureKinds instead of unknown, and the host's bounded event
queue overflow close is labeled with the new appended
sendQueueOverflow = 24 instead of protocolViolation.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Adds cmux.iroh.pathPreference (auto | relayOnly) as a release-safe,
device-local setting honored by both the macOS host runtime and the iOS
client composition, so nightly/TestFlight users can force relay-only
connection paths. The existing DEBUG defaults keys keep working in DEBUG
builds as an override; explicitly changing the setting clears them.
Surfaces a localized (en+ja) "Relay Only" toggle in macOS Iroh
networking settings and iOS Iroh settings, and extends the settings
model, snapshot, resolver, and host-admission tests on both platforms.
Stopgap kill-switch for the WiFi path-flap reconnect loop where
phone+Mac sessions die seconds after privateNetwork path selection.
Co-authored-by: Claude Fable 5 <[email protected]>
Aziz's ruling: no rate limits at all, and nothing may break when the rule ids
are unset or their Vercel firewall rules are deleted. This extends the
8714/8773/8771 fail-open pattern to the remaining consumers:
- env.ts: CMUX_FEEDBACK/CLIENT_CONFIG/ANALYTICS_RATE_LIMIT_ID become optional
(client-config and analytics previously hard-failed production deploy env
validation when unset; feedback failed every deploy).
- analytics/events + client-config routes: unset id skips limiting instead of
503ing; a not-found rule warns and fails open; genuine check failures still
503.
- waitlist + feedback routes: guard the limiter on the optional id and fail
open on not-found instead of 503ing the endpoint.
- enterprise/contact + feedback config resolvers no longer treat a missing
rate-limit id as 'endpoint not configured'.
- push and vault routes already guarded/failed open; unchanged.
Also fixes a pre-existing red test on main (client-config-env expected
CMUX_IROH_RATE_LIMIT_ID to be required, stale since 8714/8771).
Co-authored-by: Claude Fable 5 <[email protected]>
* Make hidden-entry recovery failures diagnosable and dead entries dismissable
A failed legacy unhide previously showed one generic alert for every
failure. Recovery now reports why it found nothing: instanceNotLive
(names the exact app to open, e.g. cmux NIGHTLY), deviceNotFound,
noIrohRoute, irohUnavailable (this phone has no iroh discovery client),
and connectFailed (candidate matched but the authenticated connect
failed), each with reason-specific localized guidance. Hidden rows show
the shared Stable/Nightly/DEV build badge so users can tell which
instance an entry refers to, and after a failed recovery the alert
offers a destructive "Remove from List" that clears only the local
markers, leaving server tombstones and QR re-pair untouched. A debug-log
decision trail records discovery and candidate counts plus each failed
connect so one retry diagnoses a report. Identity matching is unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Deleted Macs are not hidden: clear legacy markers, retire legacy recovery
Owner ruling: an old-build delete must not surface as a hide; they are
different things. On every paired-Mac load, hidden markers without a
matching local row (the signature of a legacy delete; both pairing-id
and raw-device-id marker forms match rows) are cleared, so Hidden
Computers only ever contains deliberate row-backed hides with instant
offline unhide. The legacy recovery machinery (live-discovery revival,
failure reasons, Remove from List) is retired as unreachable. Legacy
server tombstones are neutralized client-side: restore no longer
deletes or suppresses rows for them (locally pending deletes stay
authoritative until flushed) and upserts always send
allowTombstoneRevive, which the deployed worker honors per-op, so
previously deleted Macs return like any newly discovered Mac. The
Stable/Nightly/DEV build badge on hidden rows is kept.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Log legacy hidden-marker migration outcome
One info line with cleared/kept/row counts so a migration report is
diagnosable from a device log without reproduction.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test: workspace initial_command must launch via login shell
* Launch workspace initial commands via user login shell
Ghostty launches string commands through a profile-free Bash, leaving task agents with the app-inherited PATH. Resolve the user login shell, run the original command through -lc, and re-prepend the per-surface shim directory so cmux wrapper hooks stay active. Session restore and dock startup already use login-shell wrapping for the same profile-loading behavior.
* Add superrepo blog post and author bylines
* Fix blog card accessibility and author source
* Limit superrepo post to authored locales
* Localize superrepo command example
* Align superrepo path examples
* Tighten blog card title spacing
* Add space above blog author rows
* test: reject nearly expired admission leases
* fix: name online admission lease invalidations
* Name new admission failure kinds in macOS and iOS diagnostics UI
The Connection Report failure-kind switches are exhaustive; the two new
DiagnosticFailureKind cases need display strings on both platforms
(en + ja).
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
PR 8773 made enforceRelayRateLimit skip the firewall gate when the rule id
is unset, and the rate-limit env vars have been deleted from Vercel. But
env.ts still requires CMUX_RELAY_TOKEN_RATE_LIMIT_ID on non-preview
deployments via requireVercelRelayValue, and onValidationError throws, so
the next production deploy fails env validation at build. Make the schema
optional (matching CMUX_IROH_RATE_LIMIT_ID after PR 8714), drop the var
from privateRelayEnvNames since it can no longer produce issues, and
remove the now-unconstructable rate_limit_not_configured error code.
Co-authored-by: Claude Fable 5 <[email protected]>
* Sidebar: move workspace reorder drops to native NSTableView validateDrop/acceptDrop
The AppKit sidebar table previously received workspace reorder drops
through a transparent SidebarWorkspaceReorderDropView overlay stacked
above the scroll view, with a geometry gate feeding it visible-row
frames and a pending-drop state machine covering the async gap between
drop arrival and target collection. The table now registers for the
reorder pasteboard type itself and resolves drops in the data source's
validateDrop/acceptDrop, building visible-row targets synchronously in
table coordinates and passing them to the same shared
SidebarWorkspaceReorderDropResolver. draggingDestinationFeedbackStyle
is .none, so the row-painted indicator bars and the container's
empty-indicator overlay keep owning all drop visuals unchanged.
Behavior improvement: the controller stores the accepted drag point in
window space and re-plans it on every viewport change, so during edge
autoscroll the drop target and indicator track the rows sliding under a
stationary pointer; previously the indicator was only geometrically
repositioned and the plan froze until the pointer moved. Drag exit and
session end (including Escape cancel over the table) now clear the
indicator through explicit draggingExited/draggingEnded overrides.
The geometry gate is now bonsplit-only, the reorder overlay and its
pending-drop machinery are no longer instantiated by the AppKit
container (the legacy SwiftUI sidebar still uses that overlay class,
unchanged), and the reorder side of SidebarWorkspaceTableActions drops
its target-collection closure. Also fixes a stale .fullWidth style
assertion in SidebarWorkspaceTableTests that predates the .plain
switch in #8366 and slipped through while PR CI was disabled.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix main build: drop convenience from actor initializer
https://github.com/manaflow-ai/cmux/pull/8521 added public convenience
init() to the GitHubPullRequestRequestCoordinator actor. Actor
initializers delegate without convenience (SE-0327) and current Swift
toolchains reject the keyword as a hard error, so every fleet
reload-cloud build and CI compile of main fails with 'initializers in
actors are not marked with convenience'. It landed silently because PR
CI is currently disabled. Dropping the keyword keeps the same public
surface and delegation.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Sidebar: controller-owned reorder indicator painting
Dogfood video on a 100-row sidebar showed three drag defects: the drop
line lagged the pointer by hundreds of pixels, transitions briefly drew
two lines at once, and a line was baked into the drag ghost for the
whole drag.
Root cause for the first two: the reorder plan was written into the
SwiftUI drag state, so every gap change rebuilt every sidebar row and
repainted the line one staged apply later. The table's actions now
return the plan to the controller (SidebarWorkspaceTableReorderDropUpdate)
and never touch dragState; the controller maps the indicator onto the
two affected cells with SidebarWorkspaceTableReorderIndicatorPainter,
which wraps the same SidebarTabDropIndicatorPredicate and scope-filtered
row ids the SwiftUI sidebar uses, so gap semantics are unchanged while
transitions become two direct view mutations.
Root cause for the ghost: AppKit snapshots the dragged row lazily, after
the first validateDrop painted the indicator at the grabbed row's own
edge. The painter suppresses both lines on the dragged row, so the
snapshot is always clean.
Recycled or reconfigured cells re-apply the controller paint after their
model reset it, retire paths (drop, exit, session end) sweep the lines
clear, and the apply-time indicator sync prefers the live reorder
painter so dragState (bonsplit-only now) cannot clear the past-the-end
overlay mid-drag.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix tuple labels lost through nil-coalescing in indicator sweep
Co-Authored-By: Claude Fable 5 <[email protected]>
* Sidebar drag: stop bottom-edge autoscroll oscillation, re-arm cleared drags
Second dogfood round on the 100-row sidebar surfaced two failures.
Autoscroll parked at the very bottom stuttered continuously: the
60Hz timer's native NSClipView.autoscroll(with:) path has no content
clamp, so each tick overshot into the elastic region and rubber-banded
back. The tick now computes the plan first and stops the timer when
constrainBoundsRect proves the clip view cannot advance in the planned
direction; the next drag update restarts it. The manual fallback
already clamped.
A long drag went indicator-less and its drop silently no-oped: the app
briefly resigned active mid-drag (busy multi-agent desktop), the
app_resign_active failsafe cleared dragState AND the process-wide drag
registry, and activateSidebarWorkspaceDragIfNeeded could only re-arm
foreign drags, so every later validateDrop was rejected while the
native session stayed alive. The drag pasteboard id (parsed once per
validateDrop and handed through the table actions) now re-arms local
drags too: a still-delivering drop callback is proof the session is
real. Escape-cancel cannot resurrect because AppKit ends the session
and no further callbacks arrive.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Sidebar drag: robust payload parse for re-arm plus activation probe
string(forType:) can be nil for item-provider-promised drag data; fall
back to a UTF-8 decode of data(forType:). DEBUG logs record the parsed
payload and activation rejections so silent no-op drops are attributable
from the tagged log.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Sidebar drag: single autoscroll driver, inset-aware clamp
Making the table a native drag destination silently added AppKit's
built-in drag autoscroll on top of cmux's SidebarDragAutoScrollController.
Two drivers with different engagement bands, speeds, and boundary
behavior fought during drags: scrolling continued after the pointer
left the cmux edge zone (AppKit's band is much taller than 44pt), and
edge behavior was erratic. The table now declines AppKit autoscroll
(autoscroll(with:) -> false); the cmux controller is the only driver.
The controller is also planner-only now. Its autoscroll(with:) fast
path read NSApp.currentEvent inside a timer tick — a stale position
basis — and had no content clamp. The manual path's [0, contentHeight]
clamp ignored the scroll view's content insets, which is why upward
autoscroll stopped one top-inset short of the actual top. Ticks now
scroll by the planner's ramped step and clamp through
constrainBoundsRect, which honors insets and cannot enter the elastic
region.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Sidebar drag: scope AppKit-autoscroll refusal to active drop sessions
The unconditional autoscroll(with:) -> false override killed row-drag
initiation: NSTableView's mouseDown tracking loop also calls autoscroll
while deciding whether a press becomes a drag, and refusing it there
made every drag die at birth (pasteboardWriterForRow ran, then the
mouse-up landed as a plain click; log shows dragState.sidebar set
followed by sidebar.table.click with no session). The table now
declines AppKit's built-in drag autoscroll only while the controller
reports a live reorder drop session hovering, which is the only window
where the second scroll driver exists.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Failing test: autoscroll planner measures edge distance in document coordinates
Pointer positions converted into the clip view are document coordinates
whose origin is the scroll offset. The planner treated them as
viewport-relative, so once the list is scrolled more than one viewport
height deep, every pointer position measures as past the bottom edge
and plans max-speed downward scrolling from anywhere: the runaway
scroll-down and the up-scroll stutter (down-plan fighting upward input)
from dogfood. Test-only commit plus the visibility change it needs;
the fix lands in the next commit so CI shows red then green.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix autoscroll planner coordinate space: viewport-relative edge distances
Subtract the clip view's bounds origin before measuring distance to the
viewport edges. Scroll-down now stops the moment the pointer leaves the
44pt bottom zone regardless of scroll depth, and upward edge scrolling
no longer fights a phantom max-speed downward plan.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Sidebar drag: drop commits the painted plan, not a release-time re-resolve
Dogfood on rapid consecutive drags: the row occasionally landed one gap
away from the last indicator shown. Two races made the drop diverge
from the paint: the pointer can drift a few pixels between the final
draggingUpdated and the release (AppKit sends no update for those), and
an autoscroll tick can shift rows after the last coalesced repaint but
before acceptDrop. Both re-resolved a fresh plan at release time that
could cross a row-midpoint boundary the indicator never showed.
The accepted update now carries the resolver's full drop plan; the
controller stores it alongside the painted indicator and acceptDrop
commits that stored plan verbatim through a new commitWorkspaceDropPlan
action (falling back to point resolution only when no hover plan
exists). Indicator and outcome are now the same object by construction.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Sidebar drag: keep the viewport at the release position after a drop
Half of drops visibly yanked the sidebar away from the drop spot,
often toward the dragged row's old position. Cause: the apply after a
drop shifts the selected workspace's index, which
SidebarSelectedWorkspaceScrollPolicy cannot distinguish from an
external change, so scrollSelectedRowToVisibleIfNeeded jumped the
viewport to the selected row whenever it was offscreen. It only looked
correct when the selected row's index happened not to change or was
already visible. A successful drop now arms a one-shot suppression
consumed by the next apply; external selection changes still scroll.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Re-mint painter pbxproj UUIDs after collision with main
The hand-minted B804x02A build-file/file-reference pair for
SidebarWorkspaceTableReorderIndicatorPainter.swift was independently
allocated on main for SidebarWorkspaceRowTodoViews.swift. After merging
main, both files claimed the same UUIDs; Xcode keeps one definition and
silently drops the other file from the build, which broke compilation
with 'cannot find SidebarRowClosureMenuItem in scope'. The painter now
uses a unique pair outside the sequential B804 series.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
The root scene mounted RestoringStoredMacWorkspaceShell while the stored-Mac
reconnect was in flight and swapped to a bare WorkspaceShellView once
connectionState hit .connected (or the attempt failed, or the 6s startup
restoring gate expired). The branch change destroyed the shell's @State, so a
Settings sheet opened during "reconnecting to your Mac" dismissed itself the
moment the reconnect resolved.
Restoring is now data on one stable surface instead of a separate view:
MobileRootAuthGate.shellSurface picks between the no-devices screen and a
single WorkspaceShellHost (renamed from RestoringStoredMacWorkspaceShell) that
stays mounted across restoring -> connected -> offline and only varies the
shell's loading inputs. The host's 10s loading deadline restarts whenever the
restoring window opens or closes, so a stale timeout cannot outlive its
attempt.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test: relay token mint must fail open when rate-limit rule is missing
The relay token route 503s every request when CMUX_RELAY_TOKEN_RATE_LIMIT_ID
is unset or its Vercel firewall rule was deleted (not-found), taking every
device off the relay network. These tests encode the intended behavior:
no configured rule means no rate limiting, and a deleted rule fails open.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Make relay rate limiter fail open on missing rule and skip when unconfigured
enforceRelayRateLimit (used by /api/relay/token and /api/relay/preferences)
treated every firewall-check outcome except success as fatal: an unset rule id
env failed with rate_limit_not_configured and a deleted Vercel rule (not-found)
failed as rate_limit_unavailable, both returning 503 for every authenticated
request. With the Vercel rate-limit rules removed, every device's relay policy
fetch failed with policyUnavailable, hosts never started their iroh endpoints,
and phones could not discover or connect to Macs.
Mirror the not-found fail-open that PR 8714 applied to services/iroh/
routeHandler.ts: an unset rule id now skips rate limiting entirely, and a
not-found rule logs a warning and proceeds. Real limits (429), blocked
requests, and genuine check failures (thrown/unexpected status, still 503)
keep their behavior.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Hiding the last visible Mac previously cleared the persisted
hasKnownPairedMac hint, which routed the app into the disconnected
add-device takeover with a dead-end Reconnect banner. Hidden Macs now
count as known: the hint survives all-hidden hides, the reconnect sweep
treats hidden markers as stored Macs, and scope load self-heals installs
where an older build already cleared the hint. The root view's takeover
branch is extracted into a pure MobileAuthenticatedShellPresentation
decision that also refuses the takeover while hidden computers exist,
and the workspace list presents all-hidden as a plain empty list
instead of an unavailable banner. The now-unreachable hidden-computer
UI in DisconnectedWorkspaceShellView is removed; the Computers screen
remains the single management surface.
Co-authored-by: Claude Fable 5 <[email protected]>
* Replace iOS computer deletion with per-phone hide
Hiding a computer now records only a device-local hidden marker. The
paired-Mac SQLite row, routes, and customizations are retained, and no
pending-delete outbox entry or server backup tombstone is written, so
Unhide is instant, offline, and lossless. The Computers screen gains a
Hidden Computers section replacing the online-only Recover Deleted
Computer flow; entries hidden by the old delete path (marker without a
row) surface there too and route through the existing live-discovery
revive. The UserDefaults key cmux.mobile.pairedMacs.forgotten.v1 is
deliberately unchanged so prior deletes migrate into the hidden list.
Presence route pushes now keep hidden rows fresh; hidden Macs remain
excluded from lists, workspace aggregation, zero-touch discovery, and
every auto-reconnect path. The server tombstone protocol is untouched
for backward compatibility.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: scope detail-view hide, dedupe Hidden Computers section
The Mac detail view's Hide now targets its exact (macDeviceID,
instanceTag) pairing instead of the alias-based overload that also hid
sibling app instances. The Hidden Computers header/footer/row wiring,
previously implemented three times, is extracted into shared
HiddenComputersSection/HiddenComputersRows/HiddenComputersCopy used by
the Computers screen, the disconnected shell, and its empty state.
Redundant loadPairedMacs() calls after hideMac are dropped (hide
already reloads internally).
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test(ios): reproduce terminal input reordering (#6082)
https://github.com/manaflow-ai/cmux/issues/6082
* fix(ios): preserve terminal input ordering (#6082)
* Address review feedback: dedupe surface lookup, single-runner drain guard, allocation-free scalar split with guaranteed progress
- Reuse workspaceID(containingSurfaceID:) in both raw-input entry points
- Guard drainRawTerminalInputBuffer with an instance-level runner flag so a
stale drain Task surviving rawTerminalInputBuffer.clear() can never run a
second interleaving loop; awaited submitters wait for the active loop
- UTF8.width instead of per-scalar String allocation in nextBatch splitting
- Replace the split-progress precondition with emit-scalar-whole so a cap
narrower than one scalar makes progress instead of trapping
* Use real screenshots in iOS onboarding
* Restore original onboarding connection preview
* Restore original page three connection UI
* Load onboarding screenshots off the main actor
* Test TestFlight upload for every main push
* Upload cmux INTERNAL for every main push
* Harden TestFlight upload ordering
* Remove stale TestFlight schedule event
* test: cover iroh disconnect diagnostics
* fix: classify iroh session disconnects
* fix: import CMUXMobileCore in lane router
App-target-only compile break; package tests could not catch it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat: add iroh-diag CLI verb for headless Connection Report export
Serves the v1 iroh_diag socket command on the worker lane (not
main-thread callable) from the same DiagnosticLog snapshot path the
Settings pane uses, so field flap repros export the host ring with one
command instead of GUI clicks.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: serve iroh_diag without a main-actor hop
CodeRabbit flagged the semaphore bridge; the actionable half is the
MainActor.run dependency, which would hang the verb exactly when the
main thread is wedged. The ring moves to a nonisolated static so the
worker awaits only the log's drain actor. The bounded worker-lane wait
itself matches the established auth.* bridge pattern and stays guarded
off-main by the execution policy.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: name the type explicitly in static ring initializer
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Remove iroh rate limiting: make limiter optional and fail open
CMUX_IROH_RATE_LIMIT_ID was required on prod, and routeHandler ran the
Vercel firewall check whenever it was set. When the rate-limit rule was
deleted, the .well-known check returns 404 -> "not-found", and the old
`if (error)` branch turned that into a 503 iroh_service_unavailable on
every authed discover/challenge/register for every account. That blocked
off-tailnet iroh discovery entirely (the review Mac could not publish an
iroh binding; phones could not discover it).
Two changes so the limit can be removed cleanly:
- env: make CMUX_IROH_RATE_LIMIT_ID optional (z.string().min(1).optional()),
matching CMUX_PUSH_RATE_LIMIT_ID / CMUX_RELAY_PREFERENCES_RATE_LIMIT_ID.
Unsetting the var now skips the firewall gate instead of failing env
validation at boot.
- routeHandler: treat a missing rule ("not-found") as "no limit" and fail
open (continue to the broker) instead of 503. A deleted rule means the
operator removed the limit, not that the service is down. Genuine
unavailability (timeout / unexpected status) still fails closed via the
existing catch.
This makes the deploy itself clear the outage even before the env var is
unset, and prevents a deleted rule from ever bricking iroh again. Adds a
regression test asserting not-found fails open with a 200.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Assert firewall check ran in the fail-open test
Addresses CodeRabbit: the not-found fail-open test would still pass if
handleIrohRoute skipped the injected firewall entirely. Track a
firewallCalled flag inside check and assert it, so the regression proves
the not-found path specifically (firewall ran, returned not-found,
handler failed open to the broker).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 <[email protected]>
Import the audited host protocol implementation, API, and host-only test byte-for-byte from private archive tag cmux-browser-public-snapshot-20260722-slice1 at commit 0821bef9ec386799dc80f7ef67263d31461c2b4a. Record exact source blobs and SHA-256 digests in SOURCE_SNAPSHOT.md.
* Add failing test: Go relay __tmux-compat rejects respawn-pane
Claude Code agent-team teammate panes respawn with
`respawn-pane -k -t %<paneID> <command>`. The Swift/local __tmux-compat
path supports this, but the Go relay path used for SSH sessions has no
respawn-pane case in its command router, so the first teammate spawn
fails with "unsupported tmux command: respawn-pane" and Claude Code
falls back to headless mode for the rest of the session.
Test only (no fix) so CI shows red per the regression test policy.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Support respawn-pane in Go relay __tmux-compat for SSH sessions
Route respawn-pane/respawnp to a new tmuxRespawnPane that mirrors the
Swift __tmux-compat handler: require -k, resolve the surface target,
fall back to the surface's stored start command (then a login shell),
wrap the command in /bin/sh -c so Ghostty can exec shell expressions,
re-supply CLAUDE_CODE_SANDBOXED for opted-in claude-teams sessions, and
dispatch to the same surface.respawn socket method the local path uses.
The raw command is kept in tmux_start_command for display/persistence.
Fixes Claude Code agent-team teammate panes never appearing in remote
(SSH) cmux sessions: the first spawn failed with "unsupported tmux
command: respawn-pane" and Claude Code latched to headless mode.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Cover stored start command and -c cwd in respawn-pane tests
Review findings from the initial port: the stored-start-command fallback
chain (tmux_start_command over pane_start_command over initial_command)
and the -c working_directory passthrough were implemented but untested.
Add sub-tests for both via a surfaceListExtras hook on the RPC recorder,
and switch the suite's HOME override to t.Setenv.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Propagate stored-command lookup failure in respawn-pane
Review finding (greptile P1): when surface.list failed during a
commandless respawn, tmuxStoredStartCommand returned an empty string and
the pane was respawned with the login-shell fallback, discarding its
stored command on a transient RPC failure. Return the error instead so
the respawn fails loudly, matching the Swift path where the stored
command lookup throws. Regression test drives the failure through a
recorder-level method-failure toggle.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
TestTerminateProcessesRunsOnlyOnce has failed since it landed in #8438. The
test asserts teardown is idempotent, but the guard it was written against was
never added, so the foreground-group lookup and both SIGKILLs run again on
every call.
Two paths reach teardown for the same session. waitSessionProcess runs it once
the session leader exits, and the hub runs it on a client close frame, when a
non-persistent attachment's connection ends, on closeAll, and on an idle reap.
The hub paths exclude each other by removing the session from the hub map under
h.mu, but waitSessionProcess sits outside that bookkeeping, so a session that
outlives its leader and is then closed tears down twice.
A second pass can only run after cmd.Wait has returned, so the leader pid it
signals has already been reaped. It also repeats the member scan, which reads
every /proc/<pid>/stat on Linux and forks ps on macOS. Guard the body with a
sync.Once, matching closeTTYOnce and closePTYOnce on the same struct.
Co-authored-by: ejc3 <[email protected]>
The cmux-unit test target does not compile on main:
cmuxTests/AgentResumeLivenessTests.swift:25:25: error: missing argument
for parameter 'processLiveness' in call
#8547 added a `processLiveness` field to RestorableAgentSessionIndex.Entry with
no default, which makes it required in the memberwise initializer. This test
helper still builds an Entry without it, so the whole target fails to build and
no suite in it can run.
hasLiveProcess decides from the PID set alone, so the value only has to be
honest: derive it from the PIDs each case asks for rather than pinning one that
would claim a running process for the empty-PID case.
Co-authored-by: ejc3 <[email protected]>
Import the audited protocol implementation, API, and host-only test byte-for-byte from private candidate 0821bef9ec386799dc80f7ef67263d31461c2b4a (tree 8f65f11a28479674dcb1d0a20f849c2f00b6073c). Record the source Git blobs and SHA-256 digests in SOURCE_SNAPSHOT.md.
* test: cover Dock cwd inheritance
* fix: inherit Dock terminal working directory
* cmuxTests: pass the liveness the Entry initializer requires
The cmux-unit test target does not compile on main:
cmuxTests/AgentResumeLivenessTests.swift:25:25: error: missing argument
for parameter 'processLiveness' in call
#8547 added a `processLiveness` field to RestorableAgentSessionIndex.Entry with
no default, which makes it required in the memberwise initializer. This test
helper still builds an Entry without it, so the whole target fails to build and
no suite in it can run.
hasLiveProcess decides from the PID set alone, so the value only has to be
honest: derive it from the PIDs each case asks for rather than pinning one that
would claim a running process for the empty-PID case.
* fix: isolate live terminal cwd lookup
* fix: resolve interactive Dock split source
* test: cover remote Dock cwd inheritance
* fix: keep remote Dock cwd out of local terminals
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: ejc3 <[email protected]>
* Test: internal TestFlight bundle id must be a valid APNs registration topic
The scheduled internal TestFlight lane ships dev.cmux.app.internal, but
normalizeApnsBundle rejects it, so internal-beta phones fail
POST /api/device-tokens with invalid_bundle_id and never receive pushes.
Failing test first; fix in the next commit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Allow internal TestFlight bundle id for APNs device-token registration
Since https://github.com/manaflow-ai/cmux/pull/8183 the scheduled internal
TestFlight lane ships bundle id dev.cmux.app.internal, but normalizeApnsBundle
only accepted com.cmux.app, com.cmuxterm.app, dev.cmux.app.beta, and
dev.cmux.ios.<tag>. Internal-beta phones therefore fail
POST /api/device-tokens with invalid_bundle_id 400 and can never receive
push notifications. Map the internal bundle id to the production APNs
environment, same as the other TestFlight lane.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(iroh): add broker cooldown floor and credential-returning refresh
CmxIrohBrokerCooldown carries a broker Retry-After directive across endpoint
activation attempts (the credential coordinator honors it only within one
activation, and a torn-down runtime discarded it). CmxIrohBrokerCooldownError
conforms to CmxRetryAfterProviding so the reconnect scheduler can adopt the
server floor. CmxIrohRelayPolicyService.refreshWithCredential returns the
broker-minted relay credential alongside the effective policy so activation
can install it without a second mint.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(iroh): expose reconnect broker retry storm
Behavior-level regression tests: composition-level (counting broker fake,
serialized suite, bounded settle) proving a rate-limited activation floors
retries, surfaces Retry-After through dial errors, and re-reaches the broker
only after the window; plus a transport-level test proving an empty managed
relay fleet (relay policy unavailable) still activates the runtime for
registration and direct paths. Red on the current tree: the Retry-After floor
is discarded between activations, activation mints twice, and an empty fleet
fails activation before any broker call (the unclassified endpointFailed
b=255 signature from the field diagnostics).
Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(iroh): honor broker Retry-After across endpoint activations
Reconnect lockout fix, three legs sharing one mechanism:
1. The composition records an account-scoped CmxIrohBrokerCooldown when any
activation leg fails with a Retry-After directive, gates the next
activation on it (zero broker calls while floored, retryScheduled
diagnostic), and surfaces the remaining floor through the inactive-runtime
dial errors as CmxIrohBrokerCooldownError. The shell's existing
CmxRetryAfterProviding seam then adopts the server floor instead of its
2-64s transient backoff, so a rate-limited phone stops re-exhausting the
server's fixed rate-limit window on every retry.
2. One activation now costs one relay-token mint: refreshWithCredential
returns the bootstrap-minted credential and the runtime configuration
prefers it (fleet-compatible) over the disk cache, so the credential
coordinator installs it instead of minting again.
3. An unavailable relay policy no longer kills activation: with an empty
managed fleet the offline-policy expectation is skipped and the discovery
fleet cross-check is bypassed (nothing verified to compare, no relay gets
configured), so registration and direct LAN paths proceed without relays
instead of failing with invalidExpectation/relayFleetMismatch before or
during the first broker round trip.
Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(iroh): extend broker cooldown to the Mac host and bare 429s
Parity hardening on top of the client reconnect fix:
- The Mac host runtime now records the same account-scoped broker cooldown
when activation legs fail with a rate-limit directive, gates re-activation
on it (zero broker calls while floored, retryScheduled diagnostic), and
clears it on successful activation, so a Mac can no longer storm the
broker or keep a shared rate-limit window exhausted.
- Host activation reuses the bootstrap-minted relay credential
(refreshWithCredential) instead of minting twice.
- A 429 without a Retry-After header now arms a short default floor via the
shared CmxIrohBrokerCooldown.directiveSeconds helper (client and host), so
a missing header can never reopen the retry storm.
- The offline-policy and binding-expectation errors carry diagnostic failure
kinds instead of decoding as unknown (the b=255 field signature).
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(ios): persist diagnostics across launches and add verbose log opt-in
Two export lanes so connection drops stay diagnosable in release builds:
- The privacy-safe diagnostic ring is archived when the scene backgrounds
(DiagnosticReportArchive, bounded JSON in Application Support) and the
Share Safe Report export now prepends the previous launch's block, so a
drop followed by a relaunch no longer erases the evidence.
- A localized Verbose Connection Log toggle in the Iroh settings enables the
durable debug-log file in release builds (DEBUG keeps logging always-on),
persisted across launches, with a Share Verbose Log row for the file. The
sink gained a runtime setFileLogging toggle; terminal contents and
credentials are never written to this lane.
Localization audit: three new keys added to CmuxMobileShellUI
Localizable.xcstrings with en and ja translations; no other user-facing
strings changed.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(web): partition relay token rate limit per device
The relay token limiter keyed by account only, so one storming device
exhausted the budget for every phone, simulator, and tagged build on the
account. The check now keys by account plus the validated endpoint id and
runs after body validation, so malformed requests never consume a device's
budget and a misbehaving device only starves itself. The devices/iroh routes
already partition per account, operation, and registration identity.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(ios): attribute drops with lifecycle, reachability, and trigger events
Three export-vocabulary additions so a single Share Safe Report can name a
drop's cause remotely:
- appLifecycleChanged (52): scene phase transitions recorded at the existing
composition hooks, so a session that closes seconds after backgrounding
reads as a suspension casualty rather than a network failure. Verified
live on the simulator (inactive/active/inactive/background sequence in the
archived report).
- reachabilityChanged (53): the shell's network path observation records
online state on every transition, correlating drops with WiFi/cellular
moves.
- recoveryStarted now carries the recovery trigger in its b slot (stable
append-only codes), so every reconnect cycle names why it began: network
change, manual retry, presence push, foreground, liveness, stream end,
subscription failure, write timeout, or backoff expiry.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): close review findings on diagnostics persistence
- Account erasure and sign-out now clear the archived diagnostic report and
its in-memory cache, so a second account can never export the previous
account's connection timeline.
- The verbose-log opt-in persists only when the sink actually opened the
file, and the Settings toggle reverts when enabling fails, so the UI can
no longer claim to be recording without a log.
- Archive reads and writes moved behind a detached utility task; scene
backgrounding no longer spends main-actor time or the suspension window on
filesystem work.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(iroh): cover operation-scoped broker floors
* fix(ios): persist Iroh broker backpressure
* refactor(ios): isolate Iroh terminal gate probe
* test(ios): cover wrapped Iroh gate marker
* fix(ios): recognize wrapped Iroh gate output
* refactor(iroh): expose host broker preflight seam
* test(iroh): cover restored host broker floors
* fix(iroh): persist host broker backpressure
* test(iroh): cover persisted floor overflow
* fix(iroh): preserve overflow broker floors
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Codex <[email protected]>
* Add workspace-only focus history setting
* Default focus history to workspaces only
* Preserve workspace history across pane focus
* Propagate focus history settings to restored workspaces
* Modernize focus history coverage
* Construct detached workspaces with manager settings
* Qualify detached workspace transfer type
* Respect workspace scope for existing history
* Keep legacy history tests explicitly pane scoped
* Restore legacy XCTest fixture
* Register legacy focus history test storage key
* Scope pane history defaults to migrated tests
* Disambiguate shortcut type in history tests
* Support history scope in cmux.json
* Import Foundation in history scope tests
* test sidebar shortcut hint animation
* Animate AppKit sidebar shortcut hints
* test explicit AppKit hint fades
* Fix native sidebar hint fades
* Fix actor initializer for Xcode 26.5
* test reduced motion shortcut hints
* Make shortcut hint motion policy deterministic
* test shortcut hint cell reuse
* Reset shortcut hints when cells are reused
* test same-workspace shortcut hint reuse
* Reset shortcut hints in cell reuse lifecycle
* Add regression tests for duplicate agent-resume dedup/liveness gate (#8446)
AgentResumeLaunchGuard and AgentResumeLiveness are new, currently-inert
scaffolding: claimResumeLaunch() always allows a launch and
hasLiveProcess() always reports "not live", matching cmux's current
(buggy) behavior of firing a resume for every restored panel with no
liveness check and no cross-panel dedup. The two test suites fail
against these stubs, proving they actually exercise the missing
behavior before the fix lands in the next commit.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Stop firing duplicate agent resumes on relaunch (#8446)
On app relaunch, Workspace.createPanel decided per-panel, independently,
whether to auto-resume a restored agent session (codex resume <id> /
claude --resume <id>), using only the persisted wasAgentRunning flag with
no check of whether a process for that session was already alive. Plain
(non-tmux) resumes had no liveness check at all, and nothing prevented
two panels referencing the same session from both firing a resume in the
same restore pass. After a crash, this could fire a resume for every
restored panel simultaneously, piling up redundant processes contending
for the same on-disk session data (SQLite lock contention, etc).
Fix, scoped to the restorableAgent-driven resume path (the one that was
actually unconditional):
- Before constructing restoredAgentResumeLaunch, consult the same
live-process index already used for "reopen closed tab" and Fork
Conversation availability (SharedLiveAgentIndex /
RestorableAgentSessionIndex.entry(workspaceId:panelId:)) via the new
AgentResumeLiveness.hasLiveProcess(for:kind:sessionId:). If a process
for this exact session is already alive, skip the resume.
- Add AgentResumeLaunchGuard, a per-process-lifetime dedup guard: the
first panel to resume a given (kind, sessionId) claims it; any other
panel referencing the same session in the same restore pass is turned
away, even before the freshly spawned process becomes visible to the
live-process index.
- Generalize reconcileSurfaceResumeBindings, previously tmux-only, to
also drop a plain agent-hook resume binding when
AgentResumeLiveness reports its session is no longer live, so a
normal exit of a non-tmux agent doesn't leave a stale binding that
gets replayed as a resume on the next relaunch.
wasAgentRunning's nil-defaults-to-true backwards-compatibility behavior
is untouched: a genuine crash-restart with nothing else alive still
auto-resumes normally.
Full end-to-end restore/spawn behavior (an actual PTY resuming an
actual codex/claude process) isn't unit-testable without a running app,
so coverage is the two isolated regression suites added in the prior
commit (now green) plus this wiring, which was verified by rebuilding
the app target and manually tracing the createPanel/
reconcileSurfaceResumeBindings changes against the existing restore
code paths.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Fix agent-resume dedup regressions found in review (#8446)
Structured review (Codex) and Greptile independently flagged real bugs
in the new resume dedup/liveness gate:
- AgentResumeLaunchGuard claims were permanent for the app's process
lifetime. If a panel's launch construction failed after claiming, or
the resumed agent later exited and the user reopened its closed tab,
the stale claim permanently blocked a legitimate future resume for
that session. Claims now expire after a 60s TTL: long enough to
break the same-restore-pass race the guard exists for, short enough
that a much-later legitimate resume is never blocked.
- When agentSessionAlreadyActive suppressed restoredAgentResumeLaunch,
the (unconditional) resumeReboundSession rebind still ran, pointing
the authoritative session registry at this inactive panel instead of
the panel actually holding the live process. Mobile/chat routing
could then target the dead duplicate. Now skipped whenever the gate
skips the launch.
- isStaleAgentHookBinding consulted the separately TTL-cached
SharedLiveAgentIndex.shared.index instead of the freshly loaded
RestorableAgentSessionIndex already available (same scan generation
as the SurfaceResumeBindingIndex) at every real call site. Threaded
the fresh index through reconcileSurfaceResumeBindings so pruning
and the binding scan it pairs with describe the same snapshot.
Added AgentResumeLaunchGuardTests coverage for TTL expiry.
Not fixed here (needs a scope decision): review also flagged that
restoredBindingLaunch (the agent-hook SurfaceResumeBindingSnapshot
path) is never gated by AgentResumeLaunchGuard/AgentResumeLiveness at
all, only the restorableAgent-driven path is. Widening the gate to
that path touches the separate approval/remote-SSH/tmux policy already
governing resumeBindingForStartup and needs explicit sign-off before
a unilateral change.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Release resume claims early on failure; bound claim table growth (#8446)
Addresses two more review findings (CodeRabbit, round-2 Codex review),
both scoped entirely to AgentResumeLaunchGuard:
- releaseResumeLaunch(kind:sessionId:) frees a claim immediately when
the caller discovers its own launch never happened (terminal surface
creation failed after the claim was taken in createPanel), instead
of leaving a legitimate resume blocked for up to the 60s TTL.
- claimResumeLaunch now prunes expired entries on every call, so the
singleton's claim table stays bounded by currently-in-flight claims
rather than growing by one entry for every distinct agent session
ever resumed over the app's lifetime.
claimedSessionKeys widened from private to internal so the added
regression tests can assert on eviction via @testable import rather
than a debug-only test accessor in production source.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Fix missing Foundation import breaking cmuxTests build (#8446)
AgentResumeLaunchGuardTests.swift gained Date-based TTL tests in the
review-fix commits but only imported Testing, not Foundation, which
fails the whole cmuxTests target (all 4 CI shards) with "cannot find
'Date' in scope" rather than a normal test failure.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
* Do not prune persistentSSH agent-hook bindings via local liveness (#8446)
Round-3 structured review flagged that isStaleAgentHookBinding never
checked SurfaceResumeBindingSnapshot.launchFlavor. RestorableAgentSessionIndex
/ SharedLiveAgentIndex are built from a local process scan (pid/sysctl-based),
so a .persistentSSH agent-hook binding's remote-host process can never
appear in it — every call would report the binding "stale" and
reconcileSurfaceResumeBindings would delete it on the very next
snapshot, permanently losing the ability to auto-resume that remote
agent session, even while it was still very much alive.
Restrict the staleness check to .local bindings; remote bindings are
left untouched by this local-scan-based mechanism, same as the
existing "return false" default for any binding this function doesn't
understand.
Added WorkspaceIsStaleAgentHookBindingTests covering both the .local
(still correctly pruned when no live process) and .persistentSSH
(never pruned by this check) cases, wired into project.pbxproj and
verified with scripts/lint-pbxproj-test-wiring.sh.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
* Add failing test for terminal zoom session restore
Inject a per-terminal font size into a persisted workspace snapshot, restore it, and require the next capture to preserve that zoom. This fails until terminal session snapshots retain the explicit surface override.\n\nRefs #8515
* Persist explicit terminal zoom across session restore
Capture each terminal surface's unscaled font size together with Ghostty's explicit-adjustment ownership. Persist only explicit overrides, restore them through the shared surface config lineage, and preserve that lineage across hibernation, splits, tabs, and workspace creation. Cmd+0 clears the native ownership so unzoomed terminals continue following config changes.\n\nCloses #8515
* Fix terminal zoom lineage lifecycle edge cases
* Preserve mutable runtime surface config
* Clear stale terminal zoom inheritance
* Make terminal zoom capture allocation-free
* Preserve terminal zoom ownership during mobile fitting
* Keep unzoomed restores on current terminal config
* Keep terminal zoom inheritance source coherent
* test: reject unmounted terminal zoom sources
* fix: record terminal zoom source after insertion
* Move terminal font creation policy into core
* test: preserve initial terminal font template
* Preserve terminal font size on first runtime creation
* test: cover font ownership and crash pruning
* Preserve explicit terminal font ownership
* test: cover font bounds and live lineage cache
* fix: bound font persistence and refresh lineage cache
* test: preserve terminal runtime default sentinel
* fix: preserve terminal runtime default sentinel
* Restore todo/status parity in the AppKit sidebar
The AppKit sidebar rewrite reimplemented the workspace todo and
completion-status features loosely. This restores exact legacy parity
inside the sidebar:
- Manual task-status glyph on the title line (pie/checkmark geometry,
colors, monochrome-on-selection, tooltip), opening the real SwiftUI
SidebarWorkspaceStatusPopover in an NSPopover.
- Compact 'Status: X' flag row in hide-all-details mode with the shared
status-lane menu.
- Done rows dim their content to 60% (background/rail/chrome excluded)
via a content container, like the legacy .opacity(0.6).
- Checklist section rewritten for parity: summary line with leading
checklist/checkmark icon and two-tone count + first-unchecked preview,
full-width click target; inline expansion shows ALL items (completed
sink below) in a 6-row-capped scrollable viewport with wrapping text,
tap-to-edit fields, attachment menus, item context menus, hover-reveal
remove with reserved slot, and the ghost '+ Add item' row whose field
re-arms after each commit.
- Popover checklist style presents the legacy SwiftUI
SidebarWorkspaceChecklistPopover (header, keyboard navigation,
always-armed add field, Open as Pane footer) anchored at the section's
top-trailing edge, driven by container-owned presentation state so
context-menu/palette 'Add Checklist Item…' opens it.
- Tap-to-edit state is container-owned so the prototype height
measurement matches the live cell.
- Context-menu todo section gated on WorkspaceTodoFeature.isEnabled.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix add/edit field editor box and attachment chevron parity
The focused checklist field's editor was installed against a zero frame
(focus grabs on window attach, before layout), drawing an oversized dark
box over the row; give fields a valid frame before attaching and clear
the editor background like the legacy borderless fields. Also add the
borderless-menu disclosure chevron to the attachment button so item text
wraps at the same width as the legacy row.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Pack the attachment chevron inside the legacy 17pt slot
Co-Authored-By: Claude Fable 5 <[email protected]>
* Dogfood round 1 fixes: no premature checklist height, status popover edge, pressed dim
- Zero-item popover-style mounts (open first-item popover / pending add
token) keep the checklist section as a zero-height anchor instead of
adding the inter-slot spacing, so the row no longer grows before the
first item exists.
- The status popover opens to the right of the glyph: the glyph hugs the
sidebar's left edge, so a below-anchor popover bent its arrow into the
rounded corner.
- The custom status/checklist controls dim to 50% while pressed, matching
the SwiftUI plain-button feedback of the legacy views.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review findings: a11y press actions, gate in model, popover release
- The custom status/checklist NSControls now implement
accessibilityPerformPress() (and the menus present from it), restoring
the VoiceOver/keyboard activation the legacy SwiftUI Buttons provided.
- WorkspaceTodoFeature.isEnabled is projected into the row model
(todoControlsEnabled) so a rollout/opt-in flip changes model equality
and reconfigures + re-measures rows, instead of a live global read the
table never observes.
- The SwiftUI popover presenter resets its hosting root on close so
pooled cells stop retaining the last-presented workspace's closures.
- The status-lanes menu builder moved onto the owning cell (drops the
static-namespace factory); edit-commit now passes the trimmed text like
the add row.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-2 review fixes: edit-commit identity, popover reuse write-back
- Edit commits capture the edited item id and its workspace's action
bundle at field-creation time: the pooled line's item/actions are
overwritten by reconfiguration before the old editor tears down, so a
teardown-triggered focus-loss commit could write the draft into
whichever item the line showed next.
- Cell reuse for another workspace now writes the presented workspace's
checklist-popover state back to closed via a dismiss context captured
at present time (legacy host dismantle parity) instead of stranding
container state that re-presented the popover on scroll-back.
- The shared snapshot-cache staleness across a feature-flag flip is
pre-existing pipeline behavior affecting both sidebar paths; tracked in
https://github.com/manaflow-ai/cmux/issues/8569.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-3 review fixes: pooled-cell reuse state sweep
Same reuse class as round 2, applied across the section: the add field's
bridge captures its commit/cancel closures at creation (teardown-triggered
focus-loss commits were routed through the replaced stored closures), the
checklist scroll offset resets on workspace change, the hover-revealed
remove button clears on item-identity change, and the attachment control's
height accounts for the magnified count label.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Freeze workspace-bound add-field closures at their build site
Round-3 froze the wrapper closure values, but the wrappers themselves
still dereferenced the pooled section's mutable actions at fire time.
The section now freezes checklistAddItem / the token consumer per
configure pass, and the add row re-arms itself only while it still shows
the armed state that created the editor.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-5 review fixes: workspace-keyed add editor, pooled-child reuse teardown
The armed add editor is now keyed by workspace id AND token (per-workspace
tokens collide at 1, so recycling a cell between two armed workspaces kept
the old draft and bridge), and hidden pooled children — surplus item
lines, the unmounted section's children, and the hidden add row — drop
their items, editors, and workspace-bound action closures instead of
retaining closed workspaces indefinitely.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-6 review fixes: teardown re-entrancy, menu binding, unmount write-back
- The add field re-arms only on an explicit Return commit (bridge
onReturnCommit); focus-loss commits during teardown or replacement no
longer synchronously re-arm and strand an untracked editor, and
resetForReuse disarms before removing the field.
- The compact-status menu freezes the workspace-bound apply/hide closures
at build time (menu tracking allows the cell to be recycled mid-track).
- Unmounting a presented checklist popover (todo controls disabled with an
empty list) writes the container's presentation state back to closed.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-7 review fixes: clear committed draft, coalesce popover refreshes
A focus-loss commit now clears the add field's submitted draft (legacy
recreated an empty field; keeping the text armed double-added it on a
later Return), and the popover presenter defers + coalesces visible root
updates through CmuxPopoverVisibleUpdateScheduler like the legacy host,
instead of forcing synchronous hosted layout inside the table's
representable update turn.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-8 review fixes: add-field session latch, frozen Edit menu action
The end-editing latch re-opens after an add-field focus-loss commit (the
armed field's next commit was silently dropped), scoped to bridges that
opt into onEndEditingCommit so edit-field sessions stay latched. The item
context menu's Edit action freezes its workspace-bound closure at build
time like the sibling menu items.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-9 review fix: ID-keyed checklist line identity
Item lines are now pooled by item ID (legacy ForEach identity) so a
checklist reorder MOVES a line together with any active editor instead of
reassigning the line to a different item, which tore the editor down and
re-seeded it with stale text, overwriting the in-progress draft. Vanished
items' lines reset their captured workspace state and park in a free pool.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-10 review fixes: duplicate-ID line reclamation, unmount session reset
Line reclamation now walks the previous ordered lines by identity so
duplicate persisted item IDs cannot orphan a still-visible line (the ID
map alone lost the earlier duplicate every pass), and the unmounted path
fully resets the popover session trackers — the armed dismissal latch plus
stale token tracker dropped the next Add Checklist Item request, which
restarts at token 1 after consumption.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-11 review fixes: file split, late-attachment editor clear
Each stateful todo/checklist control moves to its own file per the
one-major-type-per-file standard (section, summary line, item line, add
row, attachment button, field support, popover presenter, status glyph,
compact status line), and the checklist fields clear the field editor's
background after the deferred focus grab too — cells configured before
window attachment previously restored the dark editor box.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Allow subclassing FocusGrabbingTextField for the sidebar checklist fields
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-12 review fixes: scoped edit-session end, retained-field restyle
Editor commit/cancel now end only their own item's session via
onEndChecklistItemEdit (a torn-down editor's focus-loss commit was
unconditionally clearing an edit the user just started on another item),
and retained add/edit fields reapply font and palette colors when the row
presentation changes instead of keeping selected-row styling on an
inactive row.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-13 review fix: accessibility labels on custom parent controls
The ghost add row, checklist summary line, and compact status line now
carry localized accessibility labels (their child text fields are removed
from the accessibility tree) — the custom NSControls do not combine child
text into an accessible name the way the SwiftUI Buttons they replace did.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-14 review fix: accessible identity for the tap-to-edit overlay
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-15 review fix: popover teardown on window detachment
A cell removed from the table without another configure pass (workspace
deleted, row unmounted, reuse enqueue) now closes both popovers and writes
the checklist presentation state back, matching the legacy host's
dismantle path.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-16 review fixes: 1pt popover anchor, configure-key memo
The anchor-only checklist mount keeps a 1pt visible frame (an empty
visibleRect can make NSPopover refuse the anchor on some macOS versions)
without contributing row height, and the section memoizes its full input
key so hover repaints, optimistic paints, and pump ticks skip rebuilding
up to 50 attributed item lines when nothing checklist-visible changed.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-17 policy fixes: one major type per file
The remaining companion types (ghost add button, transparent overlay,
focus field, pressed-dim constant, content container, flipped document
view) move into their own files.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Latch content-initiated popover dismissal like external dismissal
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test: workspace table must drive nav/tab bar scroll edge effects
The workspace list is a UIViewRepresentable UITableView, so SwiftUI never
registers it as the bars' content scroll view and the .soft top edge style
never renders: rows hard-clip at the search bar's bottom edge instead of
soft-fading under the chrome like the App Store list.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: render the native soft scroll edge effect under the workspace list chrome
Register the workspace UITableView as the content scroll view of its
enclosing navigation and tab bar controllers (setContentScrollView top /
bottom, iOS 26-gated) from didMoveToWindow, with a layoutSubviews retry
until the controller parent chain is assembled. UIKit then renders the
table's existing .soft top edge effect under the navigation bar + search
drawer and drives the tab bar's bottom edge, matching the App Store's
soft blur instead of hard-clipping rows at the search bar boundary.
Unregistration on window removal only clears registrations this table
still owns, so a replacement table's registration is never clobbered.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: extend the workspace table under the vertical bars so the soft edge effect renders
SwiftUI fits a UIViewRepresentable inside the safe area, so the table's
frame started below the search drawer and ended above the tab bar
(AX-verified frame {0,176,402,664} on a 402x874 screen): rows hard-clipped
at the table's own bounds and the scroll edge effect had no covered region
to render into. ignoresSafeArea(.container, edges: .vertical) restores the
native underlap; the real UIKit bars still contribute safe area, so
automatic content-inset adjustment keeps rows and indicators clear of the
chrome, and the soft edge effect + bar registration now render the App
Store-style progressive blur at both edges.
Also adds CMUX_UITEST_WORKSPACE_LIST_PREVIEW_TABS=1 to the DEBUG preview
fixture, wrapping the list in a TabView so the floating tab bar's bottom
edge can be dogfooded without Mac pairing. Off by default to keep the App
Store screenshot rig's chrome unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Re-resolve scroll edge bar registration on every layout pass
A one-shot registration flag stranded a partially assembled hierarchy:
if the navigation controller hosted the table before joining the tab bar
controller, the top-only registration cleared the retry flag and the
bottom edge never registered (Greptile P1). Re-resolving each layout pass
also covers reparenting that never changes the window (compact stack to
split sidebar); the coordinator's identity guard makes the repeated call
a no-op when nothing changed. Adds a regression test attaching the tab
bar controller after the first registration.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Reclaim scroll edge registration after a transient replacement departs
The identity guard trusted the coordinator's cache, so when a transient
replacement table took the registration over and cleared it on departure,
the surviving table saw unchanged targets and never re-registered, leaving
both edges dead until the hierarchy changed. The guard now also compares
the controllers' effective contentScrollView(for:) against this table, so
the next layout pass self-heals the reverse handoff. Regression test
covers replacement mount, takeover, departure, and reclaim.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Claim scroll edge registration only when vacant or stale
The previous self-heal re-registered whenever the effective registration
differed from this table, so two live coexisting tables (SwiftUI
transition overlap) stole ownership from each other on every layout pass,
making bar ownership layout-order dependent. An edge is now claimed only
when its registration is nil or held by a detached scroll view; a
different live table keeps ownership, departure clears the edge, and the
survivor reclaims on its next layout pass. Tests pin both the no-steal
coexistence and the reclaim-after-departure handoffs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Describe the late-tab-attachment test by the lifecycle it actually exercises
UIKit's hierarchy-consistency check forbids re-parenting a controller
whose view stays in a foreign hierarchy (attempting an addChild-based
window-stable variant throws UIViewControllerHierarchyInconsistency), so
a same-window assembly scenario is not constructible in a unit test; real
container attachment always relocates views. The test now documents that
it pins the end state through whichever lifecycle path fires, and points
at survivingTableReclaimsRegistrationAfterOwnerDeparts as the
window-stable layout-retry coverage.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Gate the bar underlap to iOS 26
ignoresSafeArea applied on every supported release while the scroll edge
registration is iOS 26-gated, so iOS 18-25 would scroll full-opacity rows
beneath legacy bars with no edge effect. The underlap now lives in an
availability-gated ViewModifier: iOS 26 gets the App Store treatment,
earlier releases keep the fitted frame they have today.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Nudge the waiting table when a departing owner clears a bar edge
An overlapping successor stands down while the owner is live, and UIKit
does not guarantee it another layout pass when the owner later departs,
so both edge registrations could stay vacant until an unrelated scroll or
resize. Clearing an edge now finds the remaining workspace table under the
same controller and marks it needing layout; its next pass runs the normal
claim arbitration. The reclaim test now only flushes pending window layout
instead of dirtying the survivor by hand, so a missing wake fails it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Apply review policy: instance helpers and a dedicated underlap file
Converts the coordinator's pure static helpers to instance methods (the
type holds state; statics on it read as namespace members) and moves
WorkspaceListBarUnderlap into its own file per file-organization policy.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(ios): Equatable-gate high-churn toolbar menus against UIMenu rebuilds
Apply the TerminalPickerMenu isolation pattern (PR 7959) to the four
menus whose owning views re-render during live sync/streaming while the
menu can be open: the computer title picker, the workspace-detail title
menu, the new-workspace split menu, and the workspace list filter menu.
Each now renders from an Equatable value snapshot with closures excluded
from equality, so parent body churn no longer rebuilds an open UIMenu.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): Equatable-gate streaming-adjacent menus and context menus
Extend the menu-isolation sweep to the eight medium-churn sites: group
header row, notification feed row, artifact gallery items and sort menu,
chat artifact viewer toolbar menu, chat prose bubble copy menu, and both
task composer pickers. Actions now key on stable IDs and resolve current
state at invocation, so gated snapshots cannot go stale.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): pass artifact path to path-keyed pager toolbar actions
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Swift 6 mode compilers (Xcode 26.6) reject `convenience` on actor
initializers, so GitHubPullRequestRequestCoordinator from
https://github.com/manaflow-ai/cmux/pull/8521 fails to compile and every
local and Blacksmith macOS build of main is broken. Actor initializers
have been able to delegate without the keyword since Swift 5.7 (SE-0327),
so removing it is compatible with older toolchains too.
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* test: cover Dock terminal link routing
* fix: route Dock terminal links through shared container
* fix: make terminal link request nonisolated
* fix: fail closed in terminal link routing
* refactor: move pane resolver into CmuxPanes
* docs: clarify pane resolver API
Actors do not take convenience initializers, so `public convenience init()`
fails to compile: "initializers in actors are not marked with 'convenience'".
The whole CmuxGit module fails to emit, which takes the app build and every
test target with it. Removing the keyword keeps the delegation to the internal
initializer and the public surface unchanged.
Co-authored-by: ejc3 <[email protected]>
* Fix PR poller to use per-branch head= queries so ETag cache works
The workspace PR poller resolved badges by paginating
`repos/{o}/{r}/pulls?state=all&sort=updated&direction=desc&page=N` on
every cold refresh. Because `sort=updated` reorders the list whenever any
PR in the repo is touched, the response ETag changed constantly, so the
coordinator's conditional `If-None-Match` cache almost never hit 304 —
the poller re-downloaded the full recent-PR window on nearly every pass.
Make the per-branch `head={owner}:{branch}` lookup the primary resolution
path (it was previously only a fallback for gaps). Each candidate branch
gets its own small, stable response whose ETag only changes when that
branch's PR changes, so unchanged branches revalidate to 304 (or are
served from cache) instead of re-fetching.
Also back off on 404: a per-branch lookup that returns 404 now resolves
to `.notFound` (folded into `knownAbsentBranches`) instead of
`.transientFailure`, so a renamed/deleted/inaccessible repo stops being
re-polled on the fast loop.
Removes the now-unused `repoPageLimit` constant and the cold-path
pagination block. `pullRequestMapByNormalizedBranch` is retained (still
exercised by unit tests). Note: dropping the whole-list map means a
long-lived non-default base branch (e.g. `develop`) may surface a merged
badge the old map special-cased; per-branch stale-merged filtering is
still applied via `preferredPullRequest`/`isBadgeCandidate`.
Adds fetch-layer tests (stub URLSession) covering: cold refresh issues
per-branch `head=` requests only (no `page=` listing), an unchanged
branch revalidates to 304 on the next refresh, a 404 branch becomes
known-absent and is not re-polled from a fresh cache, and multiple
candidate branches each get their own request.
Refs manaflow-ai/cmux#8367
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 34e78c9a-3c30-46df-a6fa-798c9d3cb17d
* Clarify 404 handling in branchFetchResult
A 404 from the pulls list endpoint is repo-level (renamed/deleted/no longer
visible), since a branch with no PR returns 200 []. Document why folding it
into knownAbsentBranches is correct and how regained access recovers.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 34e78c9a-3c30-46df-a6fa-798c9d3cb17d
* Address PR review: remove test seam, fix 404 doc, rename cache flag
- Replace the test-only initializer with dependency injection: widen the
existing public init with a defaulted requestCoordinator: parameter
(mirrors how commandRunner is faked) and make
GitHubPullRequestRequestCoordinator a public actor so it can appear in
the public signature. Removes the production test seam flagged by the
no-test-debug-seam-in-production-source review rule.
- Correct the branch 404 comment: GitHub returns 404 (not 403) for missing
scope/SSO on private repos, and the knownAbsentBranches suppression is
bounded to one cache window; document that the 200 [] no-PR path shares
the same bounded backoff.
- Rename useCachedRecentWindow to useFreshCache for accuracy.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
* Add public init to GitHubPullRequestRequestCoordinator
The coordinator was made a public actor so it can appear in the public
PullRequestProbeService(requestCoordinator:) signature, but its designated
initializer stayed internal, leaving external modules unable to construct one.
Add a public convenience init() that delegates to the internal designated
initializer with defaults, so the public parameter is actually usable while the
session/cache tuning knobs stay internal (tests reach them via @testable import).
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 4959e1f9-f8e6-4e97-a487-f395a0123c79
---------
Co-authored-by: Copilot <[email protected]>
* chore(web): apply weekly SEO and AEO research
* test(web): render Open Graph images for every locale
* fix(web): bypass slash form of Open Graph route
* test(web): require visible localized social taglines
* fix(web): render every localized social tagline
* test(web): use default social image timeout
* test(web): require inset social screenshot
* fix(web): inset social preview screenshot
* fix(web): harden localized social image delivery
* fix(web): cache social cards and verify glyph coverage
* fix(web): trace social assets without test seams
* Update Set Up Computer and Switch Computer copy for zero-touch pairing
The Set Up Computer sheet predates zero-touch Iroh discovery and still
taught QR scanning as the only way to connect, with protocol detail
(retry paths, admission, per-path authentication) that belongs in docs,
not a help screen. Switch Computer implied pairing is the only way a
computer enters the list.
Rewrite both sheets around the current behavior: a computer signed in
to the same account appears and connects automatically while cmux runs
on it, QR pairing stays as the manual fallback, and Iroh gets one plain
explanation (direct when possible, encrypted relay when not, both ends
verify your account). English and Japanese updated together.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: JA footer says appear, mismatch copy gives explicit recovery
CodeRabbit: the Japanese host-picker footer said computers are added
(追加) where English says appear; use 表示 to match. The account-mismatch
gate now tells the user to sign this phone out and back in with the
computer's account, which also recovers a stale session when the
accounts already match.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Narrow auto-appear copy to the computer that actually connects
Structured review: the host picker lists only stored pairings, and
zero-touch discovery persists only the Mac that completes an
authenticated connect, so 'computers on your account appear here
automatically' was false for multi-computer accounts. The footer now
says a computer joins the list the first time this phone connects to
it, and the sign-in gate promises finding 'your computer', singular.
English and Japanese updated together.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Make account-mismatch recovery bidirectional again
Structured review: accountMismatch only proves the two devices are on
different accounts, not which one is wrong, and the previous wording
told users to adopt the computer's account even when the computer was
the misconfigured side. Now either device can be signed out and back
in so both share the intended account, keeping the explicit sign-out
step that recovers a stale phone session.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Give the empty computer list a QR fallback
Structured review: the empty state is also reached after forgetting the
last computer, and zero-touch discovery deliberately excludes forgotten
Macs, so the unconditional auto-appear promise strands that recovery
path. The empty state now ends with the Pair Another Computer QR
fallback, matching the setup-help gate copy.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test: signOut must not seed placeholder workspaces
First launch runs an unauthenticated auth sync that calls signOut(),
which seeds PreviewMobileHost fixtures ("cmux"/"Build", "Docs"/"Notes")
into workspacesByMac. The fake rows render as real disconnected
workspaces on first login and linger after sign-in until the Mac
connects.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: stop seeding fake placeholder workspaces on sign-out
signOut() runs on every unauthenticated auth sync, including first
launch, and seeded PreviewMobileHost fixtures ("cmux"/"Build",
"Docs"/"Notes") into workspacesByMac as a disconnected placeholder.
Nothing cleared them on sign-in, so users saw fake disconnected
workspaces on the login screen and while the app connected to their
Mac. Seed an empty per-Mac map instead; the fixtures remain for SwiftUI
previews and the UITest preview harness via MobileShellComposite
.preview().
Co-Authored-By: Claude Fable 5 <[email protected]>
* Update signOut tests to the no-placeholder contract
signOutReturnsToPreviewHostState asserted the placeholder seeding this
PR removes; it now asserts sign-out leaves no workspaces and keeps the
group wipe. signOutForgetsComposerDismissals relied on signOut reseeding
the same terminal ids; it now reseeds explicitly (the next account's Mac
reporting the same ids) and still proves dismissals are forgotten.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: unified toast system (CmuxMobileToast) replacing ad-hoc toasts
New CmuxMobileToast package: ToastCenter (@Observable, clock-injected
dwell/queue/coalescing policy, fully unit-tested) + one toastHost overlay
mounted at the root scene. Toasts render in a passthrough UIWindow above
sheets, with Liquid Glass (material fallback), spring+blur arrival,
interactive drag-to-dismiss with velocity projection, coalescing bump
pulse, haptics, VoiceOver announcements, and Reduce Motion/Transparency
fallbacks.
Replaces WorkspaceActionToast (bottom capsule in WorkspaceShellView) and
ChatScreen's Timer-based error banner; workspace-action failures now read
as title + sentence reason (localization keys reworked en+ja). DEBUG
gallery behind CMUX_TOAST_GALLERY=1.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Toast gallery: scripted autorun for recorded animation verification
CMUX_TOAST_GALLERY_AUTORUN=1 walks every style, placement, coalescing
bump, and queue advance on a fixed cadence so a simulator screen
recording captures the full motion vocabulary without UI driving.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix toast touch capture: geometry-gated passthrough window
SwiftUI renders the card without dedicated UIViews, so UIWindow.hitTest
returns the hosting view for card and empty space alike — the previous
view-identity filter passed EVERY touch through, leaving tap/drag/action
dead. The card now publishes its window-space frame (onGeometryChange →
ToastHostChrome.interactiveRegion) and the window captures only inside
it. Verified on-device via the DEBUG hitTest probe (empty overlay nil /
card region captured / beside-card nil), written to the app container by
the gallery autorun.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: adopt toasts across app flows (copy, feedback sent, reconnected)
One copy-confirmation vocabulary via Toast.copied(): chat bubble copy
(threaded through ChatRowActions.notifyCopied to respect the snapshot
boundary), chat detail Copy All, artifact copy contents/path, terminal
text-sheet Copy All (replacing its button morph), and artifact gallery
Copy path (closure-threaded through lazy rows). Feedback submit success
now toasts after the composer dismisses; regaining a Mac connection
toasts Reconnected. Ad-hoc UINotificationFeedbackGenerator calls at
these sites removed: the toast supplies the haptic.
Co-Authored-By: Claude Fable 5 <[email protected]>
* iOS: DEBUG remote toast trigger for agents and scripts
ToastDebugTrigger (DEBUG builds only) listens for darwin notifications
and presents a toast from a JSON spec in the app's defaults, so a shell
can exercise the toast layer inside the running app without touch input:
simctl spawn defaults write + notifyutil -p. ios/scripts/toast-debug.sh
wraps both (style/title/message/icon/placement/persistent/action, plus
--dismiss-all). Registered by the toast window host, torn down with it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Toast chrome: material plate under the glass for legibility
Bare Liquid Glass (.regular, untinted) is nearly transparent over busy
content; over a terminal screen the toast text disappeared entirely. All
cards now sit on a .regularMaterial plate (vibrancy-aware in both modes);
on iOS 26 the glass layer rides on top purely for its rim and specular
response. Verified over the dark workspace UI and in light mode via the
debug trigger.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Settings: self-serve toast demo with delay (Developer section)
Extracts the demo choreography into ToastDemo (DEBUG-only, shared by the
gallery autorun, the remote trigger's new --demo verb, and Settings).
Developer section gains Toast Gallery (sheet), Run Toast Demo, and a
0-30s delay stepper (persisted via AppStorage) so you can start the demo,
navigate to any screen, and watch it play there. Strings localized en+ja.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Gate the toast system behind a Beta Features flag (off by default)
ToastCenter.isEnabled (persisted, default false) drops every present()
while off and is surfaced as Settings > Beta Features > Toasts. Off-state
falls back to the pre-toast surfaces, restored from main: the dismissible
bottom workspace-action banner (title+reason joined), ChatScreen's inline
error banner with its VoiceOver announcement, the text sheet's Copied
button morph, and the success haptics at copy/feedback sites. The
DEBUG gallery env force-enables the flag so harness runs stay meaningful.
Verified live on the sim: trigger drops with flag off, presents with
flag on; 16 ToastCenter tests incl. the disabled gate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review findings: initial-delivery bridges, immutable Toast, scoped holds
CodeRabbit majors: onChange(initial: true) so a pre-existing chat error
and an already-connected mount are handled; Toast fields are lets; the
default coalescing key joins on a unit separator instead of an aliasable
delimiter; interaction holds are scoped to the presented toast id (a
straggling gesture from a departed toast can no longer pause or resume
its successor); DocC on the four factories; gallery autorun propagates
cancellation. Skipped Greptile's caseless-enum-namespace nit: that is
the repo's established idiom.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Mobile state sync v2: per-record deltas replace invalidate-and-refetch
Adds a versioned delta protocol for the iOS workspace list. The Mac keeps
an epoch + per-collection revision store of typed workspace/group records,
answers mobile.sync.fetch with a snapshot or the exact missing span, and
pushes mobile.sync.delta events carrying only the rows a change touched.
The phone mirrors records with a cursor, projects them through the same
applyRemoteWorkspaceList path the legacy full list uses, and stops
re-fetching the entire list on every workspace.updated push. Legacy phones
and Macs keep today's behavior via method_not_found negotiation.
Design doc: docs/mobile-state-sync-v2.md
Co-Authored-By: Claude Fable 5 <[email protected]>
* Merge main (iOS reconnect/build isolation fix) and guard stale sync fetches
Merges origin/main to pull in the dev-build flakiness fix from
https://github.com/manaflow-ai/cmux/pull/8299. The negotiation fetch now
checks client currency and cancellation before sending, so a fetch task
from a replaced listener generation can never redial its stale client's
route underneath the replacement connection (caught by
manualReconnectRedialsWhenLiveStreamIsUnavailableButRPCStateIsConnected
after the merge).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use explicit self in sidebar row tint closure for newer toolchains
main's SidebarWorkspaceRowSlotViews.swift fails to compile on the
Blacksmith macos-26 runners (escaping-closure capture of 'color' needs
explicit self there). Out of this PR's feature scope, but required to
produce any dev build of a branch containing current main on that
toolchain; behavior unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Adopt main's sidebar tint-closure fix verbatim
Main fixed the Blacksmith toolchain compile with a [color] capture; drop
this branch's interim explicit-self variant so the file matches main.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Prove mobile.sync.delta rides the independent Iroh server-events lane
Adds a session-level test: a typed state-sync delta pushed through the
independent event stream (the lane an admitted Iroh connection negotiates
via iroh_server_events_v1) reaches a mobile.sync.delta topic listener and
decodes to the typed frame. Locks in that sync v2 is lane-agnostic on the
Iroh transport.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Regression test: hung redial must settle and unfreeze recovery
A transport dial that parks forever (wedged Iroh dial, issue 8531) holds
the recovery owner's in-flight claim indefinitely: no failure settles, no
backoff retry is scheduled, and every other trigger defers forever. Fails
without the fix.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bound reconnect attempts with a hard deadline; manual retry clears backoff
Every automatic stored-Mac redial now races a per-attempt deadline
(runtime-injectable, default 30s). At expiry the attempt is abandoned
(generation guards make late completion harmless), settled as timedOut,
and transient backoff schedules the next automatic try, so a hung Iroh
dial can no longer freeze the recovery machine into a permanent
"Disconnected - Tap Reconnect" dead end. The user's explicit
reconnect/pull gesture now clears transient backoff the same way
recoverMobileConnection(.manual) does, so a recorded cooldown can never
swallow a manual tap.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review findings: sync integrity guards + fetch handle race
- Store payloads no longer emit tombstones for ids that are live again
(remove-then-readd inside a cursor span would have deleted the re-added
record on the client); mirror applies removals before upserts as
belt-and-braces.
- Same-epoch snapshots older than the mirror cursor are ignored (a stale
in-flight fetch response can no longer roll the mirror back).
- An undecodable delta for a known collection now schedules a cursor
repair fetch instead of leaving the mirror silently stale.
- While v2 owns the list, the legacy full-list reload path keeps its
liveness-probe role but re-bases the mirror through a cursor fetch
instead of overwriting projected state.
- The single-flight fetch handle is generation-guarded so a cancelled
predecessor's deferred cleanup cannot erase its replacement's handle.
- Qualify the payload-reduction claim in the design doc.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 1: unstructured deadline race, repair fallback, tombstone rev bound
- raceAgainstDeadline no longer uses a task group (which structurally
awaits a cancellation-ignoring dial); the operation runs unstructured
and a lock-guarded once resumes whichever side finishes first. Direct
tests cover an operation that never completes and ignores cancellation.
- A transiently failed gap-repair fetch now drops back to legacy list
semantics (stateSyncActive off + one authoritative reload) instead of
stranding the mirror behind a suppressed refetch loop; v2 re-negotiates
on the next listener generation.
- Tombstone pruning tracks the highest discarded revision; coverability is
judged against that bound so a same-revision batch split by the ring cap
can never produce a delta that omits a removal.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 2: bounded abandoned dials + fallback reload retries
- Deadline races hand back the abandoned operation task; the composite
counts unresolved abandoned dials, pauses automatic retries above a small
ceiling, and re-arms the retry loop when an abandoned dial finally
resolves while still disconnected. A persistently wedged transport can
no longer accumulate unbounded retained reconnect tasks.
- The legacy fallback reload after a failed gap-repair fetch retries up to
three times with short pauses instead of fire-and-forget, and reports
exhaustion; connection-death cases remain owned by the recovery paths.
Rejected (named boundary): moving MobileStateSyncHost off `shared` — the
Mac mobile plane is currently rooted in TerminalController.shared /
MobileHostService.shared at every call site, the host's epoch is
process-lifetime by design, and the testable sync logic lives in the
injected CMUXMobileCore classes; the injectable-owner move rides the
de-singletonizing follow-up rather than this PR.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 3: subscription-ack sequencing + refresh semantics
- v2 negotiation now starts from the mobile.events.subscribe
ACKNOWLEDGEMENT instead of racing the handshake, so a fetch snapshot can
never miss a change emitted before the Mac registered this connection.
- The watchdog's lost-registration recovery repairs the v2 cursor (missed
events include missed deltas) instead of no-opping under v2.
- The pull-to-refresh/Computers refresh path awaits the v2 cursor fetch
and returns its outcome, so the spinner ends with authoritative state
and a failed fetch is not reported as success.
- Fetch failure handling is gated by owning generation + cancellation, so
a cancelled predecessor surfacing as a timeout can no longer disable v2
underneath its successful replacement.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 4: register abandoned dials pre-guard, follow superseded fetches
- The abandoned-dial handle is registered immediately after the race
returns, before cancellation/supersession guards can drop it, so wedged
dials from replaced attempts stay inside the accounting ceiling.
- performStateSyncFetch follows cancel-and-replace supersessions (bounded)
so a user refresh reports the authoritative replacement's outcome instead
of a superseded cancel.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 5: partial-response guard, account-boundary reset, no legacy list under v2
- A fetch response missing a requested collection can no longer activate
v2 or project an empty mirror over a valid list; it is treated as a
fetch failure.
- signOut tears down state sync: mirror wiped (previous account's titles,
directories, previews), v2 deactivated, in-flight fetch invalidated by
generation so late completions cannot write into the next session.
- While v2 is active the legacy full-list request is never built or sent;
the cursor fetch is both the liveness probe (caller timeout honored) and
the authoritative refresh. Also widens a load-flaky poll window in the
hung-redial test.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 6 + fix janitor re-block; harden deadline test
- Ordinary workspace.updated events are suppressed under v2 (each pairs
with a delta); only the watchdog's lost-registration branch repairs,
through the dedicated repairMissedEventWindow (cursor fetch under v2,
full refetch under legacy). Removes the per-event fetch RPC and the
cancel-storm that could starve a genuine gap repair.
- Abandoned-dial janitors no longer record transient backoff on
resolution: that write could land mid-manual-retry and re-block the dial
the user just requested. They now kick the coalesced recovery entry
directly, only when no attempt or scheduled retry is active. (Found via
deterministic full-suite reproduction of the hung-redial test.)
- The hung-redial test releases parked dials when lifting the hang
(eternal hangs were a test artifact racing auto-retry state) and uses
starvation-proof poll windows. Full shell suite green 3x consecutively;
the suite's residual flakiness on loaded machines reproduces on
origin/main (6 issues/run) and predates this branch.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 7: scope state-sync authority to the owning client
Replaces the bare stateSyncActive flag with stateSyncAuthorityClientID:
v2 is active only while the client that earned authority (successful
mobile.sync.fetch) IS the current remoteClient. This fixes the class the
three findings shared: client promotion/replacement implicitly demotes to
legacy (no suppressed-invalidation window on the new Mac), deltas are
ignored outside the authoritative window (no legacy/v2 concurrent writers
after a fallback), and a superseded fetch waiter consults the last settled
generation's outcome (a liveness caller can no longer read a replacement's
success as failure and tear down a healthy session). Regression test:
replacingTheForegroundClientDemotesStateSyncAuthority.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 8: single-flight fetch runner with trailing sweep
Replaces the fetch slot's cancel-and-replace semantics with the codebase's
proven single-flight + follow-up pattern (same shape as
scheduleSecondaryRefresh): same-client demand coalesces onto the in-flight
runner and requests one trailing sweep; only a different client's demand
replaces the runner. This resolves the round-8 class at its root:
- gap repairs can no longer be starved by sustained 80ms churn (deltas
coalesce instead of cancelling the repair they need);
- negotiation-window deltas request a trailing sweep instead of being
dropped (a change postdating the fetch snapshot is swept, not lost);
- no concurrent fetch generations exist, so a superseded task can neither
misreport nor overwrite a replacement's success (the settled-outcome
bookkeeping is deleted, not patched).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address autoreview round 9: bound every reconnect caller; per-waiter fetch deadlines
- The per-attempt deadline and abandoned-dial accounting move inside
reconnectActiveMacOutcome itself, so startup restore, team-scope
restore, and the manual workspace-list fallback are bounded identically
to the recovery owner (whose special-case deadline branch is deleted).
The raw dial is reconnectActiveMacOutcomeUnbounded.
- performStateSyncFetch bounds each WAITER by its own timeout when one is
provided (a 3s liveness probe joining a slow fetch reports within its
contract); the shared runner is never cancelled by a waiter's deadline
and keeps converging in the background.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Revert blanket reconnect deadline wrapper; keep waiter bounds + cancel forwarding
The round-9 blanket wrapper detached every reconnect's synchronous prefix,
breaking reconnect serialization semantics (registry-snapshot reuse and
initial-connect tests). The per-attempt deadline returns to the recovery
owner path (round-8 shape, proven 3x-green); bounding the remaining
lifecycle callers (startup restore, team restore, manual fallback) is a
consciously deferred follow-up needing per-call-site deadline policy.
Kept from round 9: per-waiter timeout bounds on coalesced state-sync
fetches, and explicit caller-cancellation forwarding in raceAgainstDeadline.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Policy pass: DocC all public sync symbols, injectable frame coder, clock deadline
- Every public symbol in the state-sync package surface carries DocC.
- MobileSyncFrameJSON (caseless namespace enum) becomes the injectable
MobileSyncFrameCoder instance type; error renamed accordingly.
- The deadline race's timer uses ContinuousClock (intentional bounded
deadline, cancellation-wired).
Remaining P2 policy findings are rejected with named boundaries recorded
on the PR: the protocol files each own a closed set of tightly coupled
wire DTOs; DeadlineRaceOutcome/RaceContinuationOnce are private helpers
co-located with their sole consumer; the once-guard must be callable from
the synchronous onCancel handler, which an actor cannot be; the static
race helper lives on a heavily stateful owning type, not a namespace.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address final review finding: fallback reload unconditional on transient failure
Renegotiation clears authority before the fetch, so the fallback's guard
on current authority skipped exactly the recovery it exists for: a
transient negotiation-fetch failure after a same-client resubscribe left
events missed in the subscription gap unrecovered. The bounded legacy
reload now runs on every transient fetch failure regardless of authority
(currency-guarded per iteration). Adds trace lines on the fallback path
and a regression test (transientNegotiationFailureStillRunsTheLegacyReload).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Re-check v2 authority after the legacy list await
Negotiation can grant v2 while a legacy full-list request is in flight;
applying the captured response then would overwrite newer mirror state.
The legacy path now re-checks authority after the await and reports
liveness success without applying when v2 took ownership.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Tell coding agents workspace todos are user-owned
Colleagues' coding agents have been populating workspace checklists and
pinning statuses unprompted: the todo-controls release flag enabled the
feature org-wide, and `cmux todo --help` described the checklist as
'writable by you and by agents', which agents exploring the CLI read as
an invitation to mirror their plans into it.
State the ownership policy in the surfaces agents actually read: the
`cmux todo` and `cmux workspace status` help now tell agents not to
touch items or status pins unless the user explicitly asks (statuses
already track agent activity via inference), and the CLI contract doc
records the same policy. en/ja catalog entries updated to match.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Clarify the agent policy covers both checklist and status pins
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Attach workspace rename and color alerts to their window
The Rename Workspace alert (sidebar context menu, SwiftUI tab row, and
the keyboard-shortcut path), the custom workspace color prompt, and the
Rename Tab alert all presented via bare NSAlert.runModal(), which shows
a detached alert window that macOS places on whichever screen it deems
main - on multi-monitor setups that is often not the screen showing
cmux. Present them through runCmuxModal(presentingWindow:) instead,
resolving the host via mainWindowContainingWorkspace so each alert
attaches as a sheet to the window that owns the workspace.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Focus the rename/color input when the alert presents as a sheet
Match the sidebar group-rename prompt's focus dance: initialFirstResponder
alone was reliable under app-modal runModal, but sheet presentation keys
the sheet on the host window's schedule, so also make the field first
responder and select its text on the next main-loop turn.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Set alert input focus via willPresent instead of async main hop
CodeRabbit and the cmux pre-merge checks flagged the
DispatchQueue.main.async focus dance as a banned timing-repair pattern.
runCmuxModal already exposes a synchronous willPresent hook that fires
just before the modal session begins, so set makeFirstResponder and
selectText there in all six touched alert sites (including the four
pre-existing async hops in the functions this PR already changes).
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: floating task composer creates workspaces with agent templates
A bottom-right floating plus button on the workspace list opens a New Task
composer: type a prompt, pick a task template (Claude/Codex/Shell seeded,
user-editable with SF Symbol or emoji icons), pick the target Mac, and set
the working directory. Submit creates a workspace on that Mac via the
existing workspace.create RPC, now sending title, working_directory,
initial_command, and initial_env from iOS.
Prompt injection is a pure composer: {prompt} placeholder or appended
shell-quoted argument, plus CMUX_TASK_PROMPT in the environment for
multi-line scripts. Templates persist device-locally in a UserDefaults
JSON store behind MobileTaskTemplateStoring. The Mac now expands a
leading ~ in working_directory.
* Fix package-conventions-lint: replace free function taskTemplateIcon with TaskTemplateIcon view
* Fix Swift file length budget and policy findings: split god-file route selection, test-support builders, and task composer types into own files
- Move route-selection statics out of MobileShellComposite.swift into
MobileShellComposite+RouteSelection.swift and ratchet the budget entry
down to 7669 (the +9 task-composer lines are stored properties that
cannot live in an extension).
- Split ComposerSubmitRoutingTestSupport.swift (509 > 500 threshold)
into support fixtures + ComposerSubmitRoutingStoreBuilders.swift.
- One major type per file: MobileWorkspaceCreateSpec,
MobileTaskTemplateStoring, MobileTaskComposition, TaskTemplateFormView,
TaskTemplateIcon each get their own file.
* Address review findings: submit lifecycle, emoji seeding, API surface
- Guard submit() against re-entry and hold the submit Task in state;
Cancel and drag-to-dismiss now cancel it, and a cancelled submit drops
its result instead of persisting last-used defaults (Codex P2 +
Greptile P1).
- Seed the custom-emoji field from an existing emoji selection so
reopening the icon picker shows the current icon (CodeRabbit).
- Narrow shellQuoted to internal; tests use @testable (CodeRabbit).
- Drop inert @Observable from UserDefaultsMobileTaskTemplateStore: it
has no tracked stored state, views re-read after mutations (CodeRabbit).
* Localize seeded task template names
Seed names are user-facing and persisted; a Japanese fresh install kept
the English 'Shell' label. MobileTaskTemplate.seedDefaults now takes the
display names, the UserDefaults store passes L10n-resolved values, and
the catalog gains mobile.taskComposer.template.seed.* (en/ja).
* Task composer: block dismissal during submit; keep directory in sync with template changes
- A sent workspace.create cannot be recalled, so Cancel is disabled and
interactive dismissal is blocked while a submit is in flight; the
bounded RPC always reports success/failure in the sheet instead of a
cancelled sheet silently creating a remote workspace (Codex P1).
- Directory field now recomputes through one syncSuggestedDirectory()
path when templates are added, deleted, or edited, not just on chip
taps, so Create cannot send a deleted/previous template's path
(Cursor Bugbot). Chip taps and Mac switches share the same helper.
* Task composer: don't preselect a forgotten Mac
Restored lastMacDeviceID is now validated against displayPairedMacs
before use, mirroring the template-id validation, so a forgotten Mac
cannot become the default target (Cursor Bugbot).
* Task composer: brand agent icons, OpenCode seed, tighter FAB padding
Dogfood feedback: the floating button sat too high (bottom padding 20 -> 6)
and the seeded templates used generic SF Symbols. Claude/Codex/OpenCode now
render their brand images (AgentIcons imagesets copied from the macOS
catalog into CmuxMobileShellUI resources, agent: icon values), OpenCode is
seeded as a fourth default, and the icon picker offers the brand icons ahead
of the symbol grid. Template storage keys bump to v2 (feature is unshipped;
dogfood-only v1 data is dropped and cleaned up).
* Flatten agent icon asset names to catalog root
Namespaced (AgentIcons/...) image lookups fail at runtime inside the
SwiftPM resource bundle: CoreUI reports 'No image named ... found in
asset catalog' even though assetutil shows the namespaced entries in the
compiled Assets.car. Root-level names resolve.
* Load agent brand icons from loose package PNGs, not the asset catalog
Dev reloads pass PRODUCT_BUNDLE_IDENTIFIER as a global xcodebuild
override, which stamps every SwiftPM resource bundle with the app's own
identifier. CoreUI registers asset catalogs per bundle identifier, so
the package catalog loses to the app catalog and Image(named:) fails
for every entry (namespaced or flat). Ship the PNGs as .copy resources
and load @3x files by explicit URL with a dark-variant pick for Codex.
* Task composer: failing test — stale rejected create reports success
A workspace.create that throws after the connection generation changed
mid-flight is mapped to .success, so the composer dismisses and persists
last-used defaults for a task that was never created (Cursor bot finding
on PR 7670). Red commit; fix follows.
* Task composer: report stale create failures instead of success
A workspace.create that throws after the connection generation changed
mid-flight was mapped to .success, so the composer dismissed and
persisted last-used defaults for a task that was never created. Only a
cancelled request (whose result the sheet drops) still maps to success;
a stale failure now returns its mapped failure while skipping the
connection-state side effects that belong to the new connection.
* Task composer: re-dial when the selected Mac's connection dropped
submitTaskComposer skipped switchToMac whenever the selected Mac id
matched foregroundMacDeviceID, but a dropped connection leaves that id
in place with remoteClient nil, so submit failed as not-connected
without attempting a reconnect. Also switch when remoteClient is nil;
switchToMac already short-circuits when the foreground connection is
genuinely live.
* Polish task composer lifecycle and accessibility
* Preserve task composer directory on Mac fallback
* Harden task composer submission boundaries
* Set empty task prompt environment explicitly
* test: cover task composer retry idempotency
* test: reject stale task composer draft writes
* fix: make task creation retries idempotent
* chore: keep terminal controller line neutral
* test: cover task composer compatibility gates
* fix: harden task composer submission compatibility
* test: cover task composer data boundaries
* fix: isolate task composer execution and state
* test: cover task directory validation boundaries
* fix: validate mobile task directories off main actor
* test: cover bounded task directory validation
* fix: serialize mobile task directory validation
* test: cover interrupted composer edge cases
* fix: harden task composer retries and validation
* test: cover composer routing and navigation races
* fix: pin task creation and compact navigation
* test: cover immutable task submission policies
* fix: harden task composer settlement
* test: cover task prompt composition edges
* fix: preserve explicit task prompt inputs
* test: clarify controlled deadline suspension
* docs: document task submission snapshot
* test: stress concurrent mount snapshots
* fix: isolate concurrent mount snapshots
* fix: narrow task composer import visibility
* test: cover session-scoped draft clearing
* fix: scope task draft clearing to session
* test: cover whitespace-only task commands
* fix: treat blank task commands as plain shell
* test: reject working directory alias bypasses
* fix: reject working directory alias bypasses
* test: cover prompt append before comments
* test: cover request-equivalent task edits
* test: preserve hash token append semantics
* test: assert request identity rotation policy
* test: cover prompt append before shell separators
* test: cover mobile cwd validation alias
* test: reconcile restored task tombstones
* fix: append task prompts before trailing shell syntax
* fix: preserve identity for equivalent task requests
* fix: validate mobile cwd aliases
* fix: reconcile restored task tombstones
* test: preserve trailing task command trivia
* test: normalize task commands for request identity
* fix: compare normalized task create requests
* refactor: keep task composer within file budgets
* test: cover comment-only task templates
* test: cover shell programs without commands
* test: classify shell programs without commands
* fix: classify task templates without commands
* refactor: isolate task and restore actions
* feat: describe task composer controls for VoiceOver
* test: decline prompt injection for complex shell scripts
* fix: decline prompt injection for complex shell scripts
* fix: inject session restore cache explicitly
* refactor: isolate shell grammar composer tests
* test: preserve manual directory across template changes
* fix: preserve manual directory across template changes
* test: cover task composer review regressions
* fix: address task composer review feedback
* test: cover task composer accessibility targets
* fix: expose task composer actions to VoiceOver
* docs: document task composer accessibility preview
* test: cover task composer review regressions
* fix: close task composer review findings
* test: cover final task composer review findings
* fix: close final task composer review findings
* test: cover final session and shell expansion races
* fix: guard composer settlement by session
* test(ios): model signed-in composer preview
* test: cover task composer review regressions
* fix: address task composer review findings
* test: cover cancelled create rejection race
* fix: preserve definite create rejections
* test: cover settled rejection and heredoc prompts
* fix: preserve rpc settlement and heredoc literals
* Split task composer parsing helpers
* Test nested shell contexts in task templates
* Track nested task template shell contexts
* Test verbatim task template command contract
* Preserve task template commands verbatim
* Test verbatim task command transport
* Preserve task commands through host startup
* Test authoritative restored task tombstones
* Honor task tombstones across restarts
* Test task creation transaction boundaries
* Make task creation transaction atomic
* Test composer cancellation during Mac switch
* Cancel owned Mac switch with composer task
* Test cancellation and tombstone persistence failures
* Make task submission settlement crash-safe
* Test task composer directory and presentation UX
* Stabilize task composer directory selection
* Test mobile task directory discovery RPC
* Test stale task directory search capability metadata
* Discover task directories from connected Macs
* Scope task directory candidate assembly
* Split task composer files for source budgets
* Add regressions for interrupted task recovery
* Fix task recovery and directory search cancellation
* Add regressions for composer review findings
* Fix composer review edge cases
* Fix iOS test Foundation import
* Add regressions for final composer review
* Fix final task composer review findings
* Untrack generated iOS workspace Package.resolved
Mirrors https://github.com/manaflow-ai/cmux/pull/8213 so dispatched CI on
this branch passes workflow-guard-tests; the delta disappears from this
PR's diff once that fix lands on main.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Replace hidden-window resize sleeps with deterministic tmux size waits
check-test-determinism flags the sleep-then-assert at the hidden-churn
site in RemoteTmuxSizingUITests+Content.swift, failing
workflow-guard-tests on every dispatched CI run. Both fixed-interval
sleeps between tmux resize-window calls now poll tmux's own reported
window size (deadline-bounded) before the second resize, matching the
existing waitWindowSizeStable idiom.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Restore tracked iOS workspace lockfile
* Fix instance-tag forwarding after main merge
* Polish iOS task composer launch flow
* Fix task composer accessibility targets
* Test task composer launch readiness
* Require prompts before launching agents
* Test bundled task agent icons
* Load bundled task agent icons
* Test immediate paired Mac visibility
* Refresh paired Macs after attach
* Fix iOS reconnect and build isolation (#8299)
* test(ios): cover reconnect overlap cleanup
* fix(ios): retire superseded reconnect sessions
* test(ios): isolate saved dev Mac instances
* fix(ios): enforce build compatibility boundaries
* test(ios): cover startup status auth race
* fix(ios): reuse connect token for identity check
* test(auth): preserve selected team during refresh outage
* fix(auth): keep selected team effective during startup
* test(auth): keep cached sessions restoring until ready
* fix(ios): wait for auth restore before reconnect
* test(ios): cover compatibility review regressions
* fix(ios): address compatibility review findings
* test(ios): use deterministic compatibility timestamps
---------
Co-authored-by: cmux reload-cloud <[email protected]>
* Test seeded directory search fast path
* Prioritize seeded directories in search
* Test cold directory search responsiveness
* Prioritize exact cold directory searches
* Test exact project root against seeded descendant
* Prioritize exact project roots over seeded descendants
* Test exact directory basename ordering
* Rank exact directory names ahead of context
* Test glanceable directory result labels
* Make directory results glanceable
* Let folder results breathe at large text sizes
* Test batched task-template deletion
* Close task composer review findings
* Test accessibility task composer action sizing
* Keep task composer usable at large text sizes
* Add Shell icon lab and task composer proofs
* Keep UX experiments in debug-only CMUX Labs
* test(ios): require created workspace identity
* feat(ios): complete prompt-to-agent launch route
* test(ios): define task retry phase policy
* fix(ios): make task submission retries explicit
* Add failing directory pagination recovery tests
* Fix task composer directory pagination recovery
* test(mobile): cover filesystem job quota
* fix(mobile): cap concurrent directory jobs
* Localize task composer recovery copy
* test(ios): cover task retry recovery
* Add failing pagination recovery UI coverage
* Clarify pagination recovery failures
* Localize task composer debug state
* Expand directory picker action targets
* Harden pagination recovery UI proof
* test(ios): expose manual pairing to composer
* test(ios): cover connected task creation
* fix(ios): refresh Macs after manual pairing
* fix(ios): finish composer debug compilation
* test(ios): make connected composer proof deterministic
* test(ios): align mock host with tagged build isolation
* test(ios): prove labs stay out of release
* fix(ios): keep labs experiments out of release
* test(ios): cover task composer autofocus
* test(ios): require visible task composer keyboard
* fix(ios): own task composer initial focus at sheet lifecycle
* Refine iOS task composer hierarchy
* test(ios): cover directory browser scroll responsiveness
* fix(ios): stabilize task directory browsing
* Gate iOS task composer behind beta setting
---------
Co-authored-by: Aziz Albahar <[email protected]>
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Abdulaziz Albahar <[email protected]>
* Round 3 colleague dogfood fixes: PR status truncation, nesting indent, multi-select flush, reorder moves, override-release notes, selected-row status color
- PR rows rendered 'PR #4 o…' with ample space: intrinsicContentSize on
a truncating single-line NSTextField caps at the CURRENT frame width,
so a pooled label laid out narrow once reports the truncated width
forever. New sidebarNaturalCellSize measures through the cell
unconstrained; applied to PR status/title, remote status, group badge
pill, and progress label.
- Nesting was invisible: legacy applies the group-member indent outside
the row so the selection/hover background shifts with the content; the
AppKit cell now indents its background the same way.
- 'Click A, cmd-click B' extended the pre-A selection: the modifier
branch dropped a plain click still inside the coalescing window.
SidebarSelectionCoalescer.flushNow() applies it first (tests added).
- Drag-reorder janked: a pure reorder went through reloadData, tearing
down every visible cell and snapping scroll. Same-id-set order changes
now apply as in-place moveRow updates.
- Row clipping class: releasing pumpHeightOverrides never re-noted the
rows, leaving the table on the override height while the cache served
the measured one; both clear sites now note released rows. Height-
drift DEBUG probe added (rect(ofRow:) minus intercellSpacing).
- Agent-status line was blue-on-blue when selected: explicit entry
colors now yield to the selected foreground (legacy parity).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Reconcile stranded selection previews; dim cmd-click preview; regenerate webviews bundle
Two interaction bugs from Aziz's videos:
- Reorder/selection interference: optimistic previews are only reconciled
by an authoritative apply, and some presses never produce one (a drag
dropped where it started, a press swallowed by the drag threshold, an
unchanged selection). The stranded peel left the sidebar with NO
visible selection. previewSelection now arms a 400ms clock-injected
bailout that restores stored-model paint unless an apply lands first;
apply() and drag-begin cancel it.
- cmd-click flash: the modifier preview painted the full bright active
treatment and settled to the dim multi-select tint. It now previews
the dim tint directly (showOptimisticMultiSelection).
Heal main: https://github.com/manaflow-ai/cmux/pull/8393 changed
webviews/ sources without regenerating the checked-in bundle, failing
react-apps-check on every gate. Regenerated with
./scripts/build-webviews-app.sh.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: bound the reorder move planner, multiset id equality
CodeRabbit: the move loop's firstIndex/remove/insert rescans trend
quadratic on bulk permutations; a positional-mismatch threshold (32)
keeps user drags on the animated move path (one contiguous span, O(n))
and routes bulk permutations to reloadData, which they gained nothing
from animating anyway.
Greptile: Set equality collapsed duplicate ids, so corrupt state with
two rows sharing an id could misclassify as a pure reorder; replaced
with counting multiset equality.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Reconcile optimistic paint on every apply; read modifiers from the clicking event
Found while adversarially driving the sidebar for the verification
video: synthetic cmd-clicks carry the command flag on the EVENT while
the hardware state stays plain, and the resulting preview/authoritative
divergence exposed a class hole — apply() cancels the preview bailout
on arrival but only reconfigures rows whose model changed, so a preview
painted on a row whose authoritative state ends up unchanged kept its
speculative paint forever (dim multi-select tints lingering through any
number of plain clicks). apply() now force-reconciles every
optimistically painted row id.
Also didClickTableRow read the global NSEvent.modifierFlags (hardware
state) instead of the clicking event's flags; NSApp.currentEvent is the
action's triggering event, matching what previewSelection already saw.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Down-then-up selection highlight; reflow + drop-resolution probes
Owner ruling: the row highlight applies on down-then-up, not on press.
The optimistic paint moves from mouseDown to the table action (which
fires on mouse-up), so a press that becomes a drag or a cancelled click
never shows a speculative highlight — removing the last source of
preview/authoritative divergence at the gesture level.
DEBUG probes for the two open dogfood reports: sidebar.viewport +
sidebar.liveReflow trace every width tick through the live re-measure
(continuous-reflow regression), and sidebar.drop.perform records where
each drop landed, target count, and the planner verdict (silent
reorder no-ops).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Respect the inline-rename field editor in tab-selection focus reassert
Double-click rename died on every attempt: click 1's async workspace
selection completes ~240ms later and converges AppKit focus through
moveFocus, which gates only on the right-sidebar coordinator and then
calls makeFirstResponder directly — stealing the field editor the
rename took at doubleAction time (probes: beginInlineRename tookFocus=1
followed by ensureFocus intent=terminal from ws.select.asyncDone, typed
text landing in the terminal). moveFocus gains an opt-in
respectForeignFirstResponder that applies the same policy check
ensureFocus and applyFirstResponderIfNeeded already use; the
tab-selection reassert opts in. Explicit focus-the-terminal callers
(find escape, tmux mirror) keep stealing deliberately.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Heal main Sendable warning; make reorder helpers private
Sources/Mobile/MobileHostIrohApplicationLaneRouter.swift landed on main
with 'Date.init' defaulting a @Sendable () -> Date parameter — the
initializer reference resolves as a non-Sendable function value and
trips the Swift 6 data-race warning, failing the zero-bucket warning
budget on every gate. A closure literal is Sendable.
Also Greptile P2 on https://github.com/manaflow-ai/cmux/pull/8461:
multisetEqual/maxAnimatedReorderMoves have no external callers, now
private.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* test: cover sidebar agent status presentation
* fix: share sidebar status text presentation
* chore: clarify sidebar presentation API access
* test: import sidebar status model
* cmux-tui: dim dead bands and draw the live-area boundary for foreign-sized surfaces
When a surface's effective grid is smaller than the pane, the TUI now
clips content to the live area, dims the unusable right/bottom bands
(theme-aware grey + DIM), draws the boundary with box-drawing glyphs
on the first dead column/row, and places the sized-by hint at the
boundary corner. Hint copy is locale-selected (EN/JA) via the same
env-based mechanism as the pairing overlay, resolved once. Matches the
cmux-lite client treatment.
* cmux-tui: remove the CmuxLite Swift frontend (moved to manaflow-ai/cmux-lite)
The demo app now lives in the private manaflow-ai/cmux-lite repo (its
pre-move history stays reachable here). Adds a bilingual
frontends/README pointer and drops the now-dead swift-frontend
Xcode-lockfile exception from check-package-resolved-policy.py.
* cmux-tui: close foreign viewport review gaps
* fix(tui): reset dead cells before restyling
* test(tui): fit Japanese viewport hint in side band
* test(tui): assert Japanese viewport glyph cells
* test(tui): reject misleading sizing takeover hint
* fix(tui): keep foreign size hint factual
* test(tui): require neutral allocation-free viewport hints
* fix(tui): make viewport hint neutral and allocation-free
* test(tui): follow injected viewport catalog copy
* test(tui): reject mouse input in foreign viewport
* fix(tui): bound mouse input to rendered viewport
* test(tui): clamp selection to rendered viewport
* fix(tui): constrain selection to rendered viewport
* test(tui): reject input without rendered viewport
* fix(tui): fail closed without rendered viewport bounds
* test(tui): preserve pane actions in viewport padding
* fix(tui): preserve pane actions in viewport padding
react-apps-check fails because #7804 committed a bundled chunk one line
out of sync with its webviews sources (reproduces on plain main).
Regenerated with ./scripts/build-webviews-app.sh as the check instructs.
Co-authored-by: austinpower1258 <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Round 3 colleague dogfood fixes: PR status truncation, nesting indent, multi-select flush, reorder moves, override-release notes, selected-row status color
- PR rows rendered 'PR #4 o…' with ample space: intrinsicContentSize on
a truncating single-line NSTextField caps at the CURRENT frame width,
so a pooled label laid out narrow once reports the truncated width
forever. New sidebarNaturalCellSize measures through the cell
unconstrained; applied to PR status/title, remote status, group badge
pill, and progress label.
- Nesting was invisible: legacy applies the group-member indent outside
the row so the selection/hover background shifts with the content; the
AppKit cell now indents its background the same way.
- 'Click A, cmd-click B' extended the pre-A selection: the modifier
branch dropped a plain click still inside the coalescing window.
SidebarSelectionCoalescer.flushNow() applies it first (tests added).
- Drag-reorder janked: a pure reorder went through reloadData, tearing
down every visible cell and snapping scroll. Same-id-set order changes
now apply as in-place moveRow updates.
- Row clipping class: releasing pumpHeightOverrides never re-noted the
rows, leaving the table on the override height while the cache served
the measured one; both clear sites now note released rows. Height-
drift DEBUG probe added (rect(ofRow:) minus intercellSpacing).
- Agent-status line was blue-on-blue when selected: explicit entry
colors now yield to the selected foreground (legacy parity).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Reconcile stranded selection previews; dim cmd-click preview; regenerate webviews bundle
Two interaction bugs from Aziz's videos:
- Reorder/selection interference: optimistic previews are only reconciled
by an authoritative apply, and some presses never produce one (a drag
dropped where it started, a press swallowed by the drag threshold, an
unchanged selection). The stranded peel left the sidebar with NO
visible selection. previewSelection now arms a 400ms clock-injected
bailout that restores stored-model paint unless an apply lands first;
apply() and drag-begin cancel it.
- cmd-click flash: the modifier preview painted the full bright active
treatment and settled to the dim multi-select tint. It now previews
the dim tint directly (showOptimisticMultiSelection).
Heal main: https://github.com/manaflow-ai/cmux/pull/8393 changed
webviews/ sources without regenerating the checked-in bundle, failing
react-apps-check on every gate. Regenerated with
./scripts/build-webviews-app.sh.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: bound the reorder move planner, multiset id equality
CodeRabbit: the move loop's firstIndex/remove/insert rescans trend
quadratic on bulk permutations; a positional-mismatch threshold (32)
keeps user drags on the animated move path (one contiguous span, O(n))
and routes bulk permutations to reloadData, which they gained nothing
from animating anyway.
Greptile: Set equality collapsed duplicate ids, so corrupt state with
two rows sharing an id could misclassify as a pure reorder; replaced
with counting multiset equality.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Lawrence Sidebar round 2, batch A: popover refresh, one-pass diff, explicit resize signal, metadata/markdown toggles
- Checklist popover refreshes while open: the row's configure pass now
forwards fresh models into an open popover (update rebuilds content
and resizes) instead of showing creation-time items until reopen.
- One equivalence pass per table apply: the height cache reuses the
controller's reconfigure diff (skippingEquivalenceCheckAt) instead of
re-running row equality over all 128 rows a second time.
- Explicit resize-completion: the portal registry posts
cmuxInteractiveGeometryResizeDidEnd from its single end path (tracker,
legacy gesture, and cursor failsafe all funnel there); the table
re-measures immediately on it. The 120ms trailing task remains only as
a fallback for width churn without an end signal (window live resize).
- Metadata show-more/less and markdown show-details toggles (legacy
parity, same localized keys): expansion state is container-owned and
flows through the model so heights re-measure via the normal apply.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add SidebarLayoutModel + width applier wrappers (unwired scaffolding)
Canonical width storage outside ContentView state so divider ticks stop
re-evaluating the whole window body; only the tiny applier wrappers
observe it. Wiring of the read/write sites follows in the next commit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Move sidebar width out of ContentView state (SidebarLayoutModel wiring)
Divider ticks no longer re-evaluate ContentView's body: canonical width
lives in an unobserved SidebarLayoutModel, and only the small
SidebarWidthReader / width-modifier wrappers observe it (sidebar panel,
terminal leading padding, resizer overlay, titlebar inset, chrome
border). Writes keep their call sites via a computed alias; the width
sanitizer moves from onChange to onReceive(removeDuplicates) since
ContentView no longer tracks the value in body. Behavior-identical
storage move; both flag paths keep the same values and layout.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Settle-pass width handler + deterministic coalescer with unit tests
The width sanitizer's move to onReceive regressed drags: Combine
delivered it synchronously inside every width write (7.6ms/event
measured), running persistence and a portal resync per drag tick. The
handler now hops to the runloop, skips entirely mid-drag (the tracking
loop and portal anchor callbacks own live geometry), and the full
settle (sanitize/persist/portal resync/cursor band) runs once on the
registry's drag-end notification.
SidebarSelectionCoalescer becomes generic over Clock with all timing
from the injected clock, making it deterministic under test; adds
SidebarSelectionCoalescerTests (manual clock: leading edge, last-wins
trailing, quiet-window reset, cancel) wired into the pbxproj.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Route the sidebar flag into the portal from its single evaluation site
lint-feature-flags requires one evaluating file per flag; the portal's
anchor-failsafe gate from round 1 read it directly. ContentView's
sidebar dispatcher (the legit site) now pushes a plain bool into
WindowTerminalPortal.usesCoalescedAnchorFailsafe on branch mount.
Co-Authored-By: Claude Fable 5 <[email protected]>
* No implicit animations in sidebar cells; group headers join the fast selection path
Dogfood video showed rails and text visibly interpolating during and
after divider resizes, and group-header clicks feeling like the old
selection path.
- noteHeightOfRows now runs in a zero-duration animation group (legacy
never animates row geometry), and both cell types disable implicit
layer actions in applyModel and manual layout — color and frame
changes snap exactly like the SwiftUI sidebar.
- Group-header clicks route through the same coalescer as workspace
rows (headers focus their anchor workspace), with an optimistic
anchor-active press treatment and the same visible-row deselection
sweep; chevron and plus presses are excluded (they don't select).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Behavior tests for the AppKit row cell (hover enforcement, optimistic paint)
Fixture factory for SidebarWorkspaceRowModel plus a DEBUG applyModel
probe on the cell. Covers: hover enforcement short-circuits when
already correct and re-applies the full model otherwise; optimistic
selection paints a flipped model while the stored model stays
authoritative; optimistic deselection no-ops on unselected rows.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix test compile: Foundation import + explicit continuation type
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix cmuxTests compile: hoist mutating gate calls out of #expect
The whole cmuxTests target failed to compile (blocking every unit test
run) because #expect captures its expression into a closure with an
immutable parameter, rejecting mutating calls on the captured var.
Pre-existing on main since the CI-advisory window; surfaced by the
first unit-test run against this branch.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Allowlist pre-existing determinism findings so unit tests run again
WorkspaceForkConversationContextMenuTests landed 7 sleep/duration
findings during the CI-advisory window; the determinism gate fails the
whole pipeline before any unit test executes, on every branch.
Regenerated via check-test-determinism.py --write-allowlist (the gate's
migration path); fixing that test's waits properly stays with its
owners.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix cmuxTests compile: fork-conversation test isolation and inference errors
Five compile errors landed with this file during the CI-advisory
window; since then the whole cmuxTests target has failed to build on
CI's own toolchain, so no unit test in the repo could run. Mechanical
fixes: explicit continuation element type; two main-actor local
functions converted to @Sendable closures (they're called from
@Sendable indexLoader closures); a main-actor snapshot builder hoisted
out of a withLock closure; a discarded Set.insert result inside
withLock to settle generic inference.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Self-healing row freeze: rebuild once at drag end (stale-rename fix)
Dogfood report: sidebar value updates (renames especially) sometimes
never rendered. Root cause: row building freezes while the interactive
resize registry is active (sidebar AND split-divider drags), so an
apply during a drag serves frozen rows and consumes the fresh content
without rendering it — stale until the next unrelated sidebar change.
The scroll area now invalidates the frozen box and forces one fresh
rebuild on the registry's drag-end notification, so mid-drag mutations
always render. The rename data path itself was verified sound
(customTitle is @Published and in the sidebar observation composition).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Zero out the five over-budget Swift warnings from the fork/attachment files
The warning-budget gate has per-file zero budgets for these buckets;
the warnings landed during the CI-advisory window. Mechanical: two
redundant awaits on same-actor calls, one var never mutated, two unused
guard bindings replaced with nil tests (identical short-circuit
semantics), and an explicit 'as Any' for the QLPreviewPanel! coercion.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix the remaining QLPreviewPanel coercion warning (second call site)
Co-Authored-By: Claude Fable 5 <[email protected]>
* Deliver sidebar observation on DispatchQueue.main (modal/menu stall fix)
Dogfood reports: renames (the Cmd+Shift+R flow especially) and workspace
color changes reached the sidebar UI with long delays. RunLoop.main as a
Combine scheduler delivers only in the default runloop mode, so every
hop in the sidebar observation pipeline (container observations, merged
extension stream, per-cell pump) stalled during modal panels, context
menus, and drag tracking - exactly where renames and color picks happen.
The snapshot-refresh coalescer beneath already used .common modes; all
publisher hops now schedule on DispatchQueue.main, which is
runloop-mode-agnostic. customTitle, customColor, description, pin, and
todo state all ride these publishers, so one fix covers both reports.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Route indexnow jobs through vars.LINUX_RUNNER
The runner guard forbids bare GitHub-hosted runners; indexnow.yml landed
on main with ubuntu-latest during the CI-advisory week and fails
workflow-guard-tests on every gate run.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Round-2 dogfood fixes: live width reflow, double-click rename, main test heal
- Rows re-wrap continuously during divider/window resize: per width tick
the visible pure-AppKit rows re-measure at the live width (manual frame
math, bounded by viewport size); heightOfRow falls back to a
content-matched entry at another width mid-drag, and the settle pass
forces a full re-measure even when the drag ends where it started.
- Double-click rename: the single-click action fires for both clicks, so
click 2's queued coalesced selection landed after the rename field took
the field editor, re-activated the workspace, and end-editing committed
the untouched title. didDoubleClickTableRow now drops the queued
selection before beginning the edit, and logs whether the field took
first responder.
- DEBUG probes across the rename/color paint chain (write, snapshot
refresh, table apply, title paint, geometry-resize gate transitions) to
localize the still-reported reactivity delay with evidence.
- Heal main: DockPortalReconcileTests calls
preparePortalHostReplacementIfOwned without the instanceSerial that
https://github.com/manaflow-ai/cmux/pull/8310 added (merged during the
CI-advisory week); all four unit-test shards fail to compile on main.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Mount sidebar observations on the shared scroll-area parent
Root cause of the delayed rename/color reactivity, proven with the
debug probes: every workspace publisher observation
(sidebarWorkspaceObservations, process-title, agent-runtime) plus the
initial refreshWorkspaceSnapshots hung off legacyWorkspaceScrollArea's
view chain. With the AppKit sidebar flag on that subtree never mounts,
so no workspace publisher was observed at all: workspaceSnapshotsById
stayed empty, renames/colors/pins/descriptions produced no sidebar
invalidation, and rows only repainted when an unrelated change rebuilt
the body (probe: title write 19:25:01.279, paint 19:25:08.481, zero
snapshot flushes in between). The observation block now lives on the
shared parent Group so both implementations use one refresh path.
Also: a fast row drag consumed the press without any selection commit,
so the optimistic press highlight lingered on the grabbed row and every
other visible row stayed peeled; drag-session begin now drops the
queued selection and restores visible cells from stored models.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: peel header optimistic highlight, width settle on main queue
CodeRabbit: previewSelection's peel loop only reset workspace cells, so
a pending group-header preview replaced by a new press kept its
anchor-active paint until its model next changed. Headers now clear via
clearOptimisticAnchorActive (re-applies the stored model).
Greptile: the width-settle onReceive still hopped through RunLoop.main;
same default-mode stall class this PR fixes elsewhere, now
DispatchQueue.main.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
Resolves conflicts from #7129 (per-category agent notification settings):
- AppSection.swift: keep BOTH the desktopNotifications permission model
(this branch) and the agentPermissionPrompt/agentTurnComplete/
agentIdleReminder rows (#7129) across the @State decls, init, and the
startSettingsObservation array; the body auto-merged with both row sets.
- .github/swift-file-length-budget.tsv: regenerated via
scripts/swift_file_length_budget.py --write-budget (never hand-edited).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Addresses cubic-dev-ai review on #6960:
- WorkspaceChromeColorTests.expectedChromeHex: replace the tautological
wrapper around WindowAppearanceSnapshot.compositedTerminalColor (the same
production call bonsplitChromeHex makes) with an independent source-over
composite over the runtime windowBackgroundColor, so a regression in the
production blend now diverges the two values instead of passing trivially.
- PanelAppearanceBackgroundTests: derive the expected composited channels
independently instead of re-calling GhosttyBackgroundTheme.color (the path
under test), and assert the flattened result is opaque (alpha == 1).
- DesktopNotificationPermissionPresentationTests: add provisional/ephemeral
state coverage (deliverQuietly/temporary -> allowed, openSystemSettings,
send-test enabled).
- Regenerate swift-file-length-budget.tsv for the added test lines.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The prefetch revision-capture fix added 5 lines to FileExplorerStore.swift
(1317 -> 1322). Regenerate the budget via
`python3 scripts/swift_file_length_budget.py --write-budget` so the
workflow-guard-tests bare budget check passes.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The file-explorer prefetch path sampled `contentRevision` inside the
delayed Task body instead of when the prefetch was requested. If a
reload() landed after the 200ms debounce fired (scheduling the Task) but
before that Task ran, cancelAllLoads() could no longer cancel it, and the
Task then read the post-reload revision — so loadChildren's guard
(contentRevision == expectedContentRevision) trivially passed while the
captured FileExplorerNode was stale, letting it populate nodesByPath and
node.children from an old tree.
Capture `revision = contentRevision` before creating the DispatchWorkItem,
matching the explicit expand()/reload() paths, so a stale prefetch is
rejected by the existing revision guard.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Both under-page-background tests now post .ghosttyDefaultBackgroundDidChange
through an injected NotificationCenter, exercising the live subscription (one
covers the Double opacity payload, the other the NSNumber payload). The
#if DEBUG applyWebViewBackgroundForTesting accessor in production source is no
longer referenced and is removed per the test/debug-seam policy.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The appearance-test stabilization (08b736e378) changed
testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges
to call applyWebViewBackgroundForTesting directly, so a regression in the live
.ghosttyDefaultBackgroundDidChange publisher/sink wiring would still pass.
Inject the panel's broadcast NotificationCenter (defaulting to .default, so
production behavior is unchanged) and post the notification through it. The test
now exercises the real subscription plus GhosttyBackgroundTheme.color(from:)
parsing without posting to the shared NotificationCenter.default that app-host
appearance tests rely on. The WithGhosttyOpacity test keeps the direct-helper
path as lower-level coverage.
Co-Authored-By: Claude Opus 4.8 <[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
5839 changed files with 1134568 additions and 72501 deletions
Reclaim disk space taken by tagged dev artifacts produced by`./scripts/reload.sh --tag <tag>`. Each tagged build is multi-GB of DerivedData plus per-tag sockets and logs.
Reclaim disk taken by tagged dev artifacts from`./scripts/reload.sh --tag <tag>`. Each tagged build is multi-GB of DerivedData plus per-tag sockets and logs.
## Steps
1.**Preview first.**
1.**Preview.**`./scripts/cleanup-dev-builds.sh` is dry-run by default and prints what would be deleted, what is skipped, and total reclaimable bytes.
```bash
./scripts/cleanup-dev-builds.sh
```
2.**Read the preview to the user.** Confirm the active tag and any tag they care about appears under `skipping:`.
Shows what would be deleted, what is skipped, and total reclaimable bytes. Dry-run by default; nothing is deleted yet.
3.**Ask before deleting.** Never run `--apply` without explicit confirmation. Surface tags they may want to protect with `--keep <tag>`.
2. **Read the preview to the user.** Confirm the active tag and any tag they care about appears under `skipping:` (running, or most recent reload via `/tmp/cmux-last-cli-path`).
4.**Apply.**`./scripts/cleanup-dev-builds.sh --apply`. Optional: `--keep <tag>` (repeatable) to protect specific tags, `--older-than <DAYS>` to skip recently touched artifacts.
3. **Ask the user before deleting.** Do not run `--apply` without explicit user confirmation. Surface any tags they may want to keep so they can add `--keep <tag>`.
4. **Apply.** Once confirmed:
```bash
./scripts/cleanup-dev-builds.sh --apply
```
Optional: `--keep <tag>` (repeatable) to protect specific tags, `--older-than <DAYS>` to skip anything touched recently.
5. **Report.** Show the freed-bytes total from the script's final line.
5.**Report** the freed-bytes total from the script's final line.
## Notes
- Safety rules always on: skip running `cmux DEV <tag>` apps, skip the tag in `/tmp/cmux-last-cli-path` (most recent reload).
- Worktrees existing under HQ are NOT a protection. Use `--keep` for explicit protection.
- The script never touches `GhosttyKit.xcframework` symlinks, the GhosttyKit cache, or anything outside per-tag artifacts.
Always-on safety rules: skip running `cmux DEV <tag>` apps, and skip the tag in `/tmp/cmux-last-cli-path` (the most recent reload). An existing HQ worktree is not protection; use `--keep`. The script never touches `GhosttyKit.xcframework` symlinks, the GhosttyKit cache, or anything outside per-tag artifacts.
@@ -5,12 +5,6 @@ Pull latest main and update all submodules to their latest remote main. No commi
## Steps
1.`git pull origin main`
2. For each submodule (ghostty, homebrew-cmux, vendor/bonsplit):
-`cd <submodule>`
-`git fetch origin`
- Check if behind: `git rev-list HEAD..origin/main --count`
- If behind, merge: `git merge origin/main --no-edit`
- Do NOT push. We only land submodule changes via PRs.
- Go back to repo root
2. For each of `ghostty`, `homebrew-cmux`, `vendor/bonsplit`: `git fetch origin`, check `git rev-list HEAD..origin/main --count`, and if behind run `git merge origin/main --no-edit`. Do not push; submodule changes land only via PRs.
3.`git submodule update --init --recursive`
4. Report: current commit, which submodules were updated and by how many commits
4. Report the current commit, plus which submodules moved and by how many commits.
Full end-to-end release built locally. Bumps version, updates changelog, tags, then builds/signs/notarizes/uploads via `scripts/build-sign-upload.sh`.
Release straight from `main` with no PR, built and published locally.
## Steps
Follow [release.md](release.md) "Shared prep" (version, changelog, contributors, `./scripts/bump-version.sh`) and its changelog and contributor-credit rules. `skills/cmux-release/SKILL.md` covers the bump and tag mechanics.
### 1. Determine the new version number
## Delta: no PR, tag on main, local build
- Get the current version from`cmux.xcodeproj/project.pbxproj` (look for `MARKETING_VERSION`)
- Bump the minor version unless the user specifies otherwise (e.g., 0.54.0 → 0.55.0)
1.**Commit on main.** Stage `CHANGELOG.md` and`cmux.xcodeproj/project.pbxproj`, commit `Bump version to X.Y.Z`.
### 2. Gather changes and contributors since the last release
2.**Guard, tag, push.**
- Find the most recent git tag: `git describe --tags --abbrev=0`
- Get commits since that tag: `git log --oneline <last-tag>..HEAD --no-merges`
- Build a deduplicated list of all contributor `@handle`s for the release
```bash
./scripts/release-pretag-guard.sh
git tag vX.Y.Z
git push origin main && git push origin vX.Y.Z
```
### 3. Update the changelog
If the guard fails, run `./scripts/bump-version.sh`, commit the build-number bump, and rerun the guard.
- Add a new section at the top of `CHANGELOG.md` with the new version and today's date
- **Only include changes that affect the end-user experience**
- Write clear, user-facing descriptions (not raw commit messages)
- **Credit contributors inline** (see Contributor Credits below)
- Also update `docs-site/content/docs/changelog.mdx` if it exists
3. **Build, sign, notarize, upload.**
### 4. Bump the version
```bash
./scripts/build-sign-upload.sh vX.Y.Z
```
- Run: `./scripts/bump-version.sh` (bumps minor by default)
The script does GhosttyKit build, xcodebuild, Sparkle key injection, codesigning, notarization of app and DMG, appcast generation, GitHub release upload of `cmux-macos.dmg` and `appcast.xml`, homebrew cask update, cleanup, and `say "cmux release complete"` on success. Pass `--allow-overwrite` only to replace existing assets on the same tag during an emergency reroll. If it fails, run `say "cmux release failed"`.
### 5. Commit, run the pre-tag guard, then tag and push
- Internal refactoring with no user-visible effect
- Dependency updates (unless they fix a user-facing bug)
**Writing style:**
- Use present tense ("Add feature" not "Added feature")
- Group by category: Added, Changed, Fixed, Removed
- Be concise but descriptive
- Focus on what the user experiences, not how it was implemented
## Contributor Credits
Credit the people who made each release happen. This builds community and encourages contributions.
**Per-entry attribution** — append contributor credit after each changelog bullet:
- For code contributions (PR author): `— thanks @user!`
- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!`
- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline
**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release:
```markdown
### Thanks to N contributors!
- [@user1](https://github.com/user1)
- [@user2](https://github.com/user2)
```
- List all contributors alphabetically by GitHub handle (including core team)
- Link each handle to their GitHub profile
- Include everyone: PR authors, issue reporters, anyone whose work is in the release
**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles.
```bash
bash tests/test_homebrew_sha.sh
git add homebrew-cmux && git commit -m "Update homebrew-cmux submodule to latest" && git push origin main
End-to-end release via PR flow: bump version, update changelog, create PR, merge, tag, then build locally via `scripts/build-sign-upload.sh`.
Release through the PR flow, then build and publish locally instead of waiting on the CI release workflow.
## Steps
Follow [release.md](release.md) "Shared prep" (version, changelog, contributors, `./scripts/bump-version.sh`) and its changelog and contributor-credit rules, then steps 5 through 8 (branch, PR, `gh pr checks --watch`, `gh pr merge --squash --delete-branch`, `./scripts/release-pretag-guard.sh`, tag and push). `skills/cmux-release/SKILL.md` covers the bump and tag mechanics.
### Phase 1: Version bump, changelog, PR, merge, tag
## Delta: build locally instead of from CI
1.**Determine the new version number**
- Get the current version from `cmux.xcodeproj/project.pbxproj` (look for `MARKETING_VERSION`)
- Bump the minor version unless the user specifies otherwise (e.g., 0.48.0 → 0.49.0)
2.**Create a release branch**
- Create branch: `git checkout -b release/vX.Y.Z`
3.**Gather changes and contributors since the last release**
- Find the most recent git tag: `git describe --tags --abbrev=0`
- Get commits since that tag: `git log --oneline <last-tag>..HEAD --no-merges`
The script does GhosttyKit build, xcodebuild, Sparkle key injection, codesigning, notarization of app and DMG, appcast generation, GitHub release upload of `cmux-macos.dmg` and `appcast.xml`, homebrew cask update, cleanup, and `say "cmux release complete"` on success. Pass `--allow-overwrite` only to replace existing assets on the same tag during an emergency reroll.
If the script fails, run `say "cmux release failed"`.
- Internal refactoring with no user-visible effect
- Dependency updates (unless they fix a user-facing bug)
## Contributor Credits
Credit the people who made each release happen. This builds community and encourages contributions.
**Per-entry attribution** — append contributor credit after each changelog bullet:
- For code contributions (PR author): `— thanks @user!`
- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!`
- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline
**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release:
```markdown
### Thanks to N contributors!
- [@user1](https://github.com/user1)
- [@user2](https://github.com/user2)
```
- List all contributors alphabetically by GitHub handle (including core team)
- Link each handle to their GitHub profile
- Include everyone: PR authors, issue reporters, anyone whose work is in the release
**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles.
Prepare a new release for cmux. This command updates the changelog, bumps the version, creates a PR, monitors CI, and then merges and tags.
Ship a stable cmux release built by CI: bump version, update changelog, open a PR, merge, tag, then GitHub Actions builds, signs, and publishes.
## Steps
`skills/cmux-release/SKILL.md` owns the version-bump, pretag-guard, and tag mechanics plus the Apple signing secrets. This file owns the shared changelog and contributor procedure that `/release-nightly` and `/release-local` also use, and the PR-and-CI build path.
1.**Determine the new version number**
- Get the current version from `cmux.xcodeproj/project.pbxproj` (look for `MARKETING_VERSION`)
- Bump the minor version unless the user specifies otherwise (e.g., 0.12.0 → 0.13.0)
## Shared prep (all three release commands)
2.**Create a release branch**
- Create branch: `git checkout -b release/vX.Y.Z`
1.**Pick the version.** Read `MARKETING_VERSION` from `cmux.xcodeproj/project.pbxproj`. Bump minor unless the user says otherwise (0.12.0 to 0.13.0).
3.**Gather changes and contributors since the last release**
- Find the most recent git tag: `git describe --tags --abbrev=0`
- Get commits since that tag: `git log --oneline <last-tag>..HEAD --no-merges`
- Update all occurrences of `MARKETING_VERSION` in `cmux.xcodeproj/project.pbxproj`
- There are typically 4 occurrences (Debug/Release for main app and CLI)
Keep only end-user visible changes, categorize into Added, Changed, Fixed, Removed, and build a deduplicated list of contributor `@handle`s from PR authors and linked issue reporters. If nothing is user-facing, ask the user whether to release anyway.
3. **Update `CHANGELOG.md`.** Add a section at the top with the new version and today's date, written as user-facing descriptions rather than raw commit messages, with inline contributor credit. The docs changelog page renders from `CHANGELOG.md`, so there is no second changelog file to edit.
4. **Bump the version.** `./scripts/bump-version.sh` (minor by default) updates `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` everywhere in the Xcode project.
8. **Monitor CI**
- Watch the CI workflow: `gh pr checks --watch`
- If CI fails, fix the issues and push again
- Wait for all checks to pass before proceeding
## CI-built release (this command)
9. **Merge the PR**
- Merge: `gh pr merge --squash --delete-branch`
- Switch back to main: `git checkout main && git pull`
5. **Branch, commit, push.** `git checkout -b release/vX.Y.Z`, stage `CHANGELOG.md` and `cmux.xcodeproj/project.pbxproj`, commit `Bump version to X.Y.Z`, then `git push -u origin release/vX.Y.Z`.
10. **Run the pre-tag guard, then create and push the tag**
- Run: `./scripts/release-pretag-guard.sh`
- If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, push/merge that change, and retry the tag
- Create tag: `git tag vX.Y.Z`
- Push tag: `git push origin vX.Y.Z`
6. **PR and CI.** `gh pr create --title "Release vX.Y.Z" --body "...changelog summary..."` with the changelog entries in the body, then `gh pr checks --watch`. Fix failures and push until every check passes.
11. **Monitor the release workflow**
- Watch: `gh run watch --repo manaflow-ai/cmux`
- Verify the release appears at: https://github.com/manaflow-ai/cmux/releases
- Check that the DMG is attached to the release
7. **Merge.** `gh pr merge --squash --delete-branch`, then `git checkout main && git pull`.
12. **Verify homebrew cask update**
- The "Update Homebrew Cask" workflow triggers automatically after the release workflow completes
- Watch: `gh run list --workflow=update-homebrew.yml --limit=1` and `gh run watch`
- Run `bash tests/test_homebrew_sha.sh` to confirm the SHA matches
8. **Guard and tag.** `./scripts/release-pretag-guard.sh`, then `git tag vX.Y.Z && git push origin vX.Y.Z`. If the guard fails, run `./scripts/bump-version.sh`, commit the build-number bump, push and merge that change, then retry.
13. **Notify**
- On success: `say "cmux release complete"`
- On failure: `say "cmux release failed"`
9. **Watch the release workflow.** `gh run watch --repo manaflow-ai/cmux`. Confirm the release at https://github.com/manaflow-ai/cmux/releases exists with `cmux-macos.dmg` attached.
## Changelog Guidelines
10. **Verify the homebrew cask.** `update-homebrew.yml` triggers automatically once the release workflow finishes.
- Internal refactoring with no user-visible effect
- Dependency updates (unless they fix a user-facing bug)
11. **Notify.** `say "cmux release complete"` on success, `say "cmux release failed"` on failure.
**Writing style:**
- Use present tense ("Add feature" not "Added feature")
- Group by category: Added, Changed, Fixed, Removed
- Be concise but descriptive
- Focus on what the user experiences, not how it was implemented
- Link to issues/PRs if relevant
## Changelog guidelines
## Contributor Credits
Include what a user can see, feel, or interact with: new features, noticeable bug fixes (crashes, UI glitches, wrong behavior), performance the user would feel, UI/UX changes, breaking changes and removals.
Exclude internal work: setup/build/reload scripts, CI and workflow changes, docs (README, CONTRIBUTING, CLAUDE.md), tests, refactors with no user-visible effect, and dependency bumps unless they fix a user-facing bug.
Write in present tense ("Add feature", not "Added feature"), grouped by Added, Changed, Fixed, Removed. Be concise and descriptive, describe what the user experiences rather than how it was implemented, and link the issue or PR when relevant.
## Contributor credits
Credit the people who made each release happen. This builds community and encourages contributions.
**Per-entry attribution** — append contributor credit after each changelog bullet:
- For code contributions (PR author): `— thanks @user!`
- For bug reports (issue reporter, if different from PR author): `— thanks @reporter for the report!`
- Core team (`lawrencecchen`, `austinywang`) contributions get no per-entry callout — core work is the baseline
Per-entry attribution goes after each changelog bullet: `— thanks @user!` for a PR author, `— thanks @reporter for the report!` for an issue reporter who is not the PR author. Core team (`lawrencecchen`, `austinywang`) work is the baseline and gets no per-entry callout.
Every release ends with a summary section listing all contributors alphabetically by handle, core team included, each linked to their GitHub profile. The published GitHub Release body carries the same section.
**Summary section** — add a "Thanks to N contributors!" section at the bottom of each release:
```markdown
### Thanks to N contributors!
- [@user1](https://github.com/user1)
- [@user2](https://github.com/user2)
```
- List all contributors alphabetically by GitHub handle (including core team)
- Link each handle to their GitHub profile
- Include everyone: PR authors, issue reporters, anyone whose work is in the release
**GitHub Release body** — when the release is published, the GitHub Release should also include the "Thanks to N contributors!" section with linked handles.
## Example Changelog Entry
## Example changelog entry
```markdown
## [0.13.0] - 2025-01-30
@@ -142,6 +88,6 @@ Credit the people who made each release happen. This builds community and encour
@@ -6,34 +6,14 @@ Get the current branch ready: update all submodules to their latest remote main,
## Steps
1.**Update submodules to latest**
- For each submodule (ghostty, homebrew-cmux, vendor/bonsplit):
-`cd <submodule>`
-`git fetch origin`
- Check if behind: `git rev-list HEAD..origin/main --count`
- If behind, merge: `git merge origin/main --no-edit`
- Do NOT push submodules. We only land submodule changes via PRs.
- Go back to repo root
1.**Update submodules to latest.** For each of `ghostty`, `homebrew-cmux`, `vendor/bonsplit`: `git fetch origin`, check `git rev-list HEAD..origin/main --count`, and if behind run `git merge origin/main --no-edit`. Do not push submodules; submodule changes land only via PRs.
2.**Commit submodule updates on main**
-`git checkout main && git pull origin main`
- Check if any submodules changed: `git diff --name-only` (look for submodule paths)
- If changed, stage and commit: `git add ghostty homebrew-cmux vendor/bonsplit && git commit -m "Update submodules: <brief description>"`
- **Do not push.** Ask the user if they want to push.
2.**Commit submodule updates on main.**`git checkout main && git pull origin main`, check `git diff --name-only` for submodule paths, and if any changed: `git add ghostty homebrew-cmux vendor/bonsplit && git commit -m "Update submodules: <brief description>"`. Do not push. Ask the user whether to push.
3.**Rebase current branch on main**
-`git checkout <original-branch>`
-`git rebase main`
- If conflicts, resolve them and continue
- **Do not push.** Ask the user if they want to force-push the rebased branch.
3.**Rebase the branch on main.**`git checkout <original-branch> && git rebase main`, resolving conflicts and continuing. Do not push. Ask the user whether to force-push the rebased branch. Skip this step if already on main.
4.**Report status**
- Show what submodules were updated and by how many commits
- Show if rebase was clean or had conflicts
- Show current branch and commit
4.**Report.** Which submodules moved and by how many commits, whether the rebase was clean or conflicted, and the current branch and commit. If no submodules needed updating and main has no new commits, say "Already up to date".
## Notes
-Never commit a submodule pointer in the parent repo unless the submodule commit is reachable from the submodule's remote main (per CLAUDE.md pitfall about orphaned commits)
- If no submodules need updating and main has no new commits, just say "Already up to date"
- If on main already, skip step 3
Never commit a submodule pointer in the parent repo unless that submodule commit is reachable from the submodule's remote main (see the submodule-safety pitfall in CLAUDE.md).
--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" \
if (!truncated && !files.some((file) => touchesIOS(file.filename))) {
shouldBuild = false;
reason = 'no iOS-relevant changes since last upload';
}
} catch (e) {
core.warning(`could not compare against last upload: ${e.message}`);
}
if (runs.data.workflow_runs.length < 100) break;
}
} catch (e) {
lookupFailed = true;
core.warning(`could not resolve last uploaded sha: ${e.message}`);
}
// A scheduled run must FAIL CLOSED when the history lookup ERRORED: a
// null last-uploaded SHA reads as "not HEAD" below, so the lane would
// re-upload the same already-shipped main commit every 2h with a fresh
// build number and fallback notes. Before this job wrapped the lookup in
// try/catch the throw failed the job here; preserve that. A genuine
// no-prior-run (the API SUCCEEDED but returned no runs) is NOT a failure:
// lookupFailed stays false and the first beta builds normally.
if (context.eventName === 'schedule' && lookupFailed) {
core.setFailed('could not resolve the last uploaded beta SHA (workflow run history lookup failed); refusing to auto-upload to avoid duplicate TestFlight builds');
return;
}
// workflow_dispatch always builds (the operator asked for it).
// Scheduled runs build unless HEAD was ALREADY uploaded by a prior
// successful run (a SHA compare, not a wall-clock window: a failed or
// missed run leaves the last success on an older SHA, so the next run
// retries the un-uploaded commit instead of stranding it).
let needsBuild = true;
if (!forceBuild && context.eventName === 'schedule') {
needsBuild = lastUploadedSha !== context.sha;
}
// Path gate: even when main advanced, a scheduled beta only ships if
// the range since the last uploaded beta touches something that can
// change the iOS IPA. Every TestFlight upload notifies every tester,
// so web-only / macOS-only / docs-only merges must not produce a new
// build. The path set mirrors test-ios.yml's should_run gate plus the
// inputs the archive actually consumes: the ghostty submodule pointer
// (GhosttyKit is linked into the app), the GhosttyKit provisioning
// scripts, the zig toolchain pin, and this workflow itself (archive /
// export settings live here). This gate FAILS OPEN (builds anyway) on
// any doubt - a compare-API error, a diverged range, a >=300-file
// diff where the API truncates the file list - because a spurious
// upload costs one notification while a wrong skip silently strands
- 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!
- Native AppKit workspace sidebar, now on by default: faster scrolling, precise hover and selection, and full settings fidelity ([#8270](https://github.com/manaflow-ai/cmux/pull/8270), [#8433](https://github.com/manaflow-ai/cmux/pull/8433), [#8366](https://github.com/manaflow-ai/cmux/pull/8366), [#8390](https://github.com/manaflow-ai/cmux/pull/8390), [#8415](https://github.com/manaflow-ai/cmux/pull/8415), [#8432](https://github.com/manaflow-ai/cmux/pull/8432), [#8450](https://github.com/manaflow-ai/cmux/pull/8450)) -- thanks @azooz2003-bit!
- Browser Design Mode: visually edit pages in the browser pane, annotate elements, and hand the changes to an agent ([#8034](https://github.com/manaflow-ai/cmux/pull/8034), [#8393](https://github.com/manaflow-ai/cmux/pull/8393))
- Forward mouse input to TUI applications running in the terminal ([#7759](https://github.com/manaflow-ai/cmux/pull/7759))
- Surface and workspace reorder shortcuts ([#8080](https://github.com/manaflow-ai/cmux/pull/8080))
- Session content width setting, and the previous width ceiling is removed ([#8222](https://github.com/manaflow-ai/cmux/pull/8222), [#8338](https://github.com/manaflow-ai/cmux/pull/8338))
- Attach images to todos ([#8117](https://github.com/manaflow-ai/cmux/pull/8117)) -- thanks @azooz2003-bit!
- OpenCode: Fork Conversation from the tab context menu ([#8140](https://github.com/manaflow-ai/cmux/pull/8140))
- CLI: `cmux ssh` accepts an initial remote command ([#8439](https://github.com/manaflow-ai/cmux/pull/8439))
- Share native SSH connections per host ([#8308](https://github.com/manaflow-ai/cmux/pull/8308))
- Notify on fatal Codex turn errors ([#8170](https://github.com/manaflow-ai/cmux/pull/8170))
- iOS (beta): files gallery with folders, previews, and a streaming viewer ([#8287](https://github.com/manaflow-ai/cmux/pull/8287)) -- thanks @azooz2003-bit!
### Changed
- Update pill installs are causal and fail visibly instead of silently ([#8375](https://github.com/manaflow-ai/cmux/pull/8375))
- The sidebar scroll indicator shows only while scrolling ([#7976](https://github.com/manaflow-ai/cmux/pull/7976))
- Group cmux TUI context menu actions ([#8225](https://github.com/manaflow-ai/cmux/pull/8225))
- Reap the persistent SSH daemon when its workspace closes ([#8073](https://github.com/manaflow-ai/cmux/pull/8073))
- Tighten terminal textbox top spacing ([#8322](https://github.com/manaflow-ai/cmux/pull/8322))
### Fixed
- Preserve Codex YOLO mode across session restore and resume repair ([#8133](https://github.com/manaflow-ai/cmux/pull/8133), [#8045](https://github.com/manaflow-ai/cmux/pull/8045))
- Preserve Pi sessions after workspace restore ([#7628](https://github.com/manaflow-ai/cmux/pull/7628)) -- thanks @silouanwright!
- Fix Pi and OMP fork actions in tab context menus ([#8173](https://github.com/manaflow-ai/cmux/pull/8173))
- Fix Cmd-click for soft-wrapped URLs ([#8110](https://github.com/manaflow-ai/cmux/pull/8110))
- Fix typing latency from title churn ([#8084](https://github.com/manaflow-ai/cmux/pull/8084), [#8155](https://github.com/manaflow-ai/cmux/pull/8155))
- Fix a sidebar scroll layout livelock ([#8211](https://github.com/manaflow-ai/cmux/pull/8211)) and sidebar GitHub polling regressions ([#8226](https://github.com/manaflow-ai/cmux/pull/8226), [#8190](https://github.com/manaflow-ai/cmux/pull/8190))
- Replace per-row sidebar hover reconcilers with a single pointer owner ([#8067](https://github.com/manaflow-ai/cmux/pull/8067)) -- thanks @azooz2003-bit!
- Fix Dock split rendering and shortcut routing ([#8142](https://github.com/manaflow-ai/cmux/pull/8142))
Run the setup script to initialize submodules, build GhosttyKit, and install the pbxproj normalization pre-commit hook:
`./scripts/setup.sh` initializes submodules, builds GhosttyKit, and installs the pbxproj normalization pre-commit hook.
## Build and reload
Always build with a tag. **Never run bare `xcodebuild` or `open` an untagged `cmux DEV.app`**: untagged builds share the default debug socket and bundle ID with other agents, causing conflicts and stealing focus.
```bash
./scripts/setup.sh
./scripts/reload.sh --tag <branch-slug> # build Debug, kill same-tag app, do not launch
./scripts/reload.sh --tag <branch-slug> --launch # also open it
```
## Local dev
A tag gives the app its own name, bundle ID, socket, and derived data path, so it runs side-by-side with the user's main app. Report the build to the user as a markdown link to `http://127.0.0.1:17320/<tag>`. Never put a `file://` URL, a raw `.app` path, or `/tmp/cmux-<tag>/...` in chat output.
After making code changes, always run the reload script with a tag to build the Debug app:
Other variants: `reloadp.sh` (Release), `reloads.sh` (Release as isolated "cmux STAGING"), `reload2.sh --tag <tag>` (both).
By default, `reload.sh` builds but does **not** launch the app. The script prints the `.app` path so the user can cmd-click to open it. After a successful build, it always terminates any running app with the same tag (so cmd-clicking launches the freshly-built binary instead of foregrounding the stale instance). Pass `--launch` to open the app automatically after the build:
Rebuild GhosttyKit.xcframework with Release optimizations:
Never use `/tmp/cmux-<tag>/...` app links in chat output.
For CLI or socket dogfood against a tagged Debug app, use the tag-bound helper and set `CMUX_TAG`.
Do not use `/tmp/cmux-cli` for tagged dogfood, since that symlink points at the most recently reloaded build and can target the user's main app socket.
For CLI or socket dogfood against a tagged Debug app, set `CMUX_TAG` and use the helper. Do not use `/tmp/cmux-cli`, which points at the most recently reloaded build and can target the user's main app socket.
The helper refuses to run without `CMUX_TAG`, targets `/tmp/cmux-debug-<tag>.sock`, and uses the matching tagged CLI from `~/Library/Developer/Xcode/DerivedData/cmux-<tag>/...`. It also scrubs ambient cmux terminal context (`CMUX_SOCKET`, `CMUX_SOCKET_PASSWORD`, workspace/surface/tab/panel IDs, cmuxd socket, and debug log), then sets `CMUX_SOCKET_PATH`, `CMUX_BUNDLE_ID`, and `CMUX_BUNDLED_CLI_PATH` for the selected tag.
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.
After making code changes, always use `reload.sh --tag` to build. **Never run bare `xcodebuild` or `open` an untagged `cmux DEV.app`.** Untagged builds share the default debug socket and bundle ID with other agents, causing conflicts and stealing focus.
## iOS builds open on the iPhone by default
```bash
./scripts/reload.sh --tag <your-branch-slug>
```
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.
If you only need to verify the build compiles (no launch), use a tagged derivedDataPath:
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.
When rebuilding GhosttyKit.xcframework, always use Release optimizations:
## iOS dev auth
```bash
cd ghostty && zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast
```
`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=...`.
When rebuilding cmuxd for release/bundling, always use ReleaseFast:
## Regression test commits
```bash
cd cmuxd && zig build -Doptimize=ReleaseFast
```
`reload` = build the Debug app (tag required) and terminate any running app with the same tag. Pass `--launch` to also open the freshly-built app:
```bash
./scripts/reload.sh --tag <tag>
./scripts/reload.sh --tag <tag> --launch
```
`reloadp` = kill and launch the Release app:
```bash
./scripts/reloadp.sh
```
`reloads` = kill and launch the Release app as "cmux STAGING" (isolated from production cmux):
```bash
./scripts/reloads.sh
```
`reload2` = reload both Debug and Release (tag required for Debug reload):
```bash
./scripts/reload2.sh --tag <tag>
```
For parallel/isolated builds (e.g., testing a feature alongside the main app), use `--tag` with a short descriptive name:
```bash
./scripts/reload.sh --tag fix-blur-effect
```
This creates an isolated app with its own name, bundle ID, socket, and derived data path so it runs side-by-side with the main app. Important: use a non-`/tmp` derived data path if you need xcframework resolution (the script handles this automatically).
Before launching a new tagged run, clean up any older tags you started in this session (quit old tagged app + remove its `/tmp` socket/derived data).
For 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 dev sign-in credentials, do not ask the user to manually authenticate every build. Tell them to run `scripts/setup-team-dev.sh` once from any cmux checkout; it prompts for and verifies their Stack login, writes `~/.secrets/cmuxterm-dev.env` with chmod 600, and future agents can auto-auth iOS DEBUG reloads. Manual fallback: create that file with `CMUX_DOGFOOD_STACK_EMAIL=...` and `CMUX_DOGFOOD_STACK_PASSWORD=...`.
## Regression test commit policy
When adding a regression test for a bug fix, use a two-commit structure so CI proves the test catches the bug:
1.**Commit 1:** Add the failing test only (no fix). CI should go red.
2.**Commit 2:** Add the fix. CI should go green.
This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the test genuinely fails without the fix.
Two commits, so CI proves the test catches the bug: commit 1 adds the failing test only (CI red), commit 2 adds the fix (CI green). This is visible in the PR Commits tab.
## First pass, then dogfood
A task's 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 given to the user). Then hand off to the user for dogfood. 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: it runs structured review plus PR feedback, and only when a check actually fails does it spawn a bounded repair subagent with that check's name and log context. Do not launch a separate parallel CI repair agent; two agents mutating one worktree race each other. One writer per worktree: if dogfood feedback needs main-agent edits while the loop runs, stop the loop first or give it its own sibling worktree. In Claude Code spawn the loop with the agent/task tool; in Codex use a background sub-task or bounded background `codex exec`.
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 still 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. At handoff the main agent sends `cmux notify --title "Dogfood ready: <short task>" --subtitle "<branch> · <tag>" --body "Was: <prior bad behavior>. Now: <expected behavior>. <concrete check>. CI + review in background. PR: <pr-url>"`. The loop sends its outcome when done or blocked, e.g. `--title "CI green: <branch>"`, `--title "Review clean: <branch>" --body "fixed <n> findings, pushed"`, or `--title "CI blocked: <branch>" --body "<check>: <one-line cause>, needs your decision"`. Titles carry the outcome and branch; bodies say what happened and the single next action. If there is no cmux socket, skip notify and rely on the chat handoff.
## Shared behavior policy
- When a behavior is exposed through multiple entrypoints (keyboard shortcut, command palette, context menu, CLI, settings, debug menu), implement one shared action/model path and verify every entrypoint that should invoke it. Do not patch one surface while leaving the others with duplicated logic.
- For optimistic UI or CLI updates, keep one mutation path, record pending state with a request id or previous snapshot, reconcile from the authoritative result, and handle failure with an explicit rollback or error state. Do not let each entrypoint maintain its own optimistic copy.
- When a user says tests missed a bug, add or adjust behavior-level coverage around the exact repro path before claiming the fix is complete.
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.
## Pitfalls
Each of these has full detail in the skill named in parentheses.
- **Typing-latency-sensitive paths** (`cmux-debugging`): `WindowTerminalHostView.hitTest()` in `TerminalWindowPortal.swift`, `TabItemView` in `ContentView.swift`, and `TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift` run on every keystroke. Read the skill before touching them.
- **SwiftUI list boundaries** (`cmux-debugging`): no view below a `LazyVStack`/`LazyHStack`/`List`/`ForEach` boundary may hold an observable store reference, and no function called from `body` may write state. Violating either reintroduces the 100% CPU spin loop from https://github.com/manaflow-ai/cmux/issues/2586. Reference pattern: `IndexSectionActions` / `SectionGapActions` / `SessionSearchFn` in `Sources/SessionIndexView.swift`.
- **Do not add an app-level display link or manual `ghostty_surface_draw` loop.** Rely on Ghostty wakeups and its renderer, or typing lags.
- **Terminal find layering** (`cmux-debugging`): `SurfaceSearchOverlay` mounts from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), never from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
-Do not add an app-level display link or manual `ghostty_surface_draw` loop; rely on Ghostty wakeups/renderer to avoid typing lag.
- **Typing-latency-sensitive paths** (read carefully before touching these areas):
-`WindowTerminalHostView.hitTest()` in `TerminalWindowPortal.swift`: called on every event including keyboard. All divider/sidebar/drag routing is gated to pointer events only. Do not add work outside the `isPointerEvent` guard.
-`TabItemView` in `ContentView.swift`: uses `Equatable` conformance + `.equatable()` to skip body re-evaluation during typing. Do not add`@EnvironmentObject`, `@ObservedObject` (besides `tab`), or `@Binding` properties without updating the`==` function. Do not remove `.equatable()` from the ForEach call site. Do not read `tabManager` or `notificationStore` in the body; use the precomputed `let` parameters instead.
-`TerminalSurface.forceRefresh()` in `GhosttyTerminalView.swift`: called on every keystroke. Do not add allocations, file I/O, or formatting here.
- **Terminal find layering contract:** `SurfaceSearchOverlay` must be mounted from `GhosttySurfaceScrollView` in `Sources/GhosttyTerminalView.swift` (AppKit portal layer), not from SwiftUI panel containers such as `Sources/Panels/TerminalPanelView.swift`. Portal-hosted terminal views can sit above SwiftUI during split/workspace churn.
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc.
- **Localization audit is required for every user-facing change.** Before finishing a task that changes UI, Settings rows, menus, shortcut metadata, schema/config text, docs, command/help text, alerts, or tooltips, enumerate the changed user-facing surfaces and verify each one has entries for every supported locale. `defaultValue`, English fallback text, schema descriptions, or copied English strings do not count as localization. For Swift/AppKit strings, update `Resources/Localizable.xcstrings`; for localized web/docs content, update every supported message catalog (currently `web/messages/en.json` and `web/messages/ja.json`) and any localized data structures that carry inline translations. Parse touched localization files, compare changed message keys across locales, and use `rg` over changed Swift/TS/TSX/docs files for newly introduced bare English. The final handoff must state what localization audit was performed or explicitly say what could not be verified.
- **Shortcut policy:** Every new cmux-owned keyboard shortcut must be added to `KeyboardShortcutSettings`, visible/editable in Settings, supported in `~/.config/cmux/cmux.json`, and documented in the keyboard shortcut and configuration docs.
- **Snapshot boundary for list subtrees.** In any SwiftUI panel whose `body` contains a `LazyVStack` / `LazyHStack` / `List` / `ForEach` of rows, no view below that boundary may hold a reference to an `ObservableObject` / `@Observable` store (no `@ObservedObject`, `@EnvironmentObject`, `@StateObject`, `@Bindable`, or even a plain `let store: SomeStore` property). Rows and drop-gaps receive immutable value snapshots plus closure action bundles only. Violating this reintroduces the "orthogonal @Published change invalidates every row and thrashes `LazyLayoutViewCache`" class of 100% CPU spin loop that hit the Sessions panel and the workspace sidebar (https://github.com/manaflow-ai/cmux/issues/2586). Reference pattern: `IndexSectionActions` / `SectionGapActions` / `SessionSearchFn` in `Sources/SessionIndexView.swift`.
- **No state mutation inside view-body computations.** A function called from `body` (directly or through a helper) must not write `@Published` state, schedule a `Task { @MainActor in store.x = … }`, or `DispatchQueue.main.async` a store write. That creates a re-render feedback loop and pegs the main thread (same root-cause family as the snapshot-boundary rule). State-changing work triggered by "new data appeared" belongs in a `reload()` completion, a `didSet`, or a property-observer — never in the projection that feeds `ForEach`.
- **Foundation, SwiftUI, AttributeGraph, and WebKit semantics change silently between macOS major versions.** A function that "obviously" returns the same value on every macOS is not a reliable assumption. Concrete case from https://github.com/manaflow-ai/cmux/issues/4529: `URL(fileURLWithPath: "/").deletingLastPathComponent().path` returns `"/.."` on macOS 14 and 15 but `"/"` on macOS 26 — Apple silently fixed the underlying CFURL normalization. The repo's `macos-26` CI and every maintainer's dev machine were on the fixed-behavior side; every reporter on the issue was on the broken side. Always test on the reporter's macOS before declaring a user-reported repro disproven. AWS M4 Pro builders (`cmux-aws-mac`, `cmux-aws-m4pro`, `aws-m4pro-1..6`) are pre-provisioned on macOS 15.7.4 and the preferred empirical-repro path; see the `regression-hunt` skill in the cmuxterm-hq sibling repo for the full playbook.
- **Test files in `cmuxTests/` must be wired into `cmux.xcodeproj/project.pbxproj`.** A `.swift` file added to the worktree without a matching `PBXFileReference` + `PBXSourcesBuildPhase` entry is silently ignored by Xcode and never compiles or runs on CI. Both `xcodebuild test -only-testing:cmuxTests/<TestClass>` and bot reviews pass with "Executed 0 tests" — so the missing wiring is indistinguishable from a clean two-commit red/green regression test until a real user hits the bug. The `workflow-guard-tests` job runs `./scripts/lint-pbxproj-test-wiring.sh` to catch this at PR time; surfaced during the https://github.com/manaflow-ai/cmux/issues/4529 investigation against https://github.com/manaflow-ai/cmux/pull/4536. Add via Xcode (drag the file into the cmuxTests target) or hand-edit the four pbxproj entries; reference any wired sibling like `TabManagerUnitTests.swift` as a template.
- **SPM packages live in group folders, and the root workspace mirrors that folder shape exactly.** Every Swift package lives physically under exactly one group directory — `Packages/Shared/<pkg>` (used by both apps), `Packages/iOS/<pkg>` (iOS app only), or `Packages/macOS/<pkg>` (macOS app only) — and `cmux.xcworkspace/contents.xcworkspacedata` has three groups whose container locations are those folders, with every package directory appearing as a FileRef under its folder's group. So opening the workspace shows all packages grouped exactly like the directory tree. The folder is the source of truth: to move a package between groups, `git mv` its directory, then run `python3 scripts/check-workspace-package-groups.py --write` to regenerate the workspace. A new package goes in the group folder matching its consumers (both apps → Shared, iOS only → iOS, macOS only → macOS). Cross-group `.package(path:)` deps use `../../<Group>/<Name>`; never hand-edit the workspace group membership. CI's `python3 scripts/check-workspace-package-groups.py --check` fails on drift.
- **Do not ignore cmux-owned `Package.resolved` files.** SwiftPM resolution changes must be visible in PR diffs. Track the root Xcode lockfile and every cmux-owned package-local `Package.resolved` generated by standalone `swift package resolve`, `swift build`, or `swift test`; a package-local lockfile is the source of truth for that package's standalone resolution and is not replaced by `cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`. Vendored third-party directories may preserve their upstream ignore policy, but cmux-owned package `.gitignore` files must not ignore `Package.resolved`. CI's `python3 scripts/check-package-resolved-policy.py` fails if this drifts.
-**Submodule safety** (`cmux-ghostty`): push the submodule commit to its remote `main` before committing the pointer in the parent repo. Never commit on a detached HEAD. Verify with `git merge-base --is-ancestor HEAD origin/main`.
- **Localize every user-facing string** (`cmux-localization`): `String(localized:)` with keys in `Resources/Localizable.xcstrings`, plus every web message catalog (`web/messages/en.json`, `web/messages/ja.json`). A localization audit is required for any UI, Settings, menu, schema, docs, or help-text change, and the handoff must state what was audited.
- **Shortcut policy** (`cmux-keyboard-shortcuts`): every new cmux-owned shortcut goes in `KeyboardShortcutSettings`, is editable in Settings, is supported in `~/.config/cmux/cmux.json`, and is documented.
- **Test wiring** (`cmux-testing`): a `.swift` file in `cmuxTests/` without a `PBXFileReference` + `PBXSourcesBuildPhase` entry is silently skipped, and both `xcodebuild test` and bot reviews pass with "Executed 0 tests". `workflow-guard-tests` runs `./scripts/lint-pbxproj-test-wiring.sh` to catch it.
- **SPM package groups** (`cmux-architecture`): packages live under `Packages/{Shared,iOS,macOS}/<pkg>` and the workspace mirrors that folder shape. To move one, `git mv` the directory then `python3 scripts/check-workspace-package-groups.py --write`. Never hand-edit workspace group membership.
- **Do not gitignore cmux-owned `Package.resolved`.** SwiftPM resolution changes must show in PR diffs; package-local lockfiles are not replaced by the root one. `python3 scripts/check-package-resolved-policy.py` fails on drift.
- **"Feature flag" means a remote PostHog runtime flag.** Implement through `CmuxFeatureFlags` with a PostHog key, explicit unavailable fallback, registry metadata, live update behavior, and focused tests. A local override may support dogfood but must not be the production control plane.
- **Foundation, SwiftUI, AttributeGraph, and WebKit semantics change between macOS major versions.** `URL(fileURLWithPath: "/").deletingLastPathComponent().path` returns `"/.."` on macOS 14 and 15 but `"/"` on macOS 26 (https://github.com/manaflow-ai/cmux/issues/4529); CI and maintainer machines were all on the fixed side while every reporter was on the broken side. Test on the reporter's macOS before declaring a repro disproven. AWS M4 Pro builders (`aws-m4pro-1..6`) run macOS 15.7.4.
## Ghostty submodule workflow
## Shared behavior policy
Ghostty changes must be committed in the `ghostty` submodule and pushed to the `manaflow-ai/ghostty` fork.
Keep `docs/ghostty-fork.md` up to date with any fork changes and conflict notes.
When a behavior is exposed through multiple entrypoints (shortcut, command palette, context menu, CLI, settings, debug menu), implement one shared action path and verify every entrypoint. Do not patch one surface and leave the others with duplicated logic.
For optimistic UI or CLI updates, keep one mutation path, record pending state with a request id or previous snapshot, reconcile from the authoritative result, and roll back explicitly on failure. Do not let each entrypoint keep its own optimistic copy.
To keep the fork up to date with upstream:
```bash
cd ghostty
git fetch origin
git checkout main
git merge origin/main
git push manaflow main
```
Then update the parent repo with the new submodule SHA:
```bash
cd ..
git add ghostty
git commit -m "Update ghostty submodule"
```
## Release
Use the `/release` command to prepare a new release. This will:
1. Determine the new version (bumps minor by default)
2. Gather commits since the last tag and update the changelog
3. Update `CHANGELOG.md` (the docs changelog page at `web/app/docs/changelog/page.tsx` reads from it)
4. Run `./scripts/bump-version.sh` to update both versions
5. Commit, run `./scripts/release-pretag-guard.sh`, tag, and push
Version bumping:
```bash
./scripts/bump-version.sh # bump minor (0.15.0 → 0.16.0)
./scripts/bump-version.sh major # bump major (0.15.0 → 1.0.0)
./scripts/bump-version.sh 1.0.0 # set specific version
```
This updates both `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` (build number). The build number is auto-incremented and is required for Sparkle auto-update to work.
Before creating a release tag, run:
```bash
./scripts/release-pretag-guard.sh
```
If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, then retry tagging.
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"]
@@ -426,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.