Compare commits

...
Author SHA1 Message Date
austinpower1258 1943a80b7a cmuxTests: align headless CLI lifecycle expectations 2026-08-04 03:50:33 -07:00
austinpower1258 34ad688380 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:42:25 -07:00
EJandejc3 0cc8445541 Open a browser at the end of the tab strip, not one slot short (#8705)
`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]>
2026-08-04 03:38:33 -07:00
Austin Wang 6d49edf927 Merge pull request #9576 from manaflow-ai/browser-headless-fixes
browser: integrate headless suite fixes against current main
2026-08-04 03:12:21 -07:00
austinpower1258 7ac4a2520a Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:06:30 -07:00
austinpower1258 6639a2bd75 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 03:02:55 -07:00
Austin Wang c7b47c3e93 web: expose changelog versions to agent page variants (#9579) 2026-08-04 03:02:45 -07:00
8b8b1b0b87 Give the PR refresh run-loop test something real to observe (#8724)
* 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]>
2026-08-04 02:53:53 -07:00
Abdulaziz Albahar f52578acb9 Preserve Iroh sessions across relay policy refresh (#9538)
* test: prove relay policy refresh mutates once

* fix: preserve sessions across relay policy refresh

* fix: clean up failed relay policy activation
2026-08-04 04:53:46 -05:00
b959519136 cmuxTests: stop the shortcut routing suite from taking the test host down (#8635)
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]>
2026-08-04 02:47:40 -07:00
austinpower1258 fcc476c9ca Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 02:47:04 -07:00
EJandejc3 e4bd9695d1 Show git status in the file explorer for repos reached through a symlink (#8577)
* 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]>
2026-08-04 02:44:50 -07:00
64f7726b4f tests: stop the portal first-reveal fixture from killing the test host (#8689)
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]>
2026-08-04 02:43:37 -07:00
EJandejc3 85fe23c44e CmuxAuthRuntime: wake the sign-in test waits on an event (#8644)
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]>
2026-08-04 02:41:00 -07:00
austinpower1258 516ac4b4cd Merge remote-tracking branch 'origin/main' into browser-headless-fixes 2026-08-04 02:39:39 -07:00
austinpower1258 370a3ff944 cmuxTests: match mock socket thread QoS to waiters 2026-08-04 02:39:03 -07:00
austinpower1258 cb2506ab61 tests: fix omnibar overlay accumulator shadowing 2026-08-04 02:38:36 -07:00
27da2328b3 Default test-process windows to releasedWhenClosed = false (rebase of #7768) (#8832)
* 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]>
2026-08-04 02:35:48 -07:00
a77df5a35e cmuxTests: unbreak the build and re-sync the remote-tmux reorder/targeting suites (#8427)
* 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]>
2026-08-04 02:33:48 -07:00
EJandejc3 7081a23261 tests: update the remote-tmux resolver assertion to the shared builder's argv (#8550)
* 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]>
2026-08-04 02:31:54 -07:00
EJandejc3 07dd5a1cd9 A fake WKNavigation was killing the test host, hiding a whole suite (#8633)
* 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]>
2026-08-04 02:29:17 -07:00
EJandejc3 a76deb63e0 Close only the workspaces a tab manager actually owns (#8753)
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]>
2026-08-04 02:26:45 -07:00
austinpower1258 5d25a5dd35 tests: harden browser headless regressions 2026-08-04 02:01:38 -07:00
Lawrence Chen 3a8705467a Add per-version changelog pages (#9543)
* Add per-version changelog pages

* Localize changelog version pages

* Inject changelog storage

* Fix nested docs pager matching

* Fix Italian changelog labels

* Prerender changelog pages

* Handle unmatched docs pager paths
2026-08-04 01:56:59 -07:00
EJandejc3 d3eeacf0b3 CMUXProjectModel: find the worktree root instead of counting directories (#8641)
* 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]>
2026-08-04 01:56:14 -07:00
EJandejc3 8c9ee247a0 file-explorer: drop cancelled loads before they list a stale path (#8595)
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]>
2026-08-04 01:46:33 -07:00
austinpower1258 9d4bf9a990 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:40:38 -07:00
EJandejc3 0a68bbee35 remote-tmux: convert CLI tab-reorder final position to a bonsplit insertion gap (#8600)
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]>
2026-08-04 01:40:04 -07:00
EJandejc3 91df18db5c tests: stop three fixtures from killing the test host on window close (#8701)
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]>
2026-08-04 01:38:15 -07:00
austinpower1258 cd643fbcfb Merge remote-tracking branch 'origin/main' into browser-headless-fixes 2026-08-04 01:36:39 -07:00
EJandejc3 007fe3527a Pin four unread session-restore tests to the model the product implements (#8798)
* 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]>
2026-08-04 01:35:21 -07:00
austinpower1258 fe88fd0ae1 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:34:19 -07:00
EJandejc3 71fb52ce63 remote-tmux: parse a session list that arrives with CRLF line endings (#8704)
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]>
2026-08-04 01:33:27 -07:00
austinpower1258 c4c171bec9 cmuxTests: adapt current SSH host test to shared loop 2026-08-04 01:31:46 -07:00
Austin Wang 7c715a4123 Exercise stale inherited surface after cached font lineage (#9571) 2026-08-04 01:31:28 -07:00
EJandejc3 2ed7358e92 cmuxTests: stop a blocking wait helper from shadowing the pumping ones (#8725)
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]>
2026-08-04 01:31:02 -07:00
EJandejc3 af84599869 cmuxTests: expect composited terminal colours, and the menu flag the grouping test needs (#8509)
* 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]>
2026-08-04 01:27:42 -07:00
EJandejc3 445db7fe9d Skip an inherited terminal surface the registry no longer owns (#8656)
* 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]>
2026-08-04 01:27:39 -07:00
EJandejc3 197ede721e tests: build the file-preview text view the way the product does (#8719)
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]>
2026-08-04 01:26:39 -07:00
EJandejc3 202908a278 Reject non-canonical (leading-zero) IPv4 spellings when classifying Tailscale peers (#8572)
* 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]>
2026-08-04 01:25:12 -07:00
EJandejc3 523783ef6a command palette: index a branch short name as one searchable token (#8578)
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]>
2026-08-04 01:22:37 -07:00
austinpower1258 2f48ef1063 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:16:36 -07:00
Ruixin Huang b46dcb71c1 fix: recover ssh-tmux sizing after peer detach (#9530)
* test: cover ssh-tmux peer detach sizing recovery

* fix: replay ssh-tmux sizes after peer detach

* test: cover malformed detach diagnostics

* fix: sanitize ssh-tmux detach handling
2026-08-04 01:16:07 -07:00
austinpower1258 8eff531da8 cmuxTests: adapt current CLI socket tests to shared loop 2026-08-04 01:10:37 -07:00
EJandejc3 ee23ff9abf Fix four unsatisfiable tests in the sidebar git suites (#8723)
* 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]>
2026-08-04 01:07:53 -07:00
EJandejc3 70481686e9 CmuxNotifications: unbreak the test target so its 66 tests run (#8642)
`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]>
2026-08-04 01:05:09 -07:00
austinpower1258 64dc135ec3 Merge remote-tracking branch 'origin/main' into cli-headless-fixes 2026-08-04 01:04:44 -07:00
Lawrence Chen 1cee402d7d Stabilize hosted tenant capability payload (#9556) 2026-08-04 00:51:00 -07:00
Abdulaziz AlbaharandClaude Fable 5 bcfc0b5028 Make the mobile browser stream self-healing (#9498)
* 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]>
2026-08-04 02:38:55 -05:00
Lawrence Chen 62f52ccff8 Fix Pi fork probes without slowing Pi hooks (#9549)
* test: cover Pi fork probe launch PATH

* fix: preserve Pi launch PATH for fork probes

* test: cover Pi extension hot-path work

* perf: keep Pi hook callbacks lightweight
2026-08-04 00:24:17 -07:00
Lawrence Chen 7dcb2bfb78 Restart the last terminal when quit is cancelled (#9492)
* 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
2026-08-03 23:45:54 -07:00
Lawrence Chen 622d2f7cb3 Move hosted Subrouter onboarding to Stack Auth (#9261)
* test: cover hosted Subrouter web flows

* feat: use Stack Auth for hosted Subrouter

* test: cover hosted auth fail-closed behavior

* test: preserve hosted account health

* test: keep hosted auth mock type-safe

* fix: harden hosted auth configuration

* test: keep CLI auth errors provider-neutral

* fix: use provider-neutral CLI auth errors

* test: publish canonical Subrouter hostname

* fix: publish sr.cmux.com to CLI clients

* test: keep CLI auth on issuing origin

* fix: complete CLI auth on issuing origin

* test: redact hosted account health details

* fix: redact hosted account health details

* test: isolate CLI config environment

* test: keep tenant credentials out of URLs

* fix: authorize hosted tenant requests by header

* test: require hosted tenant retirement on account deletion

* fix: retire hosted tenants before account deletion

* test: require trusted tenant retirement credential

* fix: authenticate hosted tenant retirement service

* test: configure hosted deletion credential

* test: preserve hosted rollout compatibility

* fix: preserve hosted rollout compatibility

* test: preserve shipped subrouter clients

* fix: preserve shipped subrouter clients

* test: protect subrouter credential responses

* fix: harden subrouter compatibility responses

* test: preserve hosted account metadata

* fix: preserve hosted account metadata

* test: keep hosted deletion retryable

* fix: keep hosted tenant cleanup retryable

* test: preserve hosted protocol failures

* fix: preserve hosted protocol semantics

* test: require legacy tenant cutover safety

* fix: migrate and retire legacy tenants safely

* test: bind hosted credentials to deployment config

* fix: bind hosted credentials to team config

* test: gate hosted tenant cutover errors

* fix: gate hosted tenant cutover safely

* fix: persist hosted cutover readiness

* test: close hosted cutover gaps

* fix: close hosted cutover gaps

* test: require resumable tenant finalization

* fix: make tenant finalization resumable

* test: cover large web test discovery

* fix: avoid web test discovery deadlock

* ci: pin current GhosttyKit artifact

* test: broker native hosted tenant exchange

* fix: broker scoped hosted tenant credentials

* style: remove trailing blank lines

* test: require secure exact hosted exchange

* fix: validate hosted exchange boundaries

* test: preserve dashboard recovery states

* fix: bound dashboard auth recovery

* test: preserve unconfigured service status

* fix: preserve unconfigured service response

* test: fail closed on hosted cleanup outages

* fix: fail closed on hosted cleanup uncertainty

* test: checkpoint hosted tenant deletion

* fix: checkpoint hosted tenant deletion

* test: bound account deletion token refresh

* fix: bound account deletion auth refresh

* test: keep hosted deletion retries visible

* fix: serialize visible deletion retries

* test: pin legacy migration source to target

* fix: bind legacy migration source to target

* test: cover account deletion without hosted Subrouter

* fix: gate hosted cleanup by deployment state

* test: keep hosted deletion checkpoint owned in flight

* fix: serialize hosted deletion checkpoint ownership

* test: checkpoint bounded legacy tenant retirement

* fix: bound legacy tenant retirement during deletion
2026-08-03 23:39:21 -07:00
Abdulaziz Albahar 72fce2fae8 Make Iroh transport diagnostics human-readable (#9485)
* test: require readable Iroh transport diagnostics

* fix: make Iroh transport diagnostics readable

* fix: support pinned Sentry breadcrumb API

* fix: satisfy diagnostic formatter ownership rules

* test: cover localized diagnostic reports

* fix: close diagnostic reporting merge blockers

* fix: honor explicit diagnostic locales

* test: gate compiled catalog locale checks

* fix: finalize diagnostic localization coverage

* test: require valid UTF-8 diagnostic reports
2026-08-04 01:38:50 -05:00
Austin Wangandcmux reload-cloud a9c351be97 Fix Google Sheets browser identity replay loop (#9482)
* test: reproduce Google Sheets identity replay

* fix: make browser identity replay idempotent

* test: reject stale Sheets identity fallback

* revert: remove stale browser identity fallback

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-08-03 22:28:58 -07:00
Abdulaziz Albahar d953ceda39 Match workspace group pin tint (#9519)
* Add regression test for group pin tint

* Match workspace group pin tint
2026-08-04 00:12:45 -05:00
Abdulaziz Albahar 6f99395b78 Focus Mac pairing QR flow on Tailscale (#9493)
* 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
2026-08-03 23:08:42 -05:00
Abdulaziz AlbaharandClaude Fable 5 87edd70966 iOS: remove redundant Switch Computer settings screen (#9490)
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]>
2026-08-03 22:52:28 -05:00
Abdulaziz AlbaharandClaude Fable 5 ed44c7daf4 Add model selection lab to the New Task composer (#8800)
* 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]>
2026-08-03 22:25:17 -05:00
Tanghui Lin f4cd2774de Fix infinite UA-policy restart loop on Google Sheets destinations (#9483)
Fixes #9462
2026-08-03 20:05:09 -07:00
Abdulaziz Albahar 840f8c074f Fetch complete Iroh discovery before Mac host activation (#9478)
* test(iroh): cover incomplete host registration discovery

* fix(iroh): fetch complete discovery for host activation
2026-08-03 21:52:13 -05:00
Abdulaziz Albahar 145b60e893 Fix iOS keyboard focus ownership after photo picker (#9371)
* 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
2026-08-03 19:51:55 -07:00
Austin Wang 37a4d212ab Translate workspace group anchor guidance across locales (#9480)
* 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
2026-08-03 19:49:44 -07:00
Austin Wang 7693b19065 Move mobile telemetry consent into CMUXMobileCore (#9505)
* Test consent provider in CMUXMobileCore

* Move mobile telemetry consent into core
2026-08-03 19:46:44 -07:00
Austin Wang c37ea7a31f Scope shortcut settings notifications to their config file (#9502)
* Test host shortcut notification source identity

* Scope host shortcut notifications to config file
2026-08-03 18:59:30 -07:00
Austin Wang 972084ddb3 Serve cached social preview images without redirects (#9503)
* Add social image delivery regression test

* Serve cached social image URLs directly
2026-08-03 18:58:02 -07:00
Austin Wang 9decec5213 Stop blank Ghostty opener stderr log bursts (#9486)
* test: reproduce Ghostty opener stderr log burst

* Fix empty Ghostty opener stderr log bursts
2026-08-03 18:31:32 -07:00
Austin WangandClaude Opus 5 20390187fd Skip directories when resolving provider executables on PATH (#9476)
* 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]>
2026-08-03 18:28:39 -07:00
Austin Wang 9d7cc488b8 Reject unknown flags for surface resume set (#9477)
* Add regression test for resume set flag validation

* Reject unknown surface resume set flags

* Fix surface resume flag regression test
2026-08-03 18:26:56 -07:00
Lawrence Chen 97f4a5d6a3 Add Pi landing page and agent SEO (#9455)
* 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
2026-08-03 17:41:15 -07:00
Abdulaziz Albahar 490b45503b Require connected iOS dogfood launches (#9252)
* test: cover disconnected iOS dogfood launch

* fix: require connected iOS dogfood launches

* fix: make mobile readiness event driven

* fix: harden mobile readiness lifecycle

* perf: buffer deadline event reads

* test: remove source-shape admission assertion

* test: cover cached host binding publication

* fix: publish cached mobile host binding

* test: cover usable mobile session readiness

* fix: require usable mobile connection readiness

* test: cover injected attach admission ownership

* fix: start injected attach at connection owner

* test: require active Iroh route publication

* fix: publish Iroh route only after activation

* fix: compile weak dictation request capture

* test: align route readiness fixtures with connectivity v2

* chore: expose safe Iroh activation failure type

* test: reject mobile session closed during revalidation

* fix: require stable mobile admission before handoff

* test: reject foreground and control dial overlap

* fix: reserve foreground mobile connection routes

* test: reproduce registry churn disconnect

* test: preserve policy during registry churn

* fix: preserve mobile connectivity during registry churn

* test: preserve active iroh session during candidate admission

* fix: promote mobile sessions only after readiness

* test: reproduce saturated mobile reconnect

* fix: reserve reconnect admission until session readiness

* fix: preserve strict single-session capacity

* test: reproduce relay refresh disconnect

* fix: preserve authorized sessions across route refresh

* test: reproduce paginated host registration wedge

* fix: recover host registration across discovery pages

* test: reproduce orphaned iPhone build process

* fix: terminate iOS app before bundle replacement

* fix: preserve usable-session promotion after main merge

* fix: preserve secondary Mac route owner after merge

* fix(ios): let Ghostty render the cursor

* Pin current GhosttyKit checksum
2026-08-03 19:32:33 -05:00
Lawrence Chen cec7ac3fa7 Suppress Pi notifications after interrupted turns (#9451)
* test: cover silent Pi turn interruption

* fix: suppress Pi notifications after interruption

* perf: keep Pi completion inspection single-pass
2026-08-03 17:22:53 -07:00
Austin Wang d35187ec47 Pin GhosttyKit checksum for iOS startup fix (#9487) 2026-08-03 16:39:35 -07:00
Abdulaziz Albahar 2141de5722 Fix middle tab drag reordering
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.
2026-08-03 18:35:45 -05:00
Abdulaziz AlbaharandClaude Fable 5 4adc8e519a iOS: diff viewer scroll momentum survives finger lift (#9257)
* 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]>
2026-08-03 18:25:50 -05:00
Abdulaziz Albahar a2d28ba765 Keep Mobile Connect available in the command palette (#9467)
* test: cover mobile connect palette availability

* fix: keep mobile connect in command palette
2026-08-03 18:15:57 -05:00
Austin WangandClaude Opus 5 e733aa4954 Pass a valid empty MCP configuration to the auto-naming summarizer (#9473)
* 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]>
2026-08-03 15:42:32 -07:00
Austin WangandClaude Opus 5 16dbad16e4 Fix Package.resolved policy false positive on leaf local-path packages (#9470)
* 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]>
2026-08-03 15:38:39 -07:00
Austin WangandClaude Opus 5 ea8c7a6fb8 Retract recovered daemon transport errors from the workspace sidebar (#9472)
* 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]>
2026-08-03 15:32:41 -07:00
Abdulaziz Albahar be6516f704 Preserve iOS workspace groups during reconnect
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.
2026-08-03 17:21:05 -05:00
Austin Wang 8cc5dc4a6e Treat "no update available" as a success in Attempt Update (#9435)
* 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.
2026-08-03 14:11:11 -07:00
Austin Wang 004d414746 Rename the focused workspace group with Cmd+Shift+R (#9428)
* 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.
2026-08-03 14:10:06 -07:00
Abdulaziz Albahar f4787432f2 Fix INTERNAL iOS Ghostty startup crash
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.
2026-08-03 15:46:37 -05:00
Abdulaziz AlbaharandClaude Fable 5 eee859d354 Harden route-content equivalence against reorders, missing baselines, and install races (#9402)
* 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]>
2026-08-03 14:59:42 -05:00
Lawrence Chen 249d0ff799 Include Pi session titles in notifications (#9452)
Include the target Pi surface title in notification titles while preserving the Pi fallback and leaving other agents unchanged.
2026-08-03 05:37:22 -07:00
Lawrence Chen 6edf2570b8 Publish the Rust SDK as cmux-sdk (#9445)
* Rename Rust SDK package to cmux-sdk

* Preserve Python release artifact digests

* Make crate bootstrap recovery independent

* Pin crate bootstrap tests to build job

* Stop crate bootstrap publication on cancellation
2026-08-03 03:31:33 -07:00
1927f130f6 Make iOS workspace groups and reconnect dogfood-ready (#9326)
* 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]>
2026-08-03 03:15:09 -05:00
Lawrence Chen 053ba0291c Publish four SDKs without taking over cmux CLI packages (#9376)
* 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
2026-08-03 00:18:11 -07:00
Austin Wang ddd4a01bc5 Bump version to 0.64.22 (#9442) 2026-08-03 00:14:42 -07:00
Austin Wang 67d4fc12e1 Merge pull request #9436 from manaflow-ai/issue-9431-intel-sentry-init-crash
Disable Ghostty native Sentry in embedded builds
2026-08-02 23:06:45 -07:00
austinpower1258 b0b96e7b34 Fix Swift Testing diagnostic type 2026-08-02 22:53:38 -07:00
Austin Wang 42d4f04126 Merge pull request #9422 from manaflow-ai/fix-close-surface-nonexistent-ref-fallthrough
Fail closed for stale destructive surface targets
2026-08-02 22:42:52 -07:00
austinpower1258 63c8c28288 fix: complete explicit surface review coverage 2026-08-02 22:21:24 -07:00
austinpower1258 1d48844494 Disable Ghostty native Sentry in cmux builds 2026-08-02 22:01:52 -07:00
Abdulaziz Albahar 06bc29603c Fail iOS workflow when selected filter runs zero tests (#9404)
* Test selected iOS execution guard

* Fail selected iOS runs that execute zero tests

* Test selected-test diagnostic safety

* Sanitize selected-test diagnostics

* Tighten selected-test log classification
2026-08-02 23:52:21 -05:00
austinpower1258 2d709e87b7 Test embedded GhosttyKit excludes native Sentry 2026-08-02 21:46:21 -07:00
austinpower1258 5e35ff0c4a fix: harden explicit destructive surface targeting 2026-08-02 21:16:31 -07:00
Austin Wang 84f5755b56 Fix cmux ssh startup script syntax error in no-progress retry loop (#9425)
* 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
2026-08-02 21:11:17 -07:00
Austin Wang 7dff5ec471 Clear Dock notifications when focused (#9418)
* test: cover Dock notification dismissal on focus

* fix: dismiss Dock notifications on focus

* fix: preserve focus history host conformance
2026-08-02 19:53:16 -07:00
Austin WangandClaude Opus 5 4de871173e Preserve CLAUDE_SECURESTORAGE_CONFIG_DIR across agent restore (#9419)
* 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]>
2026-08-02 19:17:28 -07:00
Austin WangandClaude Opus 5 b4c2163a37 Fix noclobber 'cannot overwrite existing file' error from bash shell integration (#9420)
* 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]>
2026-08-02 19:16:26 -07:00
austinpower1258 bd89d1c16c fix: fail closed for stale destructive surface targets 2026-08-02 19:09:51 -07:00
austinpower1258 786a077bc3 test: cover stale destructive surface targets 2026-08-02 19:09:36 -07:00
Austin Wang 33ac210ab4 Bump version to 0.64.21 (#9414) 2026-08-02 17:24:10 -07:00
Austin Wang ff3b4aa3cd Fix registry kind test fixture compilation (#9413)
* test: fix registry kind fixture compilation

* test: respect agent cwd policy in kind matrix
2026-08-02 15:06:45 -07:00
Austin Wang e7ca40e6e1 Preserve Pi resume identity across repeated restores (#9399)
* test: cover repeated Pi restore identity

* fix: preserve Pi identity across repeated restores
2026-08-02 14:52:07 -07:00
Austin Wang 9fc3212e72 Fix Kimi restore-of-restore binding kind decoding (#9397)
* Add restore binding kind regression tests

* Fix registry-owned restore binding kind decoding
2026-08-02 14:05:17 -07:00
Lawrence Chen afe629534f Keep sidebar icon until avatar URL exists (#9386)
* test: require avatar URL before photo mode

* fix: keep sidebar icon until avatar URL exists
2026-08-02 01:00:36 -07:00
Lawrence Chen e49777b6c1 Harden remote daemon transports and workspace RPC (#9390)
* Add remote daemon security regressions

* test(cmux-tui): reject multiline Cargo paths

* fix(cmux-tui): harden package build metadata

* fix(remote): harden workspace file mutations

* Harden remote network admission

* Harden remote runtime state handling

* fix(remote): serialize identity persistence safely

* fix(remote): persist one logical connection attempt

* Harden remote CLI secret handling

* Silence release-only remote CLI warning

* Fix remote CLI test lint

* docs(cmux-tui): remove unsafe credential examples

* test(cmux-tui): expose transient approval authorization

* fix(cmux-tui): close remote review blockers

* test(cmux-tui): expose unbounded admin request read

* fix(remote): bound diagnostics and lifecycle cleanup

* test(remote): expose admin frame boundary mismatch

* fix(remote): align relay MSRV and admin framing

* test(remote): expose replayed workspace cursors

* fix(remote): retain workspace query continuations

* test(remote): expose identity and socket durability gaps

* Harden client socket and trust persistence

* Add regressions for remote review findings

* Fix remote review findings

* test(remote): expose intermediate symlink traversal

* Harden remote directory creation against symlinks

* test(remote): expose authorization commit gaps

* Fix committed identity state and relay ticket expiry

* test(remote): expose blocking Iroh secret reads

* Harden persisted Iroh secret reads

* test(remote): expose mux reassembly budget release

* Retain ingress budgets through mux reassembly

* test(remote): specify owned client socket handoff

* Own client socket cleanup through bridge shutdown

* test(remote): expose final review races

* Fix final remote review races

* test(remote): expose cross-process ownership gaps

* Fix cross-process remote ownership races

* test(remote): expose final lifecycle leaks

* Bound remote startup and dropped request cleanup

* test(remote): expose shared auth state race

* Fix shared authorization state ownership

* test(remote): expose shutdown state lease gap

* Retain auth lease through blocking writes

* test(remote): expose daemon handoff contention

* Retry authorization state during daemon handoff

* test(remote): expose queued auth loss on exit

* fix(remote): drain auth persistence on shutdown

* test(remote): expose shutdown ownership races

* fix(remote): preserve daemon shutdown ownership

* test(remote): expose shutdown queue and hook races

* fix(remote): coalesce auth persistence snapshots

* test(remote): expose auth finalization gaps

* fix(remote): finalize auth before lifecycle cleanup

* test(remote): isolate concurrent cleanup pauses

* test(remote): expose stale metadata after auth failure

* fix(remote): clear lifecycle metadata after auth failure

* test(remote): expose legacy sidecar handoff race

* fix(remote): fence legacy sidecar process exit

* test(remote): satisfy cleanup clippy gate

* test(remote): expose failed finalization handoff

* test(remote): expose unavailable pidfd upgrade

* fix(remote): fall back when pidfd is unavailable

* fix(remote): authenticate shutdown finalization

* test(remote): expose unsafe shutdown recovery

* fix(remote): bind shutdown to daemon lifecycle

* test(remote): expose stale shutdown evidence

* fix(remote): close shutdown evidence gaps

* test(remote): expose unclean shutdown recovery

* fix(remote): recover unclean daemon shutdowns

* test(remote): expose unfenced legacy restart

* fix(remote): fence legacy automatic restarts

* test(remote): expose lifecycle fence dead ends

* fix(remote): make lifecycle fencing recoverable

* test(remote): expose rollback and malformed runtime gaps

* fix(remote): fence authorization state across rollbacks

* test(remote): expose unconfirmed auth rollback fence

* fix(remote): reconfirm auth fence durability

* test(remote): expose preflight and fence durability gaps

* fix(remote): preflight recovery before auth mutation

* test(remote): expose lifecycle startup retry gaps

* fix(remote): make fenced startup retries durable

* test(remote): expose active lifecycle durability gaps

* fix(remote): durably own active daemon lifecycle

* test(remote): expose unlocalized recovery guidance

* fix(remote): localize recovery guidance

* test(remote): expose final lifecycle review gaps

* fix(remote): fence authorization before lifecycle state

* test(remote): cover review regressions

* fix(remote): pin workspace operations to descriptors

* fix(tui): use shared pty child abstraction

* test(remote): expose delayed enrollment timeout

* fix(remote): preserve invitation approval window

* test(remote): cover replaced workspace roots

* fix(remote): preserve pinned workspace identity

* fix(tui): call renameat2 through the Linux syscall

* test(remote): cover pinned query and approval windows

* fix(remote): preserve pinned query and approval windows

* test(remote): cover resume expiry task lifecycle

* fix(remote): cancel obsolete resume expiry tasks

* test(remote): cover background task shutdown

* fix(remote): bound background task lifetimes

* fix(sdks): preserve Rust 1.88 support

* test(remote): cover transient Unix dial failures

* fix(remote): retry transient Unix dial failures

* test(remote): cover terminal reconnect failures

* fix(remote): retry only carrier failures

* test(remote): cover review regressions

* fix(remote): close reviewed lifecycle gaps

* Clarify relay routing key in TUI help

* Keep terminal provider failures out of reconnect

* test(remote): match control-character build diagnostic

* fix(relay): bound Durable Object outbound queues

* test(tui): expose acknowledged stream close race

* fix(tui): preserve completed Go stream opens

* test(tui): cover carrier and Git environment isolation

* test(tui): cap authenticated Iroh carrier fixture

* fix(tui): bound carriers and isolate Git RPCs

* test(tui): cover workspace HTTP raw admission

* fix(tui): admit workspace HTTP before parsing

* test(tui): cover Unix accept recovery and daemon locale

* fix(tui): recover Unix listeners and localize daemon output

* test(tui): cover autoreview regressions

* fix(tui): close autoreview regressions

* test(tui): cover final remote daemon review findings

* fix(tui): bound final remote daemon resources

* fix(tui): close final autoreview findings

* test(remote): preserve pagination cursor after deadline

* fix(remote): commit pagination cursors after delivery

* fix(remote): acknowledge delivered pagination pages

* test(remote): cover final transport review regressions

* fix(remote): close final transport review findings

* fix(tui): preserve Rust 1.91 SQLite compatibility

* test(remote): cover custom recovery socket selection

* fix(remote): close final latency and recovery findings

* test(remote): cover final socket hardening regressions

* fix(remote): preserve socket directory ownership boundaries

* test(remote): distinguish managed and caller-owned directories

* fix(remote): separate managed and caller-owned directories

* test(remote): cover final autoreview findings

* fix(remote): close final autoreview findings

* test(pty): bound hardened descriptor fallback

* fix(pty): fail fast on oversized fallback scans
2026-08-01 19:29:55 -07:00
Abdulaziz AlbaharandClaude Fable 5 175127ea59 Preserve live peer sessions across equivalent route revision bumps (#9342)
* 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]>
2026-08-01 19:41:59 -05:00
Austin Wang 2f4059bd32 Keep explicit agent restore records across shell preexec (#9391)
* test: cover restore binding across shell preexec

* fix: retain manual agent restore bindings

* test: cover Grok restore generations and Dock replacement

* fix: preserve replacement Dock resume bindings

* fix: construct Vault presenter on main actor
2026-08-01 15:00:57 -07:00
Austin Wang 6f79836228 Fix Vault popover crashes in recycled rows (#9392)
* test: keep Vault presentation out of hosted row content

* fix: own Vault popovers outside recycled rows

* fix: harden Vault popover table lifecycle
2026-08-01 14:42:01 -07:00
Austin Wang 6d47bf38c3 Resolve restore targets from live process identity (#9384)
* test: cover stale surface restore routing

* Prefer live terminal routing for restore

* test: reject ambiguous restore TTY routing

* Reject ambiguous TTY restore routing

* test: cover live restore target resolution

* Resolve restore target from live process

* test: cover authoritative restore routing failure

* Fail closed on missing live restore target

* test: cover restored TTY registration race

* Wait for fresh restore TTY registration

* test: scope relay restore TTY routing

* test: constrain relay TTY resolution

* test: preserve relay restore workspace aliases

* Scope relay restore to authenticated terminal

* test: cover live and Dock TTY routing

* fix: complete live TTY restore routing

* test: cover ended and cached TTY routing

* fix: retire stale TTY lifecycle evidence

* test: cover fail-closed restore and reconnect routing

* fix: make restore routing readiness authoritative

* test: cover transferred and persistent TTY routing

* fix: preserve live TTY proof across bridge retries

* test: cover relay restore after new workspace move

* fix: preserve relay routing across workspace moves

* test: cover relay ownership after surface moves

* fix: keep relay provenance scoped through moves

* test: cover stale relay TTY lifecycle

* fix: retire relay TTY provenance on terminal end

* test: cover durable relay identity after moves

* fix: keep relay identity durable across moves

* test: cover authoritative TTY trust boundaries

* fix: bind TTY reports to terminal runtime

* test: cover Grok restore routing

* test: cover relay TTY readiness gaps

* fix: wait for relay TTY readiness
2026-08-01 13:42:40 -07:00
Lawrence Chenandaustinpower1258 80f40831da cmux-tui: render inline Kitty images through libghostty (#8811)
* test: cover kitty placement frame reuse

* fix: cache kitty placement frames

* test: cover pixel-accurate kitty clipping

* fix: clip kitty placements in pixel space

* test: cover pixel-accurate kitty replay clipping

* fix: clip kitty replay in pixel space

* test: cover number-only kitty image attach

* test: cover both numbered kitty image aliases

* fix: preserve kitty number aliases across attach

* test: cover inflight kitty replay across resize

* fix: preserve inflight kitty replay

* test: cover anonymous kitty replay collisions

* fix: preserve anonymous kitty placements in replay

* test: cover kitty object count limits

* fix: bound kitty graphics object counts

* test: cover Kitty graphics in web render mode

* test: cover host kitty scene invalidation

* fix: restore kitty graphics after host resize

* feat: render Kitty graphics in web terminal

* test: cover graphics writer shutdown quiescence

* fix: layer Kitty graphics above cell backgrounds

* fix: draw web graphics from callback ref

* fix: quiesce graphics before terminal restore

* test: cover kitty replay allocation order

* fix: preserve kitty replay allocation order

* test: cover incremental render graphics deltas

* test(tui): cover linear graphics state maintenance

* fix: send incremental render graphics deltas

* test: cover bounded kitty replay semantics

* test(tui): cover late Kitty image ordering

* test: cover render transport size boundaries

* fix(tui): maintain Kitty graphic IDs linearly

* test: cover atomic cell geometry updates

* test(tui): cover Kitty PNG compatibility

* test: cover full render metadata budget

* test: preserve measured cell pixels across resize

* test: bound kitty pixel cache lookups

* fix: make cell geometry updates atomic

* test: bound kitty placement grouping

* fix: align render transport size budgets

* test: bound render taps and resize replay

* fix: preserve kitty images in bounded vt replay

* fix: bound render taps and skip unused replay

* docs(tui): document inline Kitty image support

* style(tui): format merged changes

* test: cover UTF-8 before kitty replay

* test: cover large kitty resize replay

* fix: distinguish UTF-8 from C1 kitty APC

* fix: preserve kitty upload across resize

* test(tui-sdk): cover retained render metadata overflow

* fix(tui-sdk): bound retained render events

* test(tui-web): expose placement canvas memory blowup

* fix(tui-web): bound graphic canvas backing

* test: preserve hosted Kitty image aliases

* fix: preserve hosted Kitty image aliases

* test(tui): cover stale Kitty write after resize clear

* fix(tui): discard stale Kitty writes after resize clear

* test(browser): expose terminal host alias protocol gap

* fix(browser): support terminal host Kitty aliases

* test(cmux-tui): expect resize alias sidecars

* test(tui): preserve sparse viewport across replay

* fix(tui): preserve sparse rows in terminal replay

* fix(tui): align replayed scrollback rows

* test(tui): await terminal host process exit

* test(tui): cover Kitty alias history and sparse replay

* fix(tui): preserve Kitty alias and sparse row history

* fix(tui): address Kitty graphics review findings

* fix(tui): harden Kitty graphics integration

* fix(tui): resolve final Kitty autoreview findings

* fix(tui): close Kitty autoreview findings

* test(tui): cover final Kitty review regressions

* fix(tui): close final Kitty autoreview findings

* test(tui): reject overflowing PTY pixel geometry

* fix(tui): reject invalid PTY pixel geometry

* test(tui): cover remaining Kitty review regressions

* fix(tui): close remaining Kitty review findings

* fix(cmux-tui): close graphics review gaps

* fix(cmux-tui): preserve attach and startup progress

* fix(tui): reconcile image render geometry

* fix(tui): align attach wire progress

* fix(tui): bound inline image rendering resources

* fix(tui): close inline image review gaps

* fix(tui): bound inline graphics hot paths

* test(tui): cover graphics attachment memory regressions

* fix(tui): bound graphics attachment allocations

* test(tui): budget retained render capacity

* test(tui): cover graphics review regressions

* fix(tui): close graphics autoreview gaps

* test(tui): cover second graphics review regressions

* fix(tui): close remaining graphics review gaps

* test(tui): cover remaining graphics review regressions

* fix(tui): close graphics review findings

* test(tui): cover final graphics review regressions

* fix(tui): close final graphics review findings

* test(tui): cover graphics admission regressions

* fix(tui): make graphics admission lazy and refillable

* test(tui): cover final host lifecycle findings

* fix(tui): bound host lifecycle work

* test(tui): cover final protocol review findings

* fix(tui): close final protocol review gaps

* test(tui): cover bounded graphics writer failure

* fix(tui): bound graphics output failure lifecycle

* test(tui): cover final remote graphics review gaps

* fix(tui): validate and localize remote attach data

* test(tui): cover final graphics ownership findings

* fix(tui): scope graphics output ownership

* test(tui): cover graphics resource safety gaps

* fix(tui): bound graphics resource lifecycles

* test(tui): cover graphics budget scan fanout

* fix(tui): make graphics admission single-pass

* test(tui): cover review resource safety gaps

* fix(tui): bound graphics attachment resources

* test(tui): cover enhanced input adapter compatibility

* fix(tui): reconcile shortcut merge with graphics input

* test(tui): reconcile merged attach fixtures

* test(tui): bound inline surface state

* fix(tui): keep libghostty state out of line

* test(tui): cover cell pixel fanout retry gap

* fix(tui): reconcile skipped cell pixel fanout

* test(tui): cover aggregate graphics ownership gaps

* fix(tui): bound aggregate graphics ownership

* test(tui): cover graphics teardown ownership

* fix(tui): rebalance graphics ownership on teardown

* test(tui): cover aggregate graphics recovery

* fix(tui): recover aggregate graphics capacity

* test(tui): isolate graphics counters per thread

* test(tui): cover graphics resource ownership gaps

* fix(tui): close graphics resource ownership gaps

* test(tui): cover Kitty replay state divergence

* fix(tui): preserve Kitty replay state across mirrors

* test(tui): cover terminal resource lifecycle stalls

* fix(tui): decouple terminal resource lifecycle work

* test(tui): cover review lifecycle regressions

* fix(tui): close review lifecycle gaps

* test(tui): cover graphics review regressions

* fix(tui): reconcile graphics lifecycle under load

* test(tui): cover graphics baseline and quota exhaustion

* fix(tui): reconcile graphics baselines and quota overflow

* test(tui): make graphics backpressure deterministic

* test(tui): cover exited quota and reset replay ordering

* fix(tui): preserve graphics state across resets and exits

* test(tui): cover scrolled Kitty placement alignment

* fix(tui): align Kitty graphics with scrolled viewports

* test(tui): bound stalled renderer output

* fix(tui): preserve renderer output backpressure

* test(tui): cover relabel and retry bounds

* fix(tui): bound graphics recovery work

* fix(ci): isolate fork-agent singleton default

* test(tui): bound persistent graphics recovery

* fix(tui): bound persistent graphics recovery

* test(tui): drain stalled quota worker

* test(tui): cover panic and fanout lifecycles

* fix(tui): bound graphics worker lifecycles

* test(web): cover exhausted graphics decode queue

* fix(web): retire exhausted graphics decode jobs

* test(tui): cover final Kitty review findings

* fix(tui): close final Kitty replay gaps

* test(tui): cover encoded Kitty quota

* fix(tui): budget encoded Kitty uploads

* test(browser): sync Kitty replay ceilings

* fix(browser): match Kitty replay ceilings

* test(tui): cover unsupported Kitty grayscale

* fix(tui): bound Kitty snapshot formats

* test(tui): cover Kitty quota recovery

* fix(tui): reconcile Kitty quota recovery

* test(tui): cover reconnect completion retry

* fix(tui): retry failed host reconnect completion

* test(tui): cover attach priority and replay cursor state

* fix(tui): preserve attach priority and replay state

* test(tui): cover superseded attach resize failure

* fix(tui): settle the latest promoted resize

* chore(tui): satisfy strict attach lifecycle lint

* test(tui): cover numeric Kitty final chunks

* fix(tui): parse Kitty chunk flags numerically

* test(tui): cover Kitty images in web scrollback

* fix(tui): render Kitty images in web scrollback

* test(tui): admit terminals after graphics quota failure

* fix(tui): degrade graphics after quota failure

* test(tui): finish graphics probe at DA1 marker

* fix(tui): end graphics probe at DA1 marker

* test(tui): reject stale scrollback image epochs

* fix(tui): version scrollback image anchors

* test(tui): refresh active scrollback epochs

* fix(tui): refresh active scrollback epochs

* test(tui): ignore screen-only history epochs

* fix(tui): scope history epochs to retained rows

* test(tui): keep image frames out of history epochs

* fix(tui): keep image frames out of history epochs

* test(tui): assert stable screen-only history epochs

* test(tui): bound deferred work and reserve attaches

* fix(tui): bound deferred graphics coordination

* test(tui): exhaust saturated Kitty quota retries

* fix(tui): exhaust saturated Kitty quota retries

* test(tui): retain overlapping Kitty replay placements

* fix(tui): clip Kitty placements at replay boundaries

* fix(tui): preserve merged attach invariants

* test(ci): cover Ghostty path metadata

* fix(ci): inspect executable Ghostty consumers

* test(ios): replace wall-clock synchronization

* chore(xcode): normalize project ordering

* fix(ssh): simplify retry script assembly

* test(app): update detached transfer fixture

* test: update remote PTY lifecycle fake

* test: require explicit app-host test mode

* fix: declare app-host test launch mode

* fix: clear Xcode 26.3 warning gate

* test: detect embedded app-host test bundle

* fix: detect app-host tests from embedded bundle

* chore: drop unreliable app-host scheme marker

* test: avoid async ARC lifetime assertion

* test: require app-host build identity

* fix: stamp app-host test builds before launch

* test: require test-runner app-host marker

* fix: forward app-host test identity through xcodebuild

* fix: keep completed iroh dial single-flight through install

* test: re-report lifecycle after status clear

* Fix cmux-tui merge integration

* test(web): cover render attach WebSocket budget

* fix(web): admit full render attach frames

---------

Co-authored-by: austinpower1258 <[email protected]>
2026-08-01 07:54:34 -07:00
Austin Wang b813ab9a25 Discover fresh Grok sessions from disk (#9382)
* test: cover Grok session discovery

* test: cover Grok timestamp fallbacks

* fix: discover fresh Grok sessions
2026-08-01 05:03:27 -07:00
Lawrence Chen f560731d26 Merge pull request #8667 from manaflow-ai/feat-cmux-tui-remote-daemon
Add authenticated remote daemon and clients to cmux-tui
2026-08-01 04:34:02 -07:00
Lawrence Chen 367f2ef891 Fix sidebar avatar loading fallback (#9375)
* fix: refine sidebar avatar loading fallback

* refactor: simplify sidebar avatar fallback

* test: cover sidebar avatar launch restoration

* fix: keep sidebar avatar icon during session restore
2026-08-01 04:26:09 -07:00
Lawrence Chen 9a7e4032d9 Disclose yearly billing in compact pricing labels (#9378)
* Simplify annual pricing labels

* Tighten pricing card layout

* Align pricing numerals and app grid

* Remove annual pricing totals

* Add annual billing cadence regression coverage

* Disclose yearly billing in compact labels
2026-08-01 04:04:34 -07:00
Lawrence Chen fdd8a1b7a2 Show signed-out state on app pricing page with in-app sign-in (#7821)
* 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
2026-08-01 03:48:27 -07:00
lawrencecchen 7dabcc417d Stabilize Go stream cleanup race test 2026-08-01 03:30:32 -07:00
Austin Wang a65e552e38 Fix Grok session discovery for fresh launches (#9379)
* test: cover Grok session discovery

* test: cover Grok timestamp fallbacks
2026-08-01 03:27:14 -07:00
lawrencecchen 0f264122bf Retry Unix carrier failures during reconnect 2026-08-01 03:24:03 -07:00
Lawrence Chen 43d1d3c8e9 Simplify annual pricing labels (#9373)
* Simplify annual pricing labels

* Tighten pricing card layout

* Align pricing numerals and app grid

* Remove annual pricing totals
2026-08-01 03:02:49 -07:00
Abdulaziz Albahar 03b33ab399 Merge pull request #9329 from manaflow-ai/feat-cmd-bracket-workspace-history-2
Cmd+[ / Cmd+] traverse global workspace focus history; pane cycling becomes rebindable
2026-08-01 04:50:28 -05:00
lawrencecchen bf889a5ae9 Unblock saturated PTY writers during cleanup 2026-08-01 02:50:19 -07:00
lawrencecchen 82a0cc1a54 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 02:36:55 -07:00
lawrencecchen 55fe60de70 Preserve completed Go streams across EOF 2026-08-01 02:36:40 -07:00
Lawrence Chen bbbf411686 Keep Pi hooks and unread updates off UI-critical paths (#9289)
* Test Pi managed extension refresh

* Refresh managed Pi extension on session start

* Test Pi lifecycle hook responsiveness

* Detach Pi lifecycle hooks from UI events

* Test titlebar layout invalidation

* Cache titlebar layout inputs

* Test unread invalidation scope

* Scope unread invalidation to leaf views

* Test targeted sidebar unread updates

* Route unread updates to affected sidebar rows

* Test heartbeat identity write suppression

* Suppress redundant heartbeat defaults writes

* Localize unread and titlebar observation ownership

* Fix restore argument helper compilation

* Address unread ownership review findings

* Publish refreshed extension membership snapshots

* Align projected-surface dismissal expectation

* Add regressions for Pi lifecycle isolation

* Serialize Pi lifecycle work by session

* Tighten Pi and unread regression coverage

* Scope remaining Pi and sidebar refreshes

* Add remaining Pi latency regressions

* Avoid blocking Pi refresh and stale unread delivery

* Add manual unread action regression

* Keep unread actions and group refreshes scoped

* Add extension snapshot sequence regression

* Preserve extension sequence for identical snapshots

* Add Pi lock symlink regression

* Index extension unread updates and harden Pi lock

* Fix sidebar scale test construction

* Import titlebar test dependency

* Fix Pi regression fixtures

* Stabilize sidebar projection scale gate

* Fix titlebar observer isolation warning

* Fix sidebar extension snapshot warning

* Fix restore CLI contract probe
2026-08-01 02:33:08 -07:00
lawrencecchen 1179bfbd9a Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 02:12:33 -07:00
lawrencecchen 51d4b413e4 Harden remote recovery and child reaping 2026-08-01 02:12:28 -07:00
Lawrence Chen a1026956d3 docs: fix restore CLI help probe format (#9365) 2026-08-01 01:56:16 -07:00
lawrencecchen de99cb6503 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 01:24:02 -07:00
lawrencecchen 4f24258a03 Keep terminal provider failures out of carrier retries 2026-08-01 01:23:47 -07:00
Lawrence Chen 4d1a40fbe4 fix: close browser handoff cleanup races (#9323)
* test: cover final browser handoff cleanup races

* fix: close final browser handoff cleanup races

* test: isolate browser availability revocation

* test: cover popup cleanup on app sign-out

* fix: close browser popups on app sign-out

* test: reproduce cleanup resetting closing browser panel

* fix: keep sign-out cleanup out of closing panels

* test: retain closing browser panel cleanup ownership

* fix: retain authenticated browser cleanup ownership

* test: reset browser cleanup retries for new ownership

* fix: scope browser cleanup retries to ownership

* refactor: keep browser ownership record private

* test: avoid popup window detachment timing
2026-08-01 01:22:58 -07:00
lawrencecchen 70b8b04348 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-08-01 01:05:57 -07:00
lawrencecchen 127f72ca06 Fix Linux clippy lint in provider authority command 2026-08-01 01:05:32 -07:00
Lawrence Chen 8d18ffc893 Harden resource SDK cancellation and transport lifecycles (#9366)
* Harden SDK cancellation and deadline boundaries

* Test WebSocket pairing dispatch boundaries

* Cancel WebSocket frames before pairing dispatch

* Test Zig dispatch and admission races

* Fix Zig dispatch uncertainty and admission handoff

* Use public-safe Zig admission terminology

* Test queued mutation and rejection races

* Test reusable Zig pre-write timeouts

* Preserve WebSocket dispatch certainty

* Keep Zig pre-write timeouts reusable

* Test Zig EOF payload dispatch boundary

* Test Unix connect queue cancellation

* Treat complete Zig payloads as dispatched

* Cancel Unix frames before connect dispatch

* Test transport dispatch resource release

* Test Zig custom transport uncertainty

* Release transport handles at dispatch

* Classify all Zig post-dispatch failures

* Test transport lifecycle hardening

* Harden transport dispatch lifecycle

* Test Zig transport lifetime hardening

* Harden Zig transport deadlines and teardown

* Test WebSocket preamble failure cleanup

* Close WebSocket on preamble failure

* Test Zig nonreading peer write deadline

* Test Zig nonblocking read readiness races

* Keep Zig Unix transport nonblocking

* Test WebSocket closing-state isolation

* Seal WebSocket while closing

* Test Zig close against descriptor reuse

* Hold Zig socket fd through active IO

* Test WebSocket lifecycle parity

* Test Zig stream envelopes before open ack

* Test Rust Unix socket creation hardening

* Buffer Zig stream envelopes before open ack

* Harden Rust Unix socket creation

* Share WebSocket authentication lifecycle

* Test timely Zig Unix descriptor release

* Order Rust socket setup before connect

* Release idle Zig Unix connections promptly

* Test deferred TypeScript request cancellation

* Test dispatch cancellation ordering

* Handle starved deadline rejection eagerly

* Test Zig deadlines across poll interruptions

* Cancel deferred TypeScript requests safely

* Preserve Zig deadlines across poll interruptions

* Test raw deferred dispatch deadlines

* Veto expired raw transport frames

* Test pairing denial classification

* Classify WebSocket handshake rejection

* Test raw request timeout bounds

* Validate raw request timeout bounds

* Test Rust connect socket reuse

* Test C++ request admission lock retries

* Retry C++ request admission lock attempts

* Reuse Rust socket while polling connect

* Test raw zero-timeout dispatch parity

* Preserve raw zero-timeout dispatch

* Test raw Zig client connect timeout

* Bound raw Zig client socket connects

* Test TypeScript transport review regressions

* Harden TypeScript request dispatch contracts

* Fail WebSocket handshakes closed

* Contain Unix connect observer failures

* Reject delayed Unix fixture connection failures

* Test Zig stream control overflow ownership

* Fix Zig stream pending ownership
2026-08-01 00:39:19 -07:00
lawrencecchen b6869edb7d Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http
# Conflicts:
#	cmux-tui/crates/cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui/src/main.rs
2026-08-01 00:36:14 -07:00
lawrencecchen f4d5919cee Fix Linux clippy portability 2026-08-01 00:32:22 -07:00
Lawrence Chen 3543d836b7 Make workspace schema errors actionable (#9320)
* test: require actionable workspace schema errors

* Improve newer workspace schema recovery error

* test: distinguish stale schema socket recovery

* Only suggest shutdown for a live schema socket

* test: keep schema errors free of state paths

* Hide state paths from schema recovery errors

* test: fence actionable schema recovery

* Fence schema recovery to the owning daemon

* test: fence schema recovery fallbacks

* Fence schema recovery shutdowns

* test: preserve force in fenced schema shutdown

* Unify forced daemon handoff policy
2026-08-01 00:23:49 -07:00
lawrencecchen 4b84d8f717 Fix remote daemon review findings 2026-08-01 00:17:31 -07:00
lawrencecchen ad6c16575c Allow relay transport slot in resource boundary check 2026-07-31 23:55:20 -07:00
lawrencecchen d376697337 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http 2026-07-31 23:54:20 -07:00
lawrencecchen 115c522b15 Merge remote-tracking branch 'origin/main' into codex/remote-codex-patch-http
# Conflicts:
#	cmux-tui/Cargo.lock
#	cmux-tui/crates/cmux-tui-core/src/surface.rs
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs
#	cmux-tui/crates/cmux-tui/src/cli.rs
#	cmux-tui/crates/cmux-tui/src/main.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
#	cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs
#	cmux-tui/spec/README.md
2026-07-31 23:50:59 -07:00
Austin Wang 89c52b8066 Fix restoring Codex sessions across relaunch generations (#9370)
* test: cover restoring restored Codex sessions

* fix: preserve restored agent binding generations
2026-07-31 23:48:19 -07:00
Lawrence Chen 2aef381d48 Add annual Pro pricing (#9234)
* feat(web): add annual Pro pricing

* fix(web): toggle pricing without navigation

* feat(web): refine annual pricing presentation

* fix(web): use cmux product blue for annual savings

* fix: include working cmux theme picker

* chore: update Ghostty theme picker base

* fix(web): adapt product blue for theme contrast

* chore: update Ghostty theme picker base

* test(web): reproduce stale Pagefind trace

* fix(web): build Pagefind before Next tracing

* test(web): cover annual Team pricing

* feat(web): add annual Team pricing

* test: reject external browser intent from untrusted sites

* fix: trust-gate external browser handoffs

* test(web): reject invalid billing intervals

* fix(web): reject invalid billing intervals

* Pin GhosttyKit for pricing theme picker

* Harden annual pricing integration

* Pin app theme injection to trusted origin

* test: cover final annual pricing regressions

* fix: close annual pricing review gaps

* test(web): exercise Stripe catalog behavior

* test: cover cross-origin annual checkout handoff

* fix: trust cross-origin checkout from app pricing

* test: reject arbitrary external pricing destinations

* fix: pin external pricing handoff destination

* test: require same-origin app checkout relay

* fix: relay app checkout through trusted origin

* test: cover app origin and Stripe pagination guards

* fix: close final pricing safety gaps

* test: reject invalid pricing relay parameters

* fix: validate app pricing relay parameters

* test: cover app web loopback aliases

* fix: share browser loopback host validation

* refactor: keep app origin helpers scoped

* test: keep unrelated Pagefind coverage unchanged

* test: preserve tagged callbacks through pricing relay

* fix: preserve trusted tagged pricing callbacks

* fix: clean up embedded pricing layout

* test: cover annual Team labels without lookup keys

* fix: label annual Team subscriptions from metadata

* test: cover annual pricing review regressions

* fix: close annual pricing review gaps

* fix: return validated restore arguments

* fix: align annual pricing interaction contracts

* fix: authenticate pricing callbacks and catalog retries

* test: cover final annual pricing review regressions

* fix: close final annual pricing review gaps

* test: keep app theme policy in package suite

* Record combined GhosttyKit artifact
2026-07-31 23:41:19 -07:00
Abdulaziz AlbaharandClaude Fable 5 798aa345e5 Port reconnect residuals onto connectivity v2 (#9347)
* 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]>
2026-08-01 01:39:57 -05:00
lawrencecchen d69e5b214d feat(remote): add Codex patch and authenticated HTTP RPC 2026-07-31 23:20:13 -07:00
Abdulaziz AlbaharandClaude Fable 5 649cb7f673 Add missing return in restoreCLIArgument (#9338)
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]>
2026-07-31 23:58:07 -05:00
Abdulaziz AlbaharandClaude Fable 5 6846070817 Fix missing return in restoreCLIArgument (main compile break) (#9334)
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]>
2026-07-31 23:52:03 -05:00
Austin Wang 51a95f8dc2 Fix missing return in restoreCLIArgument breaking nightly Release build (#9343) 2026-07-31 21:39:44 -07:00
Austin Wangandcmux reload-cloud cdb35d72e1 Fix sender-relative key-window routing for restored windows (#9282)
* test: reproduce sender-relative window key routing

* fix: key sender-relative window actions

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 21:24:27 -07:00
Austin Wangandcmux reload-cloud 5e83b4ec18 Fix ssh-tmux named-key encoding for remote TERM (#9273)
* test: cover tmux named-key forwarding

* fix: delegate remote tmux named keys to tmux

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 21:06:16 -07:00
Austin Wangandlawrencecchen 5d1cea3927 Fix stale semantic prompts duplicating inline TUI frames (#9275)
* terminal: log every applied surface size

* ghostty: add prompt overwrite regression coverage

* ghostty: clear stale prompt marks on output overwrite

* test: add dSYMs to iOS upload fixtures

* ghostty: pin prompt lifecycle GhosttyKit

* test: re-report lifecycle after status clear

* test: bypass quit path in remote tmux cleanup

(cherry picked from commit de565ada4d)

* test: retire remote tmux fixture surfaces

(cherry picked from commit 756cc69b57)

* test: isolate socket policy from renderer observers

* ci: route TUI spec through runner variable

* test: signal Python SDK handler teardown reliably

---------

Co-authored-by: lawrencecchen <[email protected]>
2026-07-31 21:05:51 -07:00
Abdulaziz AlbaharandClaude Fable 5 c3cdbd6044 Bound iOS iroh client retries with one shared backoff policy (#9301)
* 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]>
2026-07-31 23:01:25 -05:00
Austin Wang 515a3119f5 Fix ssh-tmux terminal replies leaking into reattached panes (#9272)
* test(remote-tmux): require mirror protocol ownership

* fix(remote-tmux): make Ghostty a protocol mirror
2026-07-31 20:49:52 -07:00
Abdulaziz AlbaharandClaude Fable 5 3a77d9a102 Classify transient token misses as connectivity so launch activation stops failing closed (#9259)
* 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]>
2026-07-31 22:38:17 -05:00
Abdulaziz AlbaharandClaude Fable 5 4f4a4d1156 iOS: fix Hidden Computers Forget swipe crash (destructive role on confirm-first button) (#9291)
* 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]>
2026-07-31 22:25:37 -05:00
Austin Wang 4ae648f956 Close the remaining ContentView release-chain gap (#9082)
* Test bounded deferred UI replacement bursts

* Strengthen deferred replacement lifetime coverage

* Cover active sleeper replacement

* Synchronize deferred closure lifetime assertions

* Bound cursor scheduler regression test

* Make sidebar test clock waits cancellable

* Test ContentView stored task ownership

* Break workspace handoff task release chain
2026-07-31 20:24:46 -07:00
Abdulaziz AlbaharandClaude Fable 5 7a6b63343e iOS: replace disconnect chrome with Mail-style status line under the computers picker (#9276)
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]>
2026-07-31 22:19:36 -05:00
Lawrence Chenandcmux-lawrence a45c38f596 Keep company information page low key (#9335)
Co-authored-by: cmux-lawrence <[email protected]>
2026-07-31 20:11:02 -07:00
e0604ca175 iOS: restore workspace drag-and-drop + group create after ticket expiry; native drop-into-group signifiers (#8602)
* 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]>
2026-07-31 22:05:17 -05:00
Austin Wangandcmux reload-cloud 6e2edb1b62 Fix Dock paste routing to selected terminal (#9112)
* Test Dock selection first-responder handoff (#9097)

* Restore selected Dock terminal focus

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 19:57:43 -07:00
Abdulaziz AlbaharandClaude Fable 5 4c62139cc6 tests: fileprivate helpers using file-private StoredShortcut alias
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]>
2026-07-31 19:44:19 -07:00
Austin Wang fa11c0398d Fix inline code escaping in markdown viewer (#9274)
* test(markdown): cover codespan escaping

* fix(markdown): escape codespans exactly once

* test(markdown): make injection proof deterministic

* fix(markdown): contain malformed codespan tokens

* fix(markdown): diagnose invalid codespans

* test(markdown): avoid hook-order coupling

* fix(markdown): use neutral invalid-span marker

* fix(markdown): keep codespan normalization linear
2026-07-31 19:37:18 -07:00
Austin Wangandcmux reload-cloud 5bf9595804 Add shell-free cmux restore verb (#9265)
* test: require short CLI restore startup input

* feat: restore processes from structured launch data

* fix: prefer live restore binding identity

* fix: route restore through bundled CLI

* fix: harden restore startup dispatch

* fix: reconcile structured restore overrides

* fix: harden restore binding and cwd fallback

* fix: recover restore context without shell env

* fix: secure restore transport failures

* fix: harden structured restore boundaries

* test: cover restore startup compatibility gaps

* fix: type restore cwd fallback explicitly

* fix: call restore launch mapper explicitly

* fix: name restore socket responder distinctly

* fix: preserve restore startup compatibility

* fix: ignore empty restore PATH components

* fix: bound restore provider preflights

* refactor: isolate restore preflight execution

* fix: harden restore launch boundaries

* test: require readable restore startup verb

* fix: keep restore startup input readable

* test: cover restore review edge cases

* fix: address restore review edge cases

* refactor: align restore types with package policy

* test: cover restore compatibility review gaps

* fix: close restore review edge cases

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 19:34:35 -07:00
Abdulaziz AlbaharandClaude Fable 5 4093b1bf1b tests_v2: replace sleep-then-assert with condition polls in focus-history e2e
check-test-determinism.py --strict flagged the post-close sleep; poll for
selection and close instead.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-31 19:19:54 -07:00
Abdulaziz Albahar b2db1c4551 Fix iOS keyboard focus after photo picker (#9287)
* test: cover keyboard recovery after photo picker

* fix: release terminal focus before photo picker
2026-07-31 21:08:27 -05:00
Austin Wangandcmux reload-cloud 4d27e944a8 Fix partial blank browser screenshots (#9281)
* test: cover partial blank browser screenshots

* fix: verify browser screenshot frames

* fix: harden screenshot verification

* fix: make screenshot probes conservative

* fix: bound screenshot verifier work

* fix: address screenshot review feedback

* fix: reduce screenshot verifier false positives

* fix: keep screenshot verification effective

* fix: make screenshot retries cancellable

* fix: make screenshot capture policy testable

* fix: tighten screenshot attestation policy

* test: exercise browser screenshot DOM probes

* fix: exclude inconclusive screenshot probes

* fix: bound screenshot verifier resources

* refactor: isolate screenshot capture types

* fix: defer screenshot bridge terminal state

* fix: tighten screenshot coordinate attestation

* docs: clarify screenshot mismatch threshold

* fix: bound screenshot WebKit evaluation

* fix: return straight screenshot pixel colors

* test: use ordinary screenshot web view configuration

* fix: preserve screenshot synchronization timeouts

* fix: classify screenshot synchronization uncertainty

* fix: fail open when screenshot preparation is unavailable

* fix: ignore text behind passive overlays

* fix: preserve screenshot synchronization ordering

* perf: sample screenshot pixels on demand

* perf: normalize only screenshot probe regions

* test: assert pending fonts through probes

* fix: share screenshot continuation completion gate

* fix: reject screenshot request reuse safely

* fix: budget verified browser screenshots

* fix: bound browser snapshot attempts

* fix: preserve screenshot scanline order

* test: cover browser screenshot timeout nesting

* fix: preserve snapshot helper defaults

* fix: share browser screenshot timing budget

* refactor: make screenshot pixel values explicit

* fix: make overlapping screenshots retryable

* fix: export screenshot timing dependency

* fix: attest text across scripts safely

* fix: detect transparent screenshot gaps

* fix: bound screenshot recovery timing

* fix: await screenshot lease teardown

* fix: serialize screenshot lease completion

* test: eliminate screenshot suite warnings

* fix: preserve screenshot deadline nesting

* fix: eliminate screenshot capture warnings

* test: cover screenshot probes on complex DOMs

* fix: bound screenshot probe work in WebKit

* fix: avoid retaining browser during frame sync

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 19:06:08 -07:00
Lawrence Chen 387061d163 test: update mobile host route cache calls (#9328) 2026-07-31 19:03:12 -07:00
Abdulaziz Albahar 08164fda77 Fix live agent prose streaming ownership
Replaces timer polling with demand-owned render/tick coalescing, binds transcript settlement to active turn ownership, and clears/replays previews on stream lifecycle changes.
2026-07-31 20:53:53 -05:00
Abdulaziz Albahar bf5e03eb15 Merge pull request #9284 from manaflow-ai/feat-connectivity-v2
Rebuild Iroh connectivity authority and Apple transport
2026-07-31 19:09:33 -05:00
Lawrence ChenandAbdulaziz Albahar 820aa65e60 Fix iOS terminal scrolling during edge swipe back (#6659)
* Add failing iOS terminal edge swipe test

* Reserve iOS back swipe edge from terminal scroll

* Fix iOS edge reservation lint

* Extract terminal scroll mechanics view

* Retire terminal-specific edge reservation

* Add failing iOS edge swipe scroll regression

* Model edge swipe gesture hierarchy in test

* Prioritize iOS back swipe over surface pans

* Scope swipe precedence to navigation content

* Fix swipe-back unit test hierarchy

---------

Co-authored-by: Abdulaziz Albahar <[email protected]>
2026-07-31 18:58:08 -05:00
Abdulaziz AlbaharandClaude Fable 5 cb468e974d Comprehensive Sentry telemetry for iroh/transport failures (iOS + macOS) (#9305)
* 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]>
2026-07-31 18:41:53 -05:00
Abdulaziz Albahar c8c7edf090 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-31 16:41:22 -07:00
Abdulaziz Albahar da0db1e71d Merge pull request #9318 from manaflow-ai/feat-connectivity-v2-closeout
Harden authoritative Iroh route reconciliation
2026-07-31 18:40:08 -05:00
Lawrence Chen a1fcec5a7f Fix Pro TestFlight fulfillment and welcome flow (#8859)
* 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
2026-07-31 15:47:54 -07:00
Abdulaziz Albahar d824c088a9 Evict pathless Iroh sessions at the lifecycle owner 2026-07-31 14:09:55 -07:00
Abdulaziz Albahar 5d0cba06c7 build: include Simulator app in iOS reload artifact 2026-07-31 14:08:38 -07:00
Abdulaziz Albahar fc007c7c5d Test pathless connectivity session eviction 2026-07-31 14:05:34 -07:00
Abdulaziz AlbaharandClaude Fable 5 f9aa3d2a7b 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]>
2026-07-31 13:58:10 -07:00
Abdulaziz AlbaharandClaude Fable 5 f922426465 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]>
2026-07-31 13:57:44 -07:00
Abdulaziz Albahar 2c428d40d7 Stabilize Iroh connectivity regression tests 2026-07-31 13:51:10 -07:00
Abdulaziz Albahar 21b48132e6 Harden authoritative Iroh route reconciliation 2026-07-31 13:37:53 -07:00
Abdulaziz Albahar bedcfb2d46 Test atomic connectivity registration revisions 2026-07-31 13:35:58 -07:00
Abdulaziz Albahar 152f78d8b5 Test fail-closed connectivity revisions 2026-07-31 13:26:08 -07:00
Abdulaziz AlbaharandClaude Fable 5 81fe1ddd71 Cmd+[ / Cmd+] traverse global workspace focus history; pane cycling becomes rebindable
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]>
2026-07-31 10:46:58 -07:00
Abdulaziz AlbaharandClaude Fable 5 e81d4fcfbe Add failing coverage: Cmd+[ / Cmd+] must traverse global workspace focus history
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]>
2026-07-31 09:33:42 -07:00
Lawrence Chen 184bd48365 Add sidebar account and mobile pairing controls (#8354)
* Add sidebar account and mobile pairing controls

* Match mobile pairing pane to terminal theme

* Match sidebar help icon sizing

* Fix clipped titlebar Pro badge

* Add sidebar footer icon balance lab

* Centralize macOS Stack sign-in routing

* Keep footer lab independent of auth refactor

* Revert "Keep footer lab independent of auth refactor"

This reverts commit 8328e300fb.

* Size trailing titlebar accessory from hosted content

* Balance sidebar footer help icon

* Fix sidebar account avatar alignment

* Use bare sidebar footer symbols

* Add per-icon blur and hover tuning

* Blur footer icons independently together

* Refine sidebar auth and icon choices

* Improve footer icon balance lab

* Apply selected footer icon balance

* Test titlebar excludes sidebar account controls

* Keep account controls out of titlebar

* Add in-pane account sign-in flow

* Refine sidebar debug footer controls

* Register Footer Icon Balance Lab as an auxiliary window

The debug window sets identifier cmux.sidebarFooterIconBalanceDebug but was
missing from cmuxAuxiliaryWindowIdentifiers, so Cmd+W routed through
workspace/panel-close behavior instead of closing the window, and
workflow-guard-tests failed the auxiliary window close-shortcut lint.

* test: cover transient account workspace restoration

* Fix sidebar footer and transient button flows

* Refine minimal footer upgrade entrypoints

* Refine sidebar profile controls

* Expose sidebar footer balance lab

* Expose footer profile lab command

* Match footer account and help heights

* Activate footer profile lab window

* Tighten sidebar utility button spacing

* Use filled circular profile icon

* Resolve removed mobile titlebar project wiring

* Use outlined circular profile icon

* Match account and help icon weight

* Move footer lab into debug window coordinator
2026-07-31 07:39:58 -07:00
Lawrence Chen 5a8a973ebc ci: route cmux-tui spec through Linux runner (#9313) 2026-07-31 05:09:41 -07:00
Lawrence Chen 72c0d5dfa4 Poll iOS TestFlight every 20 minutes for new changes (#9280)
* Poll TestFlight every 20 minutes within budget

* Remove TestFlight upload caps

* Bound TestFlight upload history lookup
2026-07-31 04:39:32 -07:00
Lawrence Chen e63526c111 Add noun-first resource API and handwritten SDKs
PR: https://github.com/manaflow-ai/cmux/pull/9215
2026-07-31 04:08:06 -07:00
Abdulaziz Albahar 2b0017bf87 Use Swift-safe simulator identity locking 2026-07-31 04:00:34 -07:00
Abdulaziz Albahar 0431b689f5 Merge remote-tracking branch 'origin/feat-connectivity-v2' into feat-connectivity-v2-final 2026-07-31 03:49:04 -07:00
Abdulaziz Albahar 48dc729dbb Centralize authoritative connectivity recovery 2026-07-31 03:48:14 -07:00
Abdulaziz Albahar 2ea1057d5a fix: fall back from unversioned discovery prefetch 2026-07-31 03:41:41 -07:00
Abdulaziz Albahar 2c6a43f809 Merge remote-tracking branch 'origin/feat-connectivity-v2' into feat-connectivity-v2 2026-07-31 03:17:32 -07:00
Abdulaziz Albahar 91f66896ca docs: record Iroh startup latency decisions 2026-07-31 03:17:15 -07:00
Abdulaziz Albahar 91e530c7dd Merge remote-tracking branch 'origin/main' into feat-connectivity-v2-final 2026-07-31 03:06:49 -07:00
Abdulaziz Albahar a5598dc655 Harden connectivity lifecycle ownership 2026-07-31 03:06:49 -07:00
Abdulaziz Albahar 2f2b4e1bbd Test connectivity lifecycle hardening 2026-07-31 03:06:49 -07:00
Abdulaziz Albahar 217a26f801 test: model embedded discovery revision 2026-07-31 02:43:28 -07:00
Austin Wangandcmux reload-cloud 35fb1af322 Fix configurable browser and focus Back/Forward shortcuts (#9296)
* test: cover focus history Ghostty shortcut collision

* fix: give live shortcuts precedence over Ghostty fallbacks

* test: partition browser and focus history shortcuts

* fix: partition browser and focus history shortcuts

* refactor: share directional shortcut metadata

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 02:42:42 -07:00
Abdulaziz Albahar 9f1915e649 perf: overlap cached Iroh discovery with endpoint bind 2026-07-31 02:35:46 -07:00
Abdulaziz Albahar 387254534e test: cover cached Iroh discovery startup 2026-07-31 02:24:44 -07:00
Austin Wangandcmux reload-cloud 9e3e324926 Keep workspace customization scoped to workspace identity (#9270)
* Add failing same-directory workspace restore regressions

* Persist workspace customization by stable identity

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-31 02:01:38 -07:00
Abdulaziz AlbaharandClaude Fable 5 d4084732ad Stop restore from cloning one directory's sticky title onto every workspace (#9260)
* 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]>
2026-07-31 03:48:11 -05:00
Abdulaziz AlbaharandClaude Fable 5 e3594dd83b Unbreak workflow-guard-tests: fake iOS archives need dSYMs and Symbols (#9279)
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]>
2026-07-31 03:46:46 -05:00
Abdulaziz Albahar 5dbbb7d080 perf: remove Iroh discovery startup round trips 2026-07-31 01:45:07 -07:00
Abdulaziz Albahar 06dd927923 test: cover lower-latency Iroh startup 2026-07-31 01:44:59 -07:00
Lawrence Chen 7dea5f752a Merge pull request #9288 from manaflow-ai/codex/pinned-workspace-groups
Fix pinned workspace grouping and group reordering
2026-07-31 00:40:22 -07:00
Abdulaziz Albahar 6a4dd9c81b Recover cached host endpoint registration 2026-07-31 00:07:19 -07:00
Abdulaziz Albahar c319919512 Test cached host endpoint port recovery 2026-07-31 00:05:46 -07:00
Abdulaziz Albahar 3ba2e6e492 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2
# Conflicts:
#	web/services/iroh/routeHandler.ts
#	web/tests/iroh-route-handler.test.ts
2026-07-30 23:59:01 -07:00
Abdulaziz AlbaharandClaude Fable 5 541fe7f0c7 Remove all iroh broker quotas and rate limits (#9269)
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]>
2026-07-31 01:23:22 -05:00
ejc3 f821f78784 browser: fix headless-broken Browser test suites and the bugs they caught
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.
2026-07-30 23:21:53 -07:00
Abdulaziz Albahar da4bf5edf1 Retire superseded connection candidates 2026-07-30 22:41:03 -07:00
Abdulaziz Albahar 4e87faebcb Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 22:34:12 -07:00
Abdulaziz AlbaharandClaude Fable 5 b5294c479a iOS: Tailscale connection method opt-in (Settings + onboarding) with QR-authorized pairing (#9247)
* 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]>
2026-07-31 00:06:56 -05:00
lawrencecchen d839d476c4 fix: reject pinned group self drops 2026-07-30 22:02:41 -07:00
lawrencecchen 527ee5d7d7 test: reject pinned group self drop 2026-07-30 22:02:24 -07:00
Abdulaziz Albahar 0e44836fce Verify Mac presence authority in release gate 2026-07-30 21:50:15 -07:00
Abdulaziz Albahar 444a92e125 Test release gate Mac presence parity 2026-07-30 21:49:58 -07:00
Abdulaziz Albahar 72848a8762 Verify release gate backend parity 2026-07-30 21:28:51 -07:00
Abdulaziz Albahar 3790961f21 Test release gate artifact authority parity 2026-07-30 21:28:08 -07:00
Abdulaziz Albahar 4f5ec136f6 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 21:27:18 -07:00
Abdulaziz AlbaharandClaude Fable 5 3d8e32ba28 Fix iOS TestFlight Release archive: guard DEBUG-only evidence probe (#9254)
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]>
2026-07-30 22:40:29 -05:00
Abdulaziz Albahar 2479c1974d Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 20:31:39 -07:00
Abdulaziz Albahar c30cf37b1f Preserve revisions across discovery pages 2026-07-30 20:31:21 -07:00
Abdulaziz Albahar f035393df2 Test paginated discovery revisions 2026-07-30 20:30:51 -07:00
Abdulaziz Albahar fbe4902384 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2
# Conflicts:
#	web/services/iroh/repository.ts
#	web/services/iroh/trustBroker.ts
#	web/tests/iroh-db-behavior.test.ts
#	web/tests/iroh-trust-broker.test.ts
2026-07-30 20:30:10 -07:00
Abdulaziz AlbaharandClaude Fable 5 ca98281d8d Ship dSYMs with iOS TestFlight builds and persist them as run artifacts (#9236)
* 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]>
2026-07-30 22:28:34 -05:00
Abdulaziz Albahar c15f1e9652 Isolate Iroh release gate runtime state 2026-07-30 20:25:31 -07:00
Abdulaziz Albahar 487623446d Test isolated Iroh gate runtime inputs 2026-07-30 20:24:13 -07:00
Abdulaziz Albahar 26434da4a1 Merge pull request #9253 from manaflow-ai/feat-unbounded-iroh-bindings
Remove total cap from Iroh endpoint bindings
2026-07-30 22:21:56 -05:00
Abdulaziz AlbaharandClaude Fable 5 a411a370c9 Fix iOS startup crash: sentry-init racing environ mutation in ghostty_init (#9238)
* 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]>
2026-07-30 22:20:07 -05:00
Abdulaziz Albahar 9a8b9c74f7 fix(iroh): keep unbounded discovery linear 2026-07-30 20:03:45 -07:00
Abdulaziz Albahar 8377cafea1 Allow multihomed LAN interface overlap 2026-07-30 20:02:23 -07:00
Abdulaziz Albahar 92ffb4331d Test multihomed LAN discovery 2026-07-30 20:02:06 -07:00
Abdulaziz Albahar a45a503674 Fix iOS workspace list toolbar insets (#9171)
* Add regression test for hidden iOS workspace row

* Fix iOS workspace list toolbar inset

* Test workspace list scroll edge underlap

* Preserve iOS workspace scroll edge effects

* Test iOS workspace bottom edge sizing

* Size iOS workspace fade to tab bar

* Test compact iOS workspace bottom edge

* Keep workspace bottom effect at tab bar

* Test soft workspace edge at tab bar

* Anchor workspace effect to tab bar

* test(ios): require UIKit-owned workspace list insets

* fix(ios): leave workspace scrolling to UIKit

* test(ios): exercise workspace row interactions

* test(ios): require workspace table to respect toolbar safe areas

* fix(ios): keep workspace rows outside toolbar hit regions

* test(ios): require native workspace scroll-edge underlap

* fix(ios): restore native workspace scroll-edge effects

* test(ios): require workspace delete confirmation

* fix(ios): confirm workspace deletion at its source row

* perf(ios): avoid snapshots for workspace payload updates
2026-07-30 21:46:54 -05:00
Abdulaziz Albahar af7ca43f7a Update connectivity v2 verification notes 2026-07-30 19:39:12 -07:00
Abdulaziz Albahar 55a32e3bd9 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 19:36:15 -07:00
Abdulaziz Albahar 6e131f8485 Filter LAN discovery to authenticated aliases 2026-07-30 19:34:43 -07:00
Abdulaziz Albahar 7dc7f33baa feat(iroh): paginate unbounded binding discovery 2026-07-30 19:26:16 -07:00
Abdulaziz Albahar fcedad8583 Test authenticated LAN discovery filtering 2026-07-30 19:23:11 -07:00
Abdulaziz Albahar bcf55c1ef7 test(iroh): cover unbounded paginated bindings 2026-07-30 19:11:31 -07:00
Abdulaziz Albahar eb9f6dedad Persist simulator device identities without Keychain 2026-07-30 18:43:45 -07:00
Abdulaziz Albahar 240cd47fc3 Test simulator device identity seeding 2026-07-30 18:40:58 -07:00
lawrencecchen 16fbe8432a fix: restore pinned workspace grouping and group drags 2026-07-30 18:28:37 -07:00
lawrencecchen 42422a252d test: cover recovering group header drags 2026-07-30 18:28:11 -07:00
Abdulaziz Albahar f13f257fc6 Restore cached host Iroh routes 2026-07-30 18:00:39 -07:00
lawrencecchen 4a0e52527e test: cover creating groups from pinned workspaces 2026-07-30 17:53:13 -07:00
Abdulaziz Albahar a799151d41 Support credential-free local relay policy 2026-07-30 17:30:22 -07:00
Abdulaziz AlbaharandClaude Fable 5 9112fe22f3 Batch approval and prefix generalization for surface resume command alerts (#9028)
* 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]>
2026-07-30 19:27:48 -05:00
Abdulaziz Albahar 47d75d8b3c Encode initial connectivity revision explicitly 2026-07-30 17:26:11 -07:00
Abdulaziz AlbaharandClaude Fable 5 1690a334a8 fix: register company-information as an agent-readable page (#9245)
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]>
2026-07-30 19:21:47 -05:00
Abdulaziz Albaharandlawrencecchen befd3caf4c fix: clear Xcode 26.3 warning gate (#9233)
Co-authored-by: lawrencecchen <[email protected]>
2026-07-30 19:07:19 -05:00
Abdulaziz AlbaharandClaude Fable 5 7034fcfbe6 fix: discard presentBrowserAlert dismiss handles explicitly (#9239)
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]>
2026-07-30 19:04:39 -05:00
Abdulaziz Albahar 22ab003e2c Align iOS Compose with the Search control (#9172)
* 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
2026-07-30 18:54:19 -05:00
Abdulaziz Albahar f3a9415bbf Secure connectivity invalidation delivery 2026-07-30 16:45:44 -07:00
Abdulaziz Albahar 28265bb897 Merge remote-tracking branch 'origin/main' into feat-connectivity-v2 2026-07-30 16:29:20 -07:00
Abdulaziz Albahar 9eb240f262 Complete connectivity v2 ownership 2026-07-30 16:29:09 -07:00
Lawrence Chenandcmux-lawrence 980cef9ffd Add public company information page (#9240)
Co-authored-by: cmux-lawrence <[email protected]>
2026-07-30 16:21:24 -07:00
Abdulaziz Albahar e7e3761551 Route Apple runtimes through connectivity engine 2026-07-30 16:04:31 -07:00
Abdulaziz Albahar 0a238da00e Add unified connectivity engine 2026-07-30 15:48:48 -07:00
David Veselý 1349bc06cc Document workspace-action in the cmux skill (#8177)
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.
2026-07-30 15:43:19 -07:00
Abdulaziz Albahar db16124676 Add revisioned connectivity authority 2026-07-30 15:38:40 -07:00
Abdulaziz AlbaharandClaude Fable 5 d304a0f0b6 Enforce iPhone+simulator default for iOS verification with an offline install queue (#9232)
* 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]>
2026-07-30 17:13:58 -05:00
Abdulaziz Albahar 1be89d7b30 Fix sidebar row clipping during height-changing reorder (#9189)
* Fix sidebar row clipping during reorder

* test: preserve sidebar viewport during height-changing reorder

* fix: preserve sidebar viewport during atomic reorder reload

* test: preserve sidebar edits during atomic reorder reload

* fix: preserve sidebar edits during atomic reorder reload

* refactor: satisfy sidebar review policy
2026-07-30 17:06:48 -05:00
Abdulaziz AlbaharandClaude Fable 5 099e7eaaa8 iOS: key all per-Mac state by pairing (device id + instance tag) so sibling builds are first-class (#8936)
* 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]>
2026-07-30 16:47:39 -05:00
Abdulaziz AlbaharandClaude Fable 5 6eeae1c619 iOS: stable Keychain device id + Forget computer (iroh re-key client) (#8888)
* iOS: stable Keychain device id + Forget computer (iroh re-key client)

Client complement to the broker binding re-key (manaflow-ai/cmux#8883),
which changes the iroh binding slot from unique(app_instance_id) to
unique(user_id, device_uuid, tag) and replaces the 409
binding_replacement_requires_revocation with a newest-authenticated-wins
in-place UPDATE.

Two changes make the phone cooperate with that slot:

1. Stable device id across reinstall. The iOS device-registry id moves
   from UserDefaults (erased on delete/reinstall) to a device-only
   Keychain item (service com.cmuxterm.deviceRegistry.iosDeviceID.v1,
   AfterFirstUnlockThisDeviceOnly). A returning phone now presents the
   same device_uuid and overwrites its own binding in place instead of
   stranding a fresh one. Keychain is authoritative; a pre-Keychain
   UserDefaults id is migrated on first read, and the generated id is
   mirrored back to UserDefaults for downgrade safety. This service is
   distinct from the iroh endpoint-identity store that sign-out/reinstall
   wipes, so forgetting the endpoint identity does not churn the slot key.

2. Forget a hidden computer. The per-phone Hidden Computers list gains a
   destructive Forget action (swipe + context menu, both gated behind a
   confirmation dialog, mirroring MacComputerRow's Hide) that revokes the
   Mac's account binding through the user-ownership-scoped broker endpoint.
   It resolves the binding id at action time via a fresh broker.discover()
   (so an offline Mac's binding is still listed and revocable), matches by
   canonical device id plus exact tag when known, revokes each match, then
   clears the local hidden marker and paired-Mac row. A still-online Mac
   re-registers and reappears on its next connect. Failure keeps the row
   and surfaces a toast.

New narrow capability MobileIrohMacForgetting keeps the shell store's
dependency minimal; en+ja localization added for the Forget copy.

* iOS: fail closed on unreadable device id, alert on Forget failure, pin account

Address the four P1 review findings on the iroh re-key iOS client branch.

Finding 1 (device-id read ambiguity): DeviceIdentityStoring.read() returned an
optional, collapsing "no id yet" and "Keychain locked before first unlock" into
nil. A background launch before first unlock therefore looked like a fresh
install and minted a NEW id, stranding the phone's existing (user, device, tag)
binding. read() now returns DeviceIdentityReadResult (.found/.absent/
.unavailable). deviceID(store:defaults:) fails closed on .unavailable: it reuses
the legacy UserDefaults mirror if readable, else a per-process ephemeral id that
is never persisted, so the durable id is adopted once the store unlocks. A
.found id is re-mirrored to UserDefaults (only when it differs) for downgrade
safety; a present-but-blank/corrupt item is treated as .absent and re-minted.

Finding 2 (account pinning): MobileIrohRuntimeComposition pins the expected
account and ensureAccountUnchanged guards Forget so a token-source swap mid-flow
can't revoke a binding under the wrong account (MobileIrohForgetError.
accountChanged).

Finding 3 (Forget ordering): MobileShellComposite forget removes the row before
clearing the hidden marker and returns Bool so a failed broker revoke surfaces
instead of silently dropping the row.

Finding 4 (Forget failure visibility): DeviceTreeView shows a .alert (not a
toast) on Forget failure, so the error surfaces even with the Toasts beta flag
off. Keys mobile.computers.forget.failureTitle/failureMessage, mobile.common.ok
localized en+ja.

CmuxMobileShell host-compiles and its 21 DeviceRegistry tests pass (incl. new
fail-closed + re-mirror coverage). DeviceTreeView and MobileIrohRuntimeComposition
transitively need GhosttyKit, so they compile only in the fleet iOS build.

Co-Authored-By: Claude Opus 4.8 <[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]>
2026-07-30 16:45:01 -05:00
b86cce2e7d iOS: stream Mac browser panes to the phone (pixel-perfect, interactive, dialogs mirrored) (#8298)
* 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]>
2026-07-30 16:13:11 -05:00
Austin Wang d55a482758 Replay sidebar clicks after reveal actions return (#9225)
* Add regression test for reveal-time sidebar click

* Replay sidebar clicks after reveal actions return
2026-07-30 13:52:47 -07:00
Abdulaziz Albahar 26402973ea Shrink Iroh pairing QR payload (#9174)
* Test endpoint-only Iroh pairing QR

* Use endpoint-only Iroh pairing QR
2026-07-30 15:16:21 -05:00
Lawrence Chen 6d899d08f0 Remove TUI install path notes (#9219) 2026-07-30 02:33:31 -07:00
Austin Wang 57d7d5f46d Pin GhosttyKit checksum for theme picker fix (#9218) 2026-07-30 01:51:29 -07:00
Austin Wang 669bbf1a4a Fix hanging cmux theme picker (#9207) (#9211)
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.
2026-07-30 01:34:20 -07:00
Austin Wang 1381129aa4 Fix detached transfer test fixture (#9216) 2026-07-30 00:44:59 -07:00
Austin Wang 6ab4595552 Fix remote PTY lifecycle test fake (#9214) 2026-07-30 00:31:18 -07:00
Lawrence Chen a847654314 Add cmux TUI product and docs pages (#9049)
* Add cmux TUI product and docs pages

* Use real macOS capture for cmux TUI

* Match TUI page to home design

* Add native TUI installers and Hunk capture

* Move TUI installer into hero

* Highlight TUI install commands

* Compact TUI install controls

* Harden TUI installer delivery
2026-07-30 00:31:15 -07:00
Austin Wang 35fd5a2983 Fix SSH retry script compiler timeout (#9213) 2026-07-30 00:25:25 -07:00
Austin Wang 1fe65ebfe0 Fix Ghostty Zig workflow guard false positives (#9209)
* test: cover Ghostty Zig workflow execution guard

* Fix Ghostty Zig workflow execution guard
2026-07-30 00:25:01 -07:00
Austin Wang bddbd4934e Unify local resume launcher scripts (#9200) (#9205)
* Add failing resume launcher regression tests (#9200)

* Unify local resume launcher scripts (#9200)

* Address resume launcher review findings

* Address PR review feedback

* Handle inaccessible resume cwd ancestors (#9200)

* Preserve remote resume working directories (#9200)

* Update resume launcher regression expectations (#9200)

* Preserve remote resume command cwd (#9200)

* Add resume cwd consistency regressions (#9200)

* Keep resume cwd delivery consistent (#9200)

* Add resume wrapper review regressions (#9200)

* Avoid nested login startup in resume wrappers (#9200)
2026-07-29 23:34:55 -07:00
Austin Wangandcmux reload-cloud 51b4ba8cd8 Fix resumed Codex Teams subagent pane backfill (#9180)
* test: cover resumed Codex subagent pane backfill

* fix: open resumed Codex subagent panes

* test: deduplicate Codex resume fixture tracking

* fix: harden Codex Teams watcher diagnostics

* test: pin Codex watcher diagnostic locale

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 21:57:41 -07:00
Austin Wangandcmux reload-cloud af519dd19b Bound overlapping agent hibernation evaluations (#9113)
* test: bound agent hibernation evaluations

* fix: serialize agent hibernation evaluations

* test: harden hibernation evaluation scheduling coverage

* docs: clarify hibernation gate recheck

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 21:22:36 -07:00
Austin Wang 4bf73202fa fix: preserve claude-teams tmux routing (#9033)
* test: preserve claude teams tmux launch context

* fix: preserve claude teams tmux routing

* fix: scope claude teams tmux routing

* fix: harden claude teams launch routing

* fix: close tmux compat review gaps

* fix: require inherited tmux launch identity

* fix: validate managed launcher context

* fix: preserve non-launch management commands

* fix: cover managed launcher aliases

* fix: validate remote managed launch context

* test: cover managed teams launch invariants

* fix: keep managed teams shims authoritative

* fix: preserve managed launcher compatibility

* fix: harden managed launch classification

* fix: reject ambiguous Claude debug filters

* fix: require context for session hosts

* fix: keep managed child identity coherent

* test: migrate OMO plugin without a session

* fix: preserve non-launch command compatibility

* fix: align managed launch policy ownership

* test: cover moved teams launch identity

* fix: honor shell snapshot argument contract

* fix: preserve managed launcher operator commands

* fix: preserve managed launcher shell contracts

* fix: close managed launcher review gaps

* test: keep focused cmux sockets below AF_UNIX limits

* fix: require launch context for ultrareview

* fix: preserve managed launcher compatibility

* fix: preserve Claude passthrough arguments

* fix: preserve managed provider passthrough

* test: cover managed launcher operator commands

* fix: preserve managed launcher team operators

* test: cover nested Codex Teams help

* fix: pass nested Codex Teams help through

* test: cover Claude Teams shell wrapper reentry

* fix: harden managed Teams launch identity

* test: consolidate managed Teams regressions

* test: cover managed provider administrative help

* fix: preserve managed provider administrative help

* test: cover Claude forward subagent text flag

* fix: recognize Claude forward subagent text flag

* test: cover OMO subcommand global options

* fix: preserve OMO subcommand global options

* test: keep Claude import surface-bound

* fix: require surface context for Claude import

* test: cover Codex Teams help subcommand

* fix: pass Codex Teams help through

* fix: preserve managed wrapper root help

* fix: apply retry binding predicate to both phases

* test: handle teammate column equalization

* test: model managed tmux focus changes

* test: expect tmux-compatible pane IDs

* fix: capture RPC session actor immutably
2026-07-29 21:22:05 -07:00
Austin Wang 8d6221348b Fix large paste truncation under transient backpressure (#9093)
* Fix large paste truncation under backpressure

* Fix Ghostty backpressure portability

* Pin GhosttyKit for backpressure fix

* Pin merged GhosttyKit archive
2026-07-29 21:11:53 -07:00
Austin Wangandcmux reload-cloud f003c63bae Prevent recursive deferred-action release chains (#9179)
* Add guard for stored DispatchWorkItem replacement chains

* Replace stored work items with deferred action scheduler

* Address deferred scheduler review findings

* Harden stored work item ownership guard

* Test deferred scheduler state transitions

* Cover deferred guard edge cases

* Preserve newest reentrant browser refresh

* Keep file explorer deinit actor-safe

* Bridge file explorer scheduler to main actor

* Enforce file explorer main actor ownership

* Close deferred scheduler review gaps

* Close deferred scheduler performance review

* Move deferred schedulers into CmuxFoundation

* Align deferred scheduler package APIs

* Add package tests for deferred schedulers

* Document deferred-action audit ownership

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 21:08:54 -07:00
Austin Wang 2229eb2028 Fix LiveSetting DynamicProperty executor crash (#9124)
* test(settings): cover LiveSetting isolation boundary

* fix(settings): remove DynamicProperty executor thunk

* refactor(settings): use signal-driven read lifetime

* test(settings): exercise DynamicProperty witness
2026-07-29 20:52:20 -07:00
Austin Wang 8b087a981b Fix Codex resume notification rebinding (#9185)
* test: cover Codex resume notification rebinding

* fix: preserve notifications across Codex resume
2026-07-29 19:55:35 -07:00
Austin Wangandcmux reload-cloud 0e80d0895d Fix stale SSH workspace connection status (#9085)
* test: cover authoritative SSH terminal liveness

* fix: derive SSH status from terminal liveness

* fix: close SSH terminal lifecycle races

* fix: authenticate SSH terminal readiness

* fix: bind SSH liveness to terminal authority

* fix: make Dock SSH readiness transactional

* test: assert remote terminal end acceptance

* test: reject retired PTY lifecycle readiness

* fix: revalidate PTY lifecycle at readiness commit

* test: cover remote readiness lifecycle races

* fix: harden remote terminal lifecycle ownership

* test: cover stale remote terminal generations

* fix: authenticate remote terminal lifecycle callbacks

* fix: bound remote lifecycle commit side effects

* chore: document remote lifecycle ownership boundaries

* test: cover reordered remote readiness callbacks

* fix: order remote terminal lifecycle callbacks

* test: cover remaining SSH lifecycle ordering gaps

* fix: close remaining SSH lifecycle ordering gaps

* test: cover lossy SSH liveness reconciliation

* fix: make SSH liveness reconciliation resilient

* test: cover remaining SSH liveness races

* fix: close remaining SSH liveness races

* test: cover Mosh and transient SSH readiness

* fix: make terminal readiness authoritative

* test: cover premature terminal readiness

* fix: require proven terminal readiness

* test: pass Dock readiness attempt generation

* test: cover remote lifecycle review regressions

* fix: preserve remote lifecycle routing

* fix: retire orphaned remote lifecycles

* test: cover raw SSH readiness gating

* fix: decouple raw SSH readiness reporting

* test: cover restored SSH lifecycle reporting

* fix: restore SSH lifecycle authority

* test: cover SSH lifecycle review regressions

* fix: close SSH lifecycle review gaps

* test: cover bounded SSH readiness lifecycle

* fix: bound persistent SSH readiness retries

* test: expose queued SSH readiness duplicates

* fix: coalesce persistent SSH readiness delivery

* fix: compile remote lifecycle app adapters

* fix: satisfy ssh readiness closeout policy

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:42:38 -07:00
Austin Wangandcmux reload-cloud c269269404 Fix OMP hook binding from live PID/TTY identity (#9091)
* test: expose OMP hook PID/TTY binding drift

* test: make OMP binding regression runnable

* fix: bind OMP hooks from live controlling TTY

* fix: harden OMP session reconciliation

* fix: expose shared hook binding helpers

* fix: negotiate and bound OMP hook delivery

* fix: isolate OMP binding and drain hook shutdown

* fix: preserve hook routing boundaries

* fix: make OMP cleanup recoverable

* fix: demote all superseded OMP claims

* fix: harden OMP hook lifecycle coverage

* Retry all superseded OMP cleanup records

* Bound OMP cleanup retries and preserve Stop hooks

* Harden superseded OMP cleanup ownership

* Preserve agent runtime across Dock ownership

* Test Dock agent session ownership gaps

* Test Dock binding-only lifecycle transfer

* Test Dock retry ownership across bindings

* Test Dock resume cwd binding ownership

* Test authoritative Dock binding clears

* Test Dock session and directory provenance

* Test completed Dock tombstone after binding clear

* Test managed Dock hook identity across tmux replacement

* Fix managed Dock hook identity across tmux replacement

* fix: disambiguate restorable agent selection

* fix: return workspace resume binding

* Split agent hook process binding types

* fix: preserve managed identity after retry revert

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:38:39 -07:00
Austin Wangandcmux reload-cloud 61c4f1077e Fix subprocess pipe descriptor leaks (#9187)
* Add subprocess pipe lifecycle regression tests (#9175)

* Fix subprocess pipe descriptor lifecycle (#9175)

* Harden subprocess lifecycle regression coverage

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:36:33 -07:00
Austin Wang 65b211ce28 Fix workspace group anchor numbering (#9176)
* test: cover workspace group numbered selection

* fix: align workspace numbering with sidebar rows

* perf: build workspace number index in one pass

* fix: keep workspace numbering outside view builder

* test: exercise numbered selection in key window
2026-07-29 19:35:38 -07:00
Austin Wangandcmux reload-cloud 92dda7db35 Chain SSH config RemoteCommand into cmux interactive sessions (#9114)
* Add failing SSH RemoteCommand chaining tests

* Chain SSH config RemoteCommand into interactive sessions

* Preserve SSH RemoteCommand across workspace restore

* Bound SSH config resolution and sanitize RemoteCommand

* Preserve RemoteCommand intent for fallback restores

* Reuse resolved SSH executable across startup

* fix(ssh): pin managed hops to system OpenSSH

* test(ssh): use Swift Testing for remote command regressions

* test: cover SSH config fallback and resume chaining

* fix: keep managed SSH launch and resume resilient

* test: cover SSH resume and config fallback precedence

* fix: define SSH command precedence during recovery

* test: cover authoritative SSH and Mosh fallbacks

* test: inspect generated SSH fallback artifact

* fix: preserve authoritative SSH and Mosh command fallbacks

* test: cover Mosh config-resolution fallback

* fix: retain SSH fallback when Mosh config is unavailable

* test: cover RemoteCommand token expansion

* test: cover raw RemoteCommand token output

* test: instantiate RemoteCommand policy fixture

* test: drop synthetic raw SSH config fixture

* docs: record SSH config token expansion contract

* test: cover SSH and Mosh fallback consistency

* fix: align SSH fallback transport and command intent

* test: execute restored RemoteCommand through SSH quoting

* fix: preserve RemoteCommand quoting across SSH restore

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 19:25:14 -07:00
Austin Wang 6e4093378c Retry restored SSH after boot-time network failure (#9083)
* test: reproduce restored SSH boot retry failure

* fix: retry initial SSH foreground authentication

* fix: classify retryable SSH authentication failures

* fix: preserve terminal SSH authentication errors

* test: cover bounded SSH auth diagnostics

* fix: bound SSH authentication diagnostics

* test: cover SSH auth retry phases

* fix: preserve established SSH auth retries

* test: cover SSH classifier edge cases

* fix: bound SSH diagnostic classification

* fix: preserve interactive SSH authentication

* fix: harden SSH authentication retry lifecycle

* test: cover signals during SSH retry backoff

* fix: make SSH retry backoff interruptible

* test: cover SSH PTY input during retry

* fix: preserve terminal input across SSH backoff

* test: cover SSH authentication process cleanup

* fix: terminate SSH authentication process trees

* test: require SSH authentication cleanup escalation

* fix: escalate SSH authentication process cleanup

* test: cover SSH startup signal during auth backoff

* fix: harden SSH retry signal cleanup

* test: exercise persistent SSH foreground auth lifecycle

* fix: support Sonoma SSH authentication capture

* test: bound SSH authentication process waits

* test: cover SSH classifier diagnostic overlap

* fix: refine SSH transport classification

* test: cover SSH server-alive timeout retry

* fix: retry SSH server-alive timeouts

* test: cover standard OpenSSH transport exits

* fix: classify standard OpenSSH transport exits

* test: align SSH retry coverage with project policy

* test: fail closed on unclassified SSH retries

* fix: fail closed on unclassified SSH retries

* test: cover SSH permission warnings and replacement cleanup

* fix: harden SSH diagnostics and process cleanup

* test: bound and anchor SSH authentication cleanup

* fix: bound and anchor SSH authentication cleanup

* test: ignore inherited SSH signal state

* fix: clear inherited SSH signal state
2026-07-29 19:23:48 -07:00
69efba1489 Reclaim hidden Ghostty renderer memory (#8998)
* Add five-tab renderer memory regression test

* Reclaim hidden terminal renderers by default

* Pin shared Metal pipeline Ghostty build

* Pin final Ghostty memory build

* Pin competitive Ghostty memory build

* Test renderer reclamation catalog defaults

* Use catalog renderer reclamation defaults

* test: require atomic first renderer presentation

* fix: make first renderer presentation atomic

* fix: resolve renderer defaults through catalog

* Exercise renderer defaults through UserDefaults

* Pin forced renderer rebuild Ghostty head

* Pin forced rebuild GhosttyKit checksum

* Test forced renderer rebuild presentation

* Preserve forced renderer rebuild presentation

* Make renderer defaults regression test throwable

* Pin merged Ghostty renderer reclamation head

* Pin final GhosttyKit checksum

* Pin reviewed Ghostty renderer retry fix

* Pin reviewed Ghostty shader cache follow-up

* Add red test for Ghostty Zig version drift

* Derive Zig version from pinned Ghostty

* Run Ghostty Zig version drift test in CI

* Test all Ghostty Zig workflow consumers

* Synchronize Ghostty Zig workflows

* Test Ghostty Zig helper as TestFlight input

* Track Ghostty Zig helper in TestFlight inputs

* Pin Ghostty shader failure backoff

* Pin Ghostty shader attempt backoff

* test: require renderer reclaim deadline scheduling

* test: initialize linked Ghostty runtime

* fix: schedule renderer reclaim at idle deadlines

* test: retain synthetic Ghostty argv

* fix: coalesce renderer visibility evaluation

* test: retain Ghostty runtime argv

* fix: wire renderer visibility coalescing

* Pin integrated Ghostty mailbox fix

* refactor: inject renderer reclaim scheduler inputs

* test: exercise renderer reclaim scheduler lifecycle

* fix: bound renderer visibility scheduling

* test: look up linked Ghostty runtime dynamically

* Validate per-consumer Ghostty Zig wiring

* test: require fail-closed Ghostty Zig workflows

* fix: fail closed on Ghostty Zig resolution

* fix: make renderer scheduling verification deterministic

* test: coalesce staggered renderer reclaim deadlines

* fix: coalesce renderer reclaim deadlines

* Update Ghostty renderer retry artifact

* test: measure five-tab renderer memory

* test: cover compatible Zig patch releases

* fix: accept compatible Zig patch releases

* refactor: separate renderer realization surface seam

---------

Co-authored-by: Austin Wang <[email protected]>
Co-authored-by: austinpower1258 <[email protected]>
2026-07-29 19:23:22 -07:00
Abdulaziz AlbaharandClaude Fable 5 8f17afcc1a iOS: mount the connection-status toast presenter at the shell root (#9159)
* 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]>
2026-07-29 21:22:17 -05:00
Abdulaziz Albahar ebd7638850 Keep iOS control connections warm across paired Macs (#8931)
* 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
2026-07-29 20:39:06 -05:00
Abdulaziz AlbaharandClaude Fable 5 d4a7bb27c5 Fix 9071 review P1s: adopt legacy device id on in-place upgrade, total-order challenge mints (#9196)
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]>
2026-07-29 20:33:09 -05:00
Abdulaziz AlbaharandClaude Fable 5 975c332ffa iOS: keep Mac discovery alive through onboarding so the connect page is ready on arrival (#9163)
* 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]>
2026-07-29 20:22:52 -05:00
Abdulaziz Albahar b2734c72b1 Fix CmuxMobileShellUI build: missing Bool return in verified-replay cancellation guard (#9195) 2026-07-29 20:12:18 -05:00
Abdulaziz Albahar 37c267ad16 Fix per-keystroke render-grid replay loop on iOS, instrument sync latency (#9146)
* Add iOS↔Mac sync latency tracing, probe, and analyzer

* Auto-navigate latency probe to first workspace (DEBUG)

* Add failing replay continuity and ack recency tests

* Preserve replay continuity and skip fresh ack resubscribe

* Address review: gate trace writes, fail-closed continuity, queued-input stamp

* Make latency stamps settle correctly and join by surface identity

* Address review: recovery retry, bounded trace writer, stamp identity fixes

* Address review: retry catch-up replay, bounded host trace writer, visible drops

* Address review: end-to-end bounded host sink, lazy trace tokens, FIFO wire joins
2026-07-30 00:59:45 +00:00
Abdulaziz AlbaharandClaude Fable 5 960dfe8a26 docs: make second-model review opt-in, not a handoff default (#9194)
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]>
2026-07-29 19:56:48 -05:00
Austin Wangandcmux reload-cloud 77c3da8e58 Make Cmd+Shift+T reopen the last closed item (#9132)
* test: cover Cmd-Shift-T closed window restore

* fix: reopen the last closed item with Cmd-Shift-T

* fix: keep legacy reopen binding consistent in Settings

* Preserve legacy shortcut unbindings

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-29 17:41:23 -07:00
oscarbrey 406c28bcab Fix Debug-build crash on macOS 26.5: non-finite event coordinates trap in sidebar divider diagnostics (#9156)
* 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.
2026-07-29 17:39:49 -07:00
Lawrence Chen f5f5d942f5 Fix TUI pointer motion during layout updates (#8709)
* fix(tui): gate input on surface attach readiness

* test(tui): cover redraw and config routing barriers

* fix(tui): render before replaying routed input

* refactor(tui): generalize event loop backend

* test(tui): cover deferred pointer ordering

* fix(tui): preserve deferred input order

* test(tui): cover cell pixel pointer barrier

* fix(tui): route cell pixel geometry updates

* test(tui): cover routing state boundaries

* fix(tui): separate pointer routes from destination intent

* test(tui): cover paint pointer route barrier

* fix(tui): guard pointer routes during paint

* test(tui): cover retained input ordering

* fix(tui): preserve pointer input across render barriers

* test(tui): cover replay requeue ordering

* fix(tui): preserve replay order across requeue

* test(tui): cover pointer frame and focus barriers

* fix(tui): bind deferred pointer input to rendered frames

* test(tui): cover trusted pointer frame barriers

* fix(tui): close trusted pointer frame gaps

* test(tui): cover merged pointer session lifecycle

* fix(tui): reset pointer frame state across sessions

* test(tui): expose browser release lifecycle gaps

* fix(tui): preserve browser release across session retirement

* test(tui): expose remaining pointer routing gaps

* fix(tui): close remaining pointer routing gaps

* test(tui): expose pointer semantic ownership gaps

* fix(tui): preserve pointer routing semantics

* test(tui): expose immediate pointer semantic race

* fix(tui): admit pointer input against terminal semantics

* test(tui): expose final pointer admission gaps

* fix(tui): close final pointer admission races

* test(tui): expose remaining deferred input races

* fix(tui): preserve deferred input identities

* test(tui): expose remaining pointer admission gaps

* fix(tui): stabilize pointer admission owners

* test(tui): expose replay lifecycle regressions

* fix(tui): close replay lifecycle gaps

* test(tui): reject stale pointer geometry

* fix(tui): guard and inline pointer encoding

* test(tui): expose pointer capture ownership gaps

* fix(tui): complete pointer capture ownership

* test(tui): reject replay across pane repaints

* fix(tui): bind pointer replay to rendered content

* test(tui): expose remaining pointer ordering races

* fix(tui): guard terminal pointer ordering

* fix(tui): gate browser input on presentation

* test(tui): expose stale presentation identities

* fix(tui): bind menus and graphics to presentation

* test(tui): expose UTF-8 mouse mode collision

* fix(tui): make mouse mode scan UTF-8 aware

* test(tui): expose graphics writer failure wedge

* fix(tui): settle failed graphics presentations

* test(tui): expose cross-session graphics collision

* fix(tui): scope graphics to machine sessions

* test(tui): expose browser pointer admission gaps

* fix(tui): guard browser pointer dispatch

* test(tui): expose remaining pointer ownership gaps

* fix(tui): preserve scoped pointer ownership

* test(tui): preserve browser drag ownership across frames

* fix(tui): preserve browser pointer captures

* test(tui): expose pointer lifecycle gaps

* fix(tui): close pointer lifecycle gaps

* test(tui): expose browser frame admission leak

* fix(tui): propagate browser pointer admission

* test(tui): expose pointer admission compatibility gaps

* fix(tui): close pointer admission compatibility gaps

* test(tui): expose reviewed pointer authority gaps

* test(tui): target guarded browser mouse shape

* test(tui): expose diagnostic pointer authority gap

* fix(tui): preserve pointer authority across boundaries

* test(tui): expose pointer barrier ownership races

* test(tui): expose ambiguous navigation rollback

* fix(tui): preserve pointer barriers across CDP races

* test(tui): expose frame epoch recovery gaps

* fix(tui): recover frame epoch transitions

* test(tui): expose pointer lifecycle ordering gaps

* fix(tui): reconcile pointer lifecycle epochs

* test(tui): expose pointer transition failure gaps

* fix(tui): settle failed pointer transitions

* test(tui): cover unresolved pointer authority transitions

* fix(tui): keep pointer authority fail-closed

* test(tui): cover download and mouse probe regressions

* fix(tui): settle downloads and gate mouse probes

* test(tui): cover document paint authority gaps

* test(tui): reject delayed pre-restart browser frames

* fix(tui): bind pointer authority to painted documents

* test(tui): advertise guarded pointer fixture capability

* test(tui): cover pointer authority recovery gaps

* fix(tui): recover guarded browser frame authority

* test(tui): cover remote screencast recovery bounds

* fix(tui): bound remote screencast recovery

* test(tui): cover bounded pointer recovery gaps

* fix(tui): bound pointer authority recovery

* test(tui): cover unresolved navigation authority gaps

* fix(tui): reconcile unresolved browser navigation

* test(tui): cover end-to-end pointer authority gaps

* test(tui): stop rejected frame recovery at ingress

* fix(tui): enforce end-to-end pointer authority

* test(tui): preserve guarded pointer wire compatibility

* fix(tui): negotiate guarded browser pointer attach

* test(tui): cover final pointer authority races

* fix(tui): close pointer authority races

* test(tui): cover remaining pointer authority gaps

* fix(tui): separate pointer authority from graphics processing

* test(tui): cover pointer worker lifecycle gaps

* fix(tui): close pointer worker lifecycle gaps

* test(tui): cover final pointer worker gaps

* fix(tui): eliminate idle pointer polling

* test(tui): cover pointer ownership invalidation

* fix(tui): preserve browser release ownership

* test(tui): cover remaining pointer ordering gaps

* fix(tui): close final pointer ordering gaps

* test(tui): cover graphics fence recovery

* fix(tui): recover graphics fence timeouts

* test(tui): cover final graphics lifecycle gaps

* fix(tui): bound graphics recovery lifecycle

* test(tui): cover pointer lifecycle liveness

* fix(tui): bound browser pointer ownership

* test(tui): cover final pointer recovery gaps

* fix(tui): close negotiated pointer lifecycles

* test(tui): cover same-document capture exhaustion

* fix(tui): surface capture recovery failures

* test(tui): cover terminal resize and hover recovery

* fix(tui): settle terminal pointer recovery

* test(tui): cover final pointer review findings

* fix(tui): close final pointer review findings

* test(tui): cover final navigation review findings

* fix(tui): preserve browser recovery authority

* test(tui): cover loaderless snapshot failure

* fix(tui): settle loaderless snapshot failures

* test(tui): cover rejected browser authority races

* fix(tui): reject stale browser authority

* test(tui): cover bootstrap snapshot invalidation

* fix(tui): retry invalidated bootstrap snapshots

* test(tui): reject unproven timestampless pixels

* fix(tui): verify each timestampless browser frame

* test(tui): cover stale capture suppression races

* fix(tui): scope browser capture suppression

* test(tui): release superseded capture reservations

* fix(tui): release superseded capture ownership

* test(tui): reject stale cleanup releases

* fix(tui): discard stale cleanup releases

* test(tui): hide stale attach pointer tokens

* fix(tui): mask stale attach pointer tokens

* test(tui): reject legacy guarded browser attach

* fix(tui): require scoped browser attach owner

* test(tui): retain timed-out image deletions

* test(tui): clean images after fence exhaustion

* fix(tui): preserve graphics cleanup after timeout

* test(tui): bound timestampless frame recovery

* fix(tui): throttle timestampless frame recovery

* test(tui): cover graphics backpressure cleanup

* fix(tui): bound graphics output ownership

* test(tui): cover unresolved output and navigation

* fix(tui): recover unresolved output and navigation

* test(tui): preserve browser timeout accounting

* fix(tui): preserve browser timeout accounting

* test(tui): keep verification failures terminal

* fix(tui): keep verification failures terminal

* test(tui): verify failed browser retries

* test(tui): keep failed browser input blocked

* test(tui): attach browser recovery epochs

* fix(tui): verify failed browser retries

* test(tui): retain committed navigation races

* test(tui): keep raced navigation paint

* test(tui): restore verified superseded navigation

* fix(tui): reconcile committed navigation races

* test(tui): cover stale browser bitmap authority

* fix(tui): bind pointer authority to exact browser bitmap

* test(tui): cover guarded pointer scheduling

* fix(tui): schedule guarded pointer input per surface

* test(tui): cover cross-surface browser input isolation

* fix(tui): isolate browser input by surface

* test(tui): cover browser pointer authority gaps

* fix(tui): preserve safe browser pointer authority

* test(tui): cover loaderless navigation snapshot race

* fix(tui): retry invalidated loaderless snapshots

* test(tui): cover impossible graphics response prefix

* fix(tui): replay impossible graphics prefixes

* test(tui): cover restart overtaking same-document navigation

* fix(tui): preserve queued same-document navigation

* test(tui): bound browser input workers

* fix(tui): bound browser input workers

* test(tui): cover state then frame attach coalescing

* fix(tui): coalesce attach state with newer frames

* test(tui): cover colliding browser surface workers

* fix(tui): schedule browser input per surface

* test(tui): cover scheduler fairness and global bounds

* fix(tui): bound and time-slice browser input

* test(tui): cover scheduler lifecycle regressions

* fix(tui): preserve bounded browser input lifecycles

* test(tui): cover end-to-end key press admission

* fix(tui): carry key presses through final queue

* test(tui): cover sustained graphics authority

* fix(tui): advance acknowledged graphics authority

* test(tui): cover final browser frame admission

* fix(tui): admit presented browser frame ranges

* test(tui): require presented pointer authority

* fix(tui): bind pointer input to presented frames

* test(tui): dedupe presentation acknowledgements

* fix(tui): publish presentation changes once

* test: preserve replaced navigation command order

* fix: preserve replacement browser command order

* test: cover resilient screencast resize barrier

* fix: fall back when screencast clock probe fails

* test: cover browser authority liveness gaps

* fix: bound browser authority lifecycles

* test(tui): cover bounded pointer authority recovery

* fix(tui): bound pointer authority retries

* test(tui): preserve deferred pointer ordering

* fix(tui): retain motion behind deferred pointer input

* test(tui): cover pointer recovery degradation

* fix(tui): recover bounded pointer fallbacks

* test(tui): cover pointer retry scheduling

* fix(tui): sleep until pointer release retry
2026-07-29 17:33:47 -07:00
Lawrence Chen bf6f113f36 Align TestFlight guard with scheduled uploads (#9170)
* test(ci): align TestFlight guard with scheduled uploads

* test(ci): execute TestFlight scheduling decision

* test(ci): cover whitespace-only workflow lines

* test(ci): execute TestFlight ordering guard

* test(ci): verify TestFlight artifact handoff

* test(ci): cover every TestFlight scheduling path

* test(ci): model post-upload workflow activity

* test(ci): cover manual TestFlight upload ordering

* test(ci): parameterize prior TestFlight event

* test(ci): cover TestFlight scheduling fail-open cases

* test(ci): model TestFlight upload history phases

* test(ci): cover TestFlight scheduling API failures

* test(ci): model transient TestFlight API failures

* test(ci): cover internal artifact and later-run ordering

* test(ci): parameterize TestFlight run ids

* test(ci): cover scoped and unchanged TestFlight runs

* test(ci): record TestFlight workflow queries

* test(ci): cover paginated TestFlight upload history

* test(ci): model paginated TestFlight history

* test(ci): cover TestFlight override artifact isolation

* test(ci): model TestFlight override input

* test(ci): exercise TestFlight concurrency mapping

* test(ci): parse TestFlight concurrency config
2026-07-29 17:33:19 -07:00
Austin Wang 3136fcabc2 Revert "feat: auto-retry failed agent sessions (#9024)" (#9184) 2026-07-29 16:29:44 -07:00
Abdulaziz AlbaharandClaude Fable 5 d2c80b4893 Fix iOS workspace-list scroll stutter from live updates (#9139)
* 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]>
2026-07-29 18:23:26 -05:00
Abdulaziz Albahar 93bfd5ea6e iOS: preserve terminal scroll position across mid-stream verified replays (#9032)
* 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
2026-07-29 17:26:04 -05:00
Abdulaziz AlbaharandClaude Fable 5 bd55030773 Drag the whole cmd-selection when dragging a selected sidebar workspace (#8933)
* 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]>
2026-07-29 17:25:50 -05:00
Abdulaziz AlbaharandClaude Fable 5 9c5c9adb5c Make the iOS notification feed scroll fast with thousands of items (#9141)
* 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]>
2026-07-29 16:45:16 -05:00
Abdulaziz AlbaharandClaude Fable 5 53bd1a082f Batch iOS TestFlight uploads on a schedule instead of per merge (#9165)
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]>
2026-07-29 16:31:52 -05:00
Abdulaziz AlbaharandClaude Fable 5 e8e1182a2b Show Game of Life backdrop on every onboarding page (#8880)
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]>
2026-07-29 16:04:14 -05:00
Abdulaziz AlbaharandClaude Fable 5 5d89b26093 Make iOS onboarding tour a swipeable horizontal pager (#9158)
* 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]>
2026-07-29 15:24:25 -05:00
Abdulaziz AlbaharandClaude Fable 5 87b072736c Route iOS connection statuses through toasts behind the beta flag (#8784)
* 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]>
2026-07-29 15:17:25 -05:00
Abdulaziz AlbaharandClaude Fable 5 3185b2be3d Add a dev target to the presence deploy workflow (#9161)
* 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]>
2026-07-29 14:58:33 -05:00
Abdulaziz Albahar 06356f2afa Render realistic onboarding iPhone frame
Render onboarding screenshots in a more realistic modern iPhone frame and increase the footer spacing above Continue by 6 points.
2026-07-29 14:45:16 -05:00
Abdulaziz Albahar 69d05f442f iOS: anchor the keyboard-rise render slide to the cursor row (#9133)
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.
2026-07-29 14:38:31 -05:00
Abdulaziz AlbaharandClaude Fable 5 7d870a43a6 docs: open iOS builds on the connected iPhone by default (#9160)
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]>
2026-07-29 14:28:44 -05:00
Abdulaziz AlbaharandClaude Fable 5 7200a8fdb6 Keep iOS New Task button clear of the bottom search pill (#9136)
* 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]>
2026-07-29 13:28:12 -05:00
Abdulaziz Albahar 800c3f6ebf Pipeline iOS terminal input over ordered mobile RPC (#9036)
* 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.
2026-07-29 12:55:46 -05:00
Austin Wang 2068b7c008 Fix SSH relay deadlock after app restart (#9105)
* test: keep SSH relay off shared ControlMaster (#8894)

* fix: give SSH reverse relay an app-owned transport (#8894)

* test: prove dedicated relay startup is reached (#8894)

* test: observe relay startup in regression scope (#8894)

* test: capture dedicated reverse relay launch argv (#8894)

* fix: cancel inherited SSH relay forwards (#8894)

* test: remove reverse relay launch seam (#8894)

* fix: keep relay cleanup off coordinator queue (#8894)

* fix: recover relay from inherited ControlMaster lease (#8894)

* docs: justify synchronous relay cancellation bridge (#8894)

* test: isolate relay recovery and sanitize status (#8894)

* test: cover successful relay conflict recovery (#8894)

* test: isolate reverse relay recovery launch (#8894)

* fix: recover relay through the shared SSH master (#8894)

* fix: coordinate conflicted SSH master recovery

* fix: make shared master reset recoverable

* fix: scope unresolved master reset events

* fix: resolve SSH master paths before reset

* fix: preserve retryable SSH reset state

* fix: bound reverse relay startup lifecycle

* fix: wait for SSH forward confirmation

* fix: retain shared SSH reset ownership

* fix: gate SSH master resets across processes

* fix: preserve SSH master lifecycle invariants

* fix: close SSH ownership coordination gaps

* fix: bound reverse relay termination

* fix: preserve SSH master recovery identity

* fix: cancel only inherited relay forwards

* fix: retry inherited forward recovery

* fix: bound SSH relay recovery lifecycle

* refactor: isolate SSH recovery lifecycle state

* Address SSH relay ownership review findings

* Fix app target remote session import

* fix: expose adopted SSH control path

* test: cover rotated relay auth recovery

* fix: recover rotated persistent relay leases

* test: cover inherited SSH master reap

* fix: reap inherited SSH masters after relay conflicts

* refactor: isolate inherited master reap types
2026-07-29 09:03:24 -07:00
Austin Wang 47b49e87f1 Merge pull request #9090 from manaflow-ai/issue-8997-memory-growth-panics
Hibernate idle agents before critical memory pressure panics
2026-07-29 08:58:06 -07:00
austinpower1258 235449d7be Eliminate Swift concurrency warnings 2026-07-29 07:37:55 -07:00
austinpower1258 e04c7f4f8b Fix hibernation process test compilation 2026-07-29 07:10:48 -07:00
austinpower1258 c1b19fb0e6 Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics 2026-07-29 06:53:14 -07:00
austinpower1258 812930e655 fix: make hibernation cleanup fallbacks bounded 2026-07-29 06:51:54 -07:00
austinpower1258 5037f250f8 test: cover hibernation cleanup fallback failures 2026-07-29 06:47:52 -07:00
austinpower1258 d212472f2e refactor: split agent hibernation panel types 2026-07-29 06:41:19 -07:00
austinpower1258 bb29d1470a test: repair merged main CI guards 2026-07-29 06:33:17 -07:00
Austin Wang 83bc021363 Prevent stalled remote PTY starts and bound wedged reattach loops (#9111)
* test(remote): reproduce PTY hub start wedge

* fix(remote): isolate persistent PTY session startup

* test: bound zero-progress SSH PTY reattach churn

* fix: stop zero-progress SSH PTY attach loops

* test: prove concurrent PTY starts coalesce

* Fix SSH PTY retry policy target ownership

* fix: bound managed SSH PTY retry churn

* fix: isolate stalled remote PTY attaches

* fix: close remote PTY attach teardown races

* fix: preserve remote PTY session generations

* fix: harden remote PTY lifecycle handoffs

* fix: bound PTY exit output draining

* test: preserve remote PTY capacity failures

* fix: retry remote PTY capacity failures

* test: bound remote PTY capacity recovery

* fix: bound remote PTY start waiters

* test: update persistent PTY retry contract

* test(remote): hide retained fast-exit generations

* fix(remote): separate retained and live PTY state

* test: preserve daemon transport after PTY attach timeout

* fix: isolate PTY attach call timeouts

* test: cancel timed-out remote PTY attaches

* fix(remote): cancel timed-out PTY attach requests

* fix(remote): bound PTY attach cancellation writes

* fix(remote): use cancellable attach timeout timers

* test(remote): pin canceled start publication race

* fix(remote): linearize PTY start waiter cancellation

* test(remote): assert timeout cancellation ordering

* test(remote): expose completed attachment context leak

* fix(remote): release completed attachment contexts

* test(remote): advertise attach cancellation in bridge fixture

* test(remote): expose canceled anonymous PTY retention

* fix(remote): terminate canceled anonymous PTY starts

* test(remote): distinguish replay from live PTY output

* fix(remote): exclude replay from attach progress

* test(remote): require cancellable attachment identities

* fix(remote): require cancellable attachment ids
2026-07-29 06:29:21 -07:00
austinpower1258 387fc94d92 Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics
# Conflicts:
#	Sources/DockSplitStore+Reset.swift
#	Sources/DockSplitStore.swift
#	Sources/Workspace+PanelLifecycle.swift
#	Sources/Workspace.swift
2026-07-29 06:18:37 -07:00
austinpower1258 7d9c8ee11b fix: hibernate idle agent panes under memory pressure 2026-07-29 06:15:42 -07:00
Lawrence Chenandcmux-lawrence 1e0aecd0a3 Add workspace-wide terminal font zoom shortcuts (#8791)
* 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]>
2026-07-29 03:53:32 -07:00
Abdulaziz Albaharandlawrencecchen 21e195088e Retire QuickLook previews after window loss (#8789)
* test: reproduce QuickLook reuse after window loss

* fix: retire stale QuickLook preview views

* test: tighten QuickLook lifecycle coverage

* test: exercise mounted QuickLook retirement

* test: keep QuickLook lifecycle setup in tests

* test: exercise deactivated QuickLook replacement

* fix: retire orphaned QuickLook previews

* test: reproduce QuickLook reuse after window close

* test: import QuickLook lifecycle types

* fix: own QuickLook preview lifecycle

* fix: close app-owned QuickLook views off-window

* refactor: modernize QuickLook lifecycle tests

* test: await file preview save completion

* test: use Swift Testing comment literals

* test: isolate file preview save from watcher

* test: keep async save coverage on XCTest

* test: isolate file preview save dispatch

* test: isolate preview shortcut override

* test: retain preview focus window through close

* test: restore absent preview shortcut override

* test: exercise the production preview editor

---------

Co-authored-by: lawrencecchen <[email protected]>
2026-07-29 02:58:38 -07:00
Abdulaziz AlbaharandClaude Fable 5 cf817f7e2d Bump iroh-ffi to 1.0.2-cmux.7: idle-path stall evidence fix (#9134)
* 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]>
2026-07-29 04:30:50 -05:00
Lawrence Chen 6f58efbdca Add cmux.com team-vault APIs for local Subrouter egress (#9099)
* Add authenticated Subrouter team-vault APIs

* Decouple Subrouter CLI login from transcript storage

* Log safe Subrouter control-plane failures

* Test Subrouter auth, validation, and team pagination

* Harden Subrouter auth and team authorization

* Test lease auth mode and bounded permission discovery

* Preserve lease constraints and bound team discovery

* Test fail-closed Subrouter authorization

* Harden Subrouter authorization and device consent

* Test Subrouter authorization review regressions

* Harden Subrouter device and team authorization

* Test Subrouter dashboard authorization recovery

* Render Subrouter authorization recovery state

* Test complete Subrouter authorization deadlines

* Bound cloud authorization and harden API edges
2026-07-29 02:03:33 -07:00
Austin Wang dec474e89d Merge pull request #9096 from manaflow-ai/issue-8627-html-click-render
Render Command-clicked HTML files in browser panes
2026-07-29 01:46:05 -07:00
Lawrence Chen 9a16a84c9e Add token multitasking workflow blog post (#9135) 2026-07-29 00:58:14 -07:00
austinywang 0c2c39703a fix: capture RPC session actor immutably 2026-07-29 00:34:59 -07:00
cmux reload-cloud e88486a21f Merge remote-tracking branch 'origin/main' into issue-8627-html-click-render 2026-07-28 23:59:25 -07:00
cmux reload-cloud 74b1dd9faf Distinguish non-web browser user agent policy 2026-07-28 23:59:12 -07:00
Lawrence Chen ae52684c53 Restore the Swift warning budget on current main (#9100)
* fix: isolate goto split focus observer

* fix: handle retry binding phases without overlap

* test: cover active retry binding identity

* test: respect retry coordinator boundary
2026-07-28 23:55:08 -07:00
Abdulaziz AlbaharandClaude Fable 5 058dc304f2 Scope iOS primary search to the search tab (#9129)
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]>
2026-07-29 01:34:25 -05:00
Abdulaziz AlbaharandClaude Fable 5 d40948e169 Fix iOS RPC cancellation reconnect churn (#8929)
* 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]>
2026-07-29 01:29:36 -05:00
cmux reload-cloud 1c848d8311 Add failing distinct user agent policy test 2026-07-28 23:29:35 -07:00
cmux reload-cloud fb56973857 Add failing local-file user agent policy test 2026-07-28 22:51:55 -07:00
Austin Wang 1dd429dd5c Emit Pi compact and subagent lifecycle Feed events (#9106)
* test: cover Pi compact and subagent feed events

* fix: emit Pi compact and subagent feed events
2026-07-28 22:48:58 -07:00
austinpower1258 37ce5fd980 test: preserve live Dock panel aliases during reconcile 2026-07-28 22:43:05 -07:00
Austin Wang aaddb97ffb Merge pull request #9115 from manaflow-ai/issue-8722-omp-restore-nested-sessions
Fix OMP restore tracking for nested task sessions
2026-07-28 22:25:02 -07:00
Austin Wang 4eee8cee33 Merge pull request #9098 from manaflow-ai/issue-6291-spinner-title-sidebar-freeze
Collapse spinner terminal titles before ingress dedup
2026-07-28 21:59:16 -07:00
Abdulaziz AlbaharandClaude Fable 5 4941384418 iOS: scroll-reveal the terminal files chip (#8928)
* 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]>
2026-07-28 23:42:38 -05:00
Abdulaziz Albahar 4574582563 iOS: stop terminal zoom + push-up during keyboard transitions (#8907)
* 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.
2026-07-28 23:39:51 -05:00
cmux reload-cloud 804add5084 Merge remote-tracking branch 'origin/main' into issue-8722-omp-restore-nested-sessions 2026-07-28 21:35:18 -07:00
Abdulaziz AlbaharandClaude Fable 5 8ba7781f8f Directed presence channel: server can wake the Mac (nudge push) (#9012)
* 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]>
2026-07-28 23:22:14 -05:00
Abdulaziz Albahar a20fc59edd Fix stale GhosttyKit in iOS reloads (#9121) 2026-07-28 23:18:40 -05:00
Abdulaziz AlbaharandClaude Fable 5 99cd94b98e iOS: make the files chip count exactly match the gallery rows (#8902)
* 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]>
2026-07-28 23:17:30 -05:00
austinpower1258 120713c6e0 fix: release restore monitors after teardown aborts 2026-07-28 21:08:25 -07:00
cmux reload-cloud 5480fdab69 Merge remote-tracking branch 'origin/main' into issue-8722-omp-restore-nested-sessions 2026-07-28 21:05:10 -07:00
austinpower1258 a751a4b2a9 perf: batch Dock panel ownership cleanup 2026-07-28 20:57:06 -07:00
austinpower1258 8e7796f44a test: cover incomplete hibernation process identity scope 2026-07-28 20:52:29 -07:00
Abdulaziz Albahar 246f6eec87 Validate production Iroh trust in release gates (#9118)
* test(iroh): expose retained production gate identity

* fix(iroh): validate production gate trust profile

* test(projects): cover synchronized workspace groups

* fix(projects): support synchronized workspace groups
2026-07-28 22:49:32 -05:00
cmux reload-cloud ade1331b4c Merge remote-tracking branch 'origin/main' into issue-8722-omp-restore-nested-sessions 2026-07-28 20:46:33 -07:00
Austin Wang 955df1465f Merge pull request #9117 from manaflow-ai/issue-9092-drainmailbox-livelock
Fix main-thread livelock under saturated terminal output
2026-07-28 20:42:43 -07:00
Abdulaziz AlbaharandClaude Fable 5 7587005cb6 iOS: stop the terminal files chip from flickering (#8822)
* 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]>
2026-07-28 22:41:03 -05:00
austinpower1258 4f6d2295dd fix: compile transcript revalidation closure 2026-07-28 20:40:59 -07:00
Austin Wang c30fc24a32 Merge pull request #9087 from manaflow-ai/issue-8971-find-next-navigate-search
Fix terminal Find Next navigation
2026-07-28 20:27:24 -07:00
austinpower1258 53914adf1f fix: make agent hibernation teardown irreversible 2026-07-28 20:27:13 -07:00
Austin Wang 88af67aff5 Merge pull request #9102 from manaflow-ai/issue-9056-ssh-tmux-pane-input-capture
Fix ssh-tmux sibling pane input capture
2026-07-28 20:23:34 -07:00
austinpower1258 cc98f6aa6b fix: bound Ghostty app mailbox drain turns 2026-07-28 20:22:57 -07:00
austinpower1258 6c9247df57 test: reproduce app mailbox drain starvation 2026-07-28 20:22:56 -07:00
austinpower1258 9fa57a4822 Merge remote-tracking branch 'origin/main' into issue-6291-spinner-title-sidebar-freeze 2026-07-28 20:12:10 -07:00
cmux reload-cloud eb98bead43 fix: ignore nested OMP artifact sessions 2026-07-28 20:08:13 -07:00
Abdulaziz AlbaharandClaude Fable 5 364da2a355 Bump iroh-ffi to 1.0.2-cmux.6: dead-path failover + make-before-break relay rotation (#9116)
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]>
2026-07-28 21:54:20 -05:00
Abdulaziz AlbaharandClaude Opus 4.8 1472990921 feat(iroh): complete release closeout and recovery hardening (#9071)
* 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]>
2026-07-28 21:41:40 -05:00
cmux reload-cloud 294d83ae13 test: reject nested OMP restore sessions 2026-07-28 18:09:18 -07:00
Lawrence Chenandcmux-lawrence c3e16646fc Add Windows and Linux download pages (#9070)
* 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]>
2026-07-28 17:53:35 -07:00
austinpower1258 76c6ebd2ea test: cover committed hibernation and Dock cleanup 2026-07-28 17:49:07 -07:00
Austin Wang cbcd505f75 Merge pull request #9046 from manaflow-ai/pr-2639-goto-split-cycle
fix: complete goto_split previous/next pane cycling
2026-07-28 17:47:24 -07:00
austinpower1258 1e897ff31e Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics
# Conflicts:
#	web/messages/en.json
#	web/messages/ja.json
2026-07-28 17:39:04 -07:00
Austin Wang 32f1e83324 Merge pull request #9086 from manaflow-ai/issue-9069-memory-attribution-helpers
Fix aggregate memory attribution across workspaces
2026-07-28 17:35:16 -07:00
austinpower1258 bb94ce5fdc Merge remote-tracking branch 'origin/main' into issue-9056-ssh-tmux-pane-input-capture
# Conflicts:
#	Sources/GhosttyNSView+PointerFocusActivation.swift
#	Sources/GhosttyTerminalView.swift
#	Sources/RemoteTmuxWindowMirror+FocusNavigation.swift
#	Sources/Workspace+RemoteTmuxControlTopology.swift
#	Sources/Workspace+SurfaceNavigation.swift
#	cmux.xcodeproj/project.pbxproj
#	cmuxTests/RemoteTmuxMirrorPaneInputMappingTests.swift
2026-07-28 17:29:30 -07:00
cmux reload-cloud a9331bdc65 test: isolate goto split config from user state 2026-07-28 17:20:25 -07:00
austinpower1258 ca7ca8fff2 fix: await emergency agent process exit 2026-07-28 17:19:42 -07:00
austinpower1258 262caf718c fix: project ssh-tmux pane input focus 2026-07-28 17:17:39 -07:00
Austin Wangandcmux reload-cloud 2757d9de84 Fix ssh-tmux focus after single-pane promotion (#9020)
* test: reproduce ssh-tmux single-pane focus failure

* fix: preserve ssh-tmux focus after pane promotion

* fix: reject stale remote tmux focus seeds

* test: expose promoted tmux container focus theft

* fix: project tmux container focus to active pane

* test: fail closed on stale nested tmux focus

* test: construct outer focus neighbor in mirror harness

* fix: fail closed on invalid nested tmux focus

* test: expose nested tmux key repair target

* fix: canonicalize nested tmux input focus

* perf: avoid tmux topology allocation during key repair

* test: use tmux mirror teardown API

* fix: fail closed on unresolved tmux focus

* fix: project all tmux terminal consumers

* fix: mirror tmux panes from initial attachment

* test: cover external tmux pane projection

* fix: project tmux panes through external inputs

* test: cover projected tmux metadata and notifications

* fix: propagate projected tmux panes to consumers

* fix: index projected tmux surface lookup

* test: cover projected tmux notification lifecycle

* fix: route projected tmux notification lifecycle

* fix: preserve projected tmux window recovery route

* fix: disambiguate projected terminal readiness

* test: cover projected tmux pointer and search focus

* fix: project tmux focus into Ghostty interactions

* test: cover projected tmux surface ownership

* fix: unify projected tmux surface ownership

* test: cover tmux focus handoff regressions

* fix: complete projected tmux focus handoff

* test: cover projected tmux focus lifecycle regressions

* fix: harden projected tmux focus lifecycle

* test: cover projected terminal ownership consumers

* fix: canonicalize projected terminal ownership consumers

* test: cover exact tmux split focus ownership

* fix: bind tmux split focus to command result

* test: cover projected focus confirmation boundaries

* test: cover projected focus rollback

* fix: confirm projected pane focus before dismissal

* fix: wait for authoritative notification focus

* test: cover projected trust boundary regressions

* fix: preserve projected tmux trust boundaries

* test: tolerate queued tmux topology commands

* fix: forward active projected pane focus to tmux

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-28 17:15:52 -07:00
austinpower1258 ca86a20690 fix: preserve non-spinner Braille titles 2026-07-28 17:13:40 -07:00
austinpower1258 50023de67b test: reproduce ssh-tmux sibling input capture 2026-07-28 17:13:04 -07:00
Austin Wangandcmux reload-cloud 4f5aafd973 feat: auto-retry failed agent sessions (#9024)
* feat: auto-retry failed agent sessions

* fix: preserve retry state through idle teardown

* refactor: align retry policy with package conventions

* fix: harden agent retry provenance and cleanup

* fix: bind retries to the ended shell command

* test: make browser quarantine recovery deterministic

* fix: address final agent retry review feedback

* fix: preserve classified retries through teardown

* fix: bind retries to authoritative ended sessions

* fix: correlate agent retries with shell generations

* fix: make agent retry ownership fail closed

* fix: wire retry state through surface transfers

* fix: clarify retry shell-state fallback

* fix: close agent retry review gaps

* test: cover agent retry event ordering

* fix: make agent retry ordering event-driven

* test: cover retry launch acknowledgement gaps

* fix: bound agent retry launch acknowledgement

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-28 17:10:05 -07:00
austinpower1258 e7a0727215 Merge remote-tracking branch 'origin/main' into issue-6291-spinner-title-sidebar-freeze 2026-07-28 17:04:28 -07:00
cmux reload-cloud 19697295ce test: isolate goto split UI bridge under debug support 2026-07-28 16:57:38 -07:00
cmux reload-cloud 006ed142ad Merge origin/main into pr-2639-goto-split-cycle 2026-07-28 16:57:24 -07:00
cmux reload-cloud 5f98f961b5 Isolate terminal file routing defaults 2026-07-28 16:51:10 -07:00
austinpower1258andMaxx Yung 0b5c0faf79 fix: collapse spinner titles before ingress dedup
Co-authored-by: Maxx Yung <[email protected]>
2026-07-28 16:50:30 -07:00
Austin Wang d61e6688ef Instrument direct Codex fork sessions (#9081)
* test: cover direct Codex fork hook injection

* fix: instrument direct Codex fork sessions

* docs: name Codex exec alias in wrapper comment
2026-07-28 16:46:28 -07:00
austinpower1258 5839b0e9c9 Merge remote-tracking branch 'origin/main' into issue-8997-memory-growth-panics 2026-07-28 16:43:54 -07:00
austinpower1258 4d5f1428a2 fix: harden emergency agent hibernation 2026-07-28 16:43:48 -07:00
austinpower1258 28f6255982 test: expose spinner title ingress churn 2026-07-28 16:43:16 -07:00
cmux reload-cloud 9f0472dcd4 Render terminal-linked HTML in browser panes 2026-07-28 16:37:16 -07:00
cmux reload-cloud 147d96f417 Add failing HTML terminal click routing tests 2026-07-28 16:34:22 -07:00
Myk MelezandClaude Opus 4.6 72a457cad3 fix: goto_split:previous/next cycle through all panes with wrapping (#2639)
* 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]>
2026-07-28 16:33:07 -07:00
Austin Wangandcmux reload-cloud 8697445d61 Fix detached shell reports under cmuxOnly (#9077)
* test: cover detached shell report authentication

* fix: authenticate detached shell reports

* ci: provision fish for shell integration regression

* fix: keep socket capability out of tmux

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-28 16:31:47 -07:00
austinpower1258 48d13aacef fix: navigate terminal search results 2026-07-28 16:26:21 -07:00
cmux reload-cloud b34c4c3af0 Address memory attribution review findings 2026-07-28 16:19:31 -07:00
austinpower1258 102e733026 fix: retain confirmed process generations 2026-07-28 16:18:14 -07:00
austinpower1258 4cfff09d8d fix: harden critical-pressure agent teardown 2026-07-28 16:16:03 -07:00
Austin Wang 5ce75aec0a Fix TextBox selection replacement under refresh churn (#9084)
* test: cover textbox selection replacement refresh

* fix: keep textbox editor authoritative while mounted
2026-07-28 16:15:46 -07:00
cmux reload-cloud 1e55a10166 Expose memory group accumulator initializer 2026-07-28 16:06:12 -07:00
austinpower1258 131781c47b fix: hibernate safe agents under critical pressure 2026-07-28 16:04:03 -07:00
austinpower1258 f3ad9eba08 test: cover terminal find navigation actions 2026-07-28 15:59:06 -07:00
cmux reload-cloud 921d783eb7 Fix aggregate memory ownership reporting 2026-07-28 15:51:53 -07:00
austinpower1258 a9069343ab test: cover critical-pressure agent reclamation 2026-07-28 15:44:36 -07:00
cmux reload-cloud ab3bbb2a7f Add failing memory attribution aggregation tests 2026-07-28 15:42:53 -07:00
Austin Wang 64ec2bbf07 Merge pull request #9064 from manaflow-ai/issue-9059-resume-notification-reliability
Keep resumed agent notification hooks armed during restore
2026-07-28 15:18:05 -07:00
Abdulaziz Albaharandlawrencecchen 664c6e1fb7 Stop XCTest crashes from reaching Sentry (#8786)
* test: cover Sentry startup under XCTest

* fix: disable macOS Sentry under XCTest

* Make Sentry startup policy constructable

* test: cover sandbox-denied CLI socket telemetry

* fix: drop sandbox-denied CLI socket telemetry

* fix: allow explicit Sentry opt-in under XCTest

* Address Sentry startup review policy

* Close Sentry test launch review gaps

* Require provenance for Sentry suppression

* Document Sentry suppression contract

---------

Co-authored-by: lawrencecchen <[email protected]>
2026-07-28 14:40:37 -07:00
Austin Wang 27e2050046 Merge pull request #9063 from manaflow-ai/issue-8938-dim-terminal-text
Fix occasional dim terminal text after appearance changes
2026-07-28 14:04:00 -07:00
Austin Wang da093cc84d Merge pull request #9054 from manaflow-ai/issue-9052-browser-ua-google-workspace
Fix browser UA compatibility without blurring Google Sheets
2026-07-28 14:02:24 -07:00
Abdulaziz Albahar ce84b6ffa0 Frame iOS onboarding screenshots (#8932)
* Add signed-out onboarding regression test

* Frame onboarding screenshots on iPhone

* Update workspace list test defaults

* Clarify same-account onboarding copy

* Separate signed-out onboarding analytics
2026-07-28 15:13:01 -05:00
cmux reload-cloud a6449e5a95 Merge remote-tracking branch 'origin/main' into issue-8938-dim-terminal-text 2026-07-28 12:42:58 -07:00
austinpower1258 c9e336824e refactor: avoid optional version components 2026-07-28 12:34:05 -07:00
austinpower1258 2dfc28ad92 fix: floor Safari compatibility identity 2026-07-28 12:26:05 -07:00
cmux reload-cloud df0e75348d Fix terminal appearance reload ordering 2026-07-28 12:19:43 -07:00
austinpower1258 7ff6d68cfe test: reject stale Safari compatibility identity 2026-07-28 12:00:08 -07:00
Austin Wang 352e6dc894 Merge pull request #9048 from manaflow-ai/issue-9041-dock-cwd-inheritance
Fix Dock terminal cwd inheritance from Ghostty reports
2026-07-28 11:32:59 -07:00
Austin Wang d611565aec Merge pull request #9047 from manaflow-ai/issue-9040-dock-notification-ring
Fix Dock agent-finished notification rings
2026-07-28 11:32:01 -07:00
Abdulaziz Albahar 9946d58743 Fix iOS TestFlight crash paths (#9034)
* Fix iOS surface callbacks and menu layout crashes

* Address iOS surface callback ownership review
2026-07-28 10:14:22 -05:00
cmux reload-cloud 8b54723d68 docs: show restore launch construction 2026-07-28 08:14:19 -07:00
cmux reload-cloud fe9033e6c7 fix: route authorized restores through wrappers 2026-07-28 08:09:58 -07:00
cmux reload-cloud 919d89c7ce fix: bind restore authorization to agent session 2026-07-28 07:22:23 -07:00
cmux reload-cloud 1753157fd6 test: close resume authorization gaps 2026-07-28 07:19:36 -07:00
cmux reload-cloud d5f5f3a03d fix: trust only app-owned resume launches 2026-07-28 06:54:26 -07:00
cmux reload-cloud fdb8acf5e4 Test appearance reload config ordering 2026-07-28 06:47:45 -07:00
cmux reload-cloud 93314ac639 fix: resolve shared live index on main actor 2026-07-28 06:36:44 -07:00
cmux reload-cloud b7f87c81ed test: cover app-level Dock split bypass 2026-07-28 06:29:55 -07:00
cmux reload-cloud eeefd5ed5e test: remove Dock attention global state 2026-07-28 06:25:32 -07:00
cmux reload-cloud 864b40c848 fix: scope Dock unread rendering updates 2026-07-28 06:19:59 -07:00
cmux reload-cloud 5520ef121d fix: scope resume hook bypass to explicit restores 2026-07-28 06:11:24 -07:00
cmux reload-cloud 6e86c0961e test: isolate Dock notification routing 2026-07-28 06:05:53 -07:00
cmux reload-cloud 7995994f71 fix: isolate goto split cycle navigation support 2026-07-28 06:02:10 -07:00
cmux reload-cloud 4038cdb517 fix: preserve Dock split identifier types 2026-07-28 06:01:28 -07:00
cmux reload-cloud a2233e5b38 fix: keep resumed agent notification hooks armed 2026-07-28 05:56:52 -07:00
cmux reload-cloud 9a517b7a34 Merge remote-tracking branch 'origin/main' into issue-9040-dock-notification-ring
# Conflicts:
#	Sources/DockPanelView.swift
2026-07-28 05:51:51 -07:00
cmux reload-cloud cade42d88c test: restore split-gated Dock routing coverage 2026-07-28 05:50:01 -07:00
cmux reload-cloud b4890a215f Merge remote-tracking branch 'origin/main' into issue-8938-dim-terminal-text 2026-07-28 05:41:32 -07:00
austinpower1258 7920780e18 fix: isolate live agent index default 2026-07-28 05:41:19 -07:00
cmux reload-cloud 3b9a38a509 test: expose resumed agent hook startup race 2026-07-28 05:37:53 -07:00
cmux reload-cloud f9eba1928f test: cover Dock unread panel composition 2026-07-28 05:33:46 -07:00
cmux reload-cloud 6ae6af110a test: avoid AppDelegate global in Dock routing 2026-07-28 05:23:45 -07:00
cmux reload-cloud 1448e4aa4c fix: replay deferred terminal appearance updates 2026-07-28 05:18:19 -07:00
cmux reload-cloud 4a3b9841d1 test: isolate Dock attention AppDelegate 2026-07-28 05:13:48 -07:00
austinpower1258 4d9ebc7a60 refactor: share user-agent navigation replay 2026-07-28 05:05:56 -07:00
austinpower1258 0f35c401e6 fix: preserve automation across deferred UA replay 2026-07-28 05:00:05 -07:00
cmux reload-cloud 71fee8ffb1 test: cover reentrant appearance synchronization 2026-07-28 04:58:19 -07:00
cmux reload-cloud 82fc45451c refactor: split Dock unread projection source 2026-07-28 04:50:45 -07:00
Lawrence Chen cdd84ba930 Allow npm publish while main advances (#9058)
Accept a workflow commit that remains in protected main history while preserving the separate SDK source-drift gate.
2026-07-28 04:44:11 -07:00
austinpower1258 11c6efee86 fix: replay navigation after user-agent changes 2026-07-28 04:43:30 -07:00
Austin Wang 42f9faed6a Merge pull request #8693 from manaflow-ai/issue-8672-pi-extension-spawnsync-blocking
Fix blocking Pi extension hook dispatch
2026-07-28 04:41:17 -07:00
Austin Wang b27cc2d0e6 Merge pull request #9051 from manaflow-ai/issue-9042-dock-pane-click-focus
Fix Dock terminal pointer focus
2026-07-28 04:40:26 -07:00
Lawrence Chen 767c60e008 Route SDK npm releases through trusted publisher (#9057)
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.
2026-07-28 04:40:18 -07:00
cmux reload-cloud 2b722157f8 fix: move Dock unread projection off view updates 2026-07-28 04:39:49 -07:00
cmux reload-cloud dc62f7de1b fix: gate goto split UI setup on focus notification 2026-07-28 04:31:05 -07:00
cmux reload-cloud 646ab31f0b fix: avoid fork availability actor warning 2026-07-28 04:21:49 -07:00
cmux reload-cloud 34d7e34b7f ci: gate Dock notification regressions 2026-07-28 04:21:49 -07:00
cmux reload-cloud 7a0fd02a7a fix: scope Dock unread projection updates 2026-07-28 04:21:49 -07:00
Austin Wang 90d63678bb Fix Dock pointer focus activation 2026-07-28 04:14:41 -07:00
cmux reload-cloud d9a5baefb7 fix: address goto split cycle review feedback 2026-07-28 04:14:10 -07:00
Lawrence Chen a70bd86f2b Fix SDK publish Rust toolchain (#9055)
Pin the npm and PyPI SDK publish jobs to Rust 1.95.0 so cmux-tui builds consistently instead of inheriting the runner's Rust 1.92.0.
2026-07-28 04:12:45 -07:00
cmux reload-cloud 6158f077e4 fix: invalidate dock cwd cache on moves 2026-07-28 03:59:43 -07:00
cmux reload-cloud f9e62286e5 test: isolate Dock ring settings 2026-07-28 03:47:34 -07:00
Austin Wang a4af4d7f9a Test Dock terminal pointer focus activation 2026-07-28 03:46:57 -07:00
cmux reload-cloud e03b4e7711 test: make Dock notification coverage CI-enforceable 2026-07-28 03:40:34 -07:00
austinpower1258 0478aed897 fix: use site-aware browser identity 2026-07-28 03:38:42 -07:00
cmux reload-cloud 39c5cbe10e fix: keep dock cwd cache authoritative 2026-07-28 03:35:50 -07:00
austinpower1258 f74bef3e38 test: cover browser user-agent compatibility policy 2026-07-28 03:31:55 -07:00
cmux reload-cloud d5b55ce4af fix: render Dock notification attention rings 2026-07-28 03:29:28 -07:00
cmux reload-cloud cdd8c9b429 fix: preserve inherited cwd path bytes 2026-07-28 03:26:48 -07:00
Austin Wang 877057f83b Merge pull request #9026 from manaflow-ai/fix/issue-8843-reentrant-layout-loop
Stop reentrant focus storms (#8843)
2026-07-28 03:09:41 -07:00
cmux reload-cloud ec8b929d22 fix: preserve dock cwd review invariants 2026-07-28 03:09:08 -07:00
cmux reload-cloud f7ee159e1d test: cover Dock unread notification ring 2026-07-28 03:08:29 -07:00
cmux reload-cloud 063957bfdd fix: make terminal cwd independent of container 2026-07-28 02:35:58 -07:00
cmux reload-cloud fb7a0016f7 test: expose Dock PWD report inheritance gap 2026-07-28 02:33:29 -07:00
cmux reload-cloud d0a7bbfd74 fix goto split cycle verification gaps 2026-07-28 02:14:44 -07:00
Austin Wang df485ff825 Merge pull request #8884 from manaflow-ai/issue-8872-io-gather-poll-spin
Fix io-gather spin and reap dead pane children
2026-07-28 02:06:59 -07:00
austinpower1258 2dfacfc697 fix: carry first responder focus transactions (#8843) 2026-07-28 02:05:46 -07:00
Lawrence Chen 5fa4fac857 Fix Vim Mode cursor and selection rendering (#8995)
* Add failing Vim cursor appearance regression

* Render Vim cursor in terminal cell coordinates

* Add failing Vim grid alignment regression

* Align Vim overlays to Ghostty grid origin

* Add failing visual jump selection regression

* Keep Vim visual endpoints synchronized

* Use Ghostty-native Vim mode geometry

* Make Vim mode navigation terminal-native

* Fix Vim cursor geometry test initializer

* Keep Vim mode cursor synchronized with Ghostty

* test: cover empty Vim mode clipboard selections

* fix: copy empty Vim mode selections

* test: bound rendered frame delivery hops

* fix: coalesce rendered frame delivery at source

* Preserve bounded rich Vim mode copies

* Test mixed terminal clipboard representations

* Publish rich terminal clipboard representations

* Document bounded clipboard representation fallback

* Refine bounded renderer delivery architecture

* Resolve final renderer review findings

* test: reject oversized rich clipboard payloads

* fix: bound rich clipboard decoding

* test: separate cursor frame demand

* fix: scope copy-mode frame delivery

* test: preserve preferred clipboard representations

* fix: preserve preferred clipboard formats

* test: wait for screenshot quarantine state
2026-07-28 02:02:55 -07:00
Austin Wang 60d0e073b7 Merge pull request #6960 from manaflow-ai/issue-5919-settings-desktop-notifications-row-stuck-on-p
Fix desktop notification permission settings row
2026-07-28 01:36:35 -07:00
Austin Wang c242b7a541 Merge pull request #9037 from manaflow-ai/issue-9029-fork-menu-first-click
Fix first-open Fork Conversation tab menu availability
2026-07-28 01:33:24 -07:00
austinpower1258 16ec386b24 fix: preserve first responder focus transactions (#8843) 2026-07-28 01:09:42 -07:00
Austin Wang 047e346c1f Merge pull request #9031 from manaflow-ai/fix/issue-9027-sticky-identity-cmdn
Fix sticky workspace identity leaking into Cmd+N
2026-07-28 00:47:26 -07:00
austinpower1258 d6426ebaab fix: key focus breaker by transaction (#8843) 2026-07-28 00:30:39 -07:00
Austin Wang 33b4f7dfd2 Merge pull request #8909 from ejc3/fix/four-single-assertion-stale-tests
cmuxTests: catch three suites up to shipped behavior
2026-07-28 00:17:09 -07:00
cmux reload-cloud 1d3d90e641 Make failed restore customization test deterministic 2026-07-27 23:46:30 -07:00
austinpower1258 877f4ced43 Preserve fork menu validation fallback identity 2026-07-27 23:42:41 -07:00
austinpower1258 18880090b2 fix: hold focus breaker open after recovery (#8843) 2026-07-27 23:29:00 -07:00
austinpower1258 fdd9d7f2b3 Merge origin/main into issue-9029-fork-menu-first-click 2026-07-27 23:28:40 -07:00
austinpower1258 23e9cd13f3 Fix first-open fork conversation menu availability 2026-07-27 23:25:24 -07:00
Austin Wang 192e44428c Merge pull request #9018 from manaflow-ai/issue-9010-layout-surface-order
Fix saved layout surface ordering
2026-07-27 23:18:32 -07:00
Austin Wang 0aec6ce357 Merge pull request #8912 from ejc3/fix/pi-resume-keeps-launch-arguments
Resuming a restored pi session keeps its launch arguments
2026-07-27 23:09:33 -07:00
austinpower1258 cf9f72e9c4 fix: rate-limit focus breaker continuations (#8843) 2026-07-27 23:09:06 -07:00
austinpower1258 714d244220 Fix closed-peer socket deadline handling 2026-07-27 22:53:09 -07:00
cmux reload-cloud e990970e6f Move workspace customization mode to its own file 2026-07-27 22:52:18 -07:00
austinpower1258 3549e34a5d fix: preserve final focus broadcast on breaker (#8843) 2026-07-27 22:51:19 -07:00
cmux reload-cloud 5a8ca39d82 Merge remote-tracking branch 'origin/main' into fix/issue-9027-sticky-identity-cmdn 2026-07-27 22:50:45 -07:00
cmux reload-cloud d51c18d0d7 Stop applying sticky identity to fresh workspaces (#9027) 2026-07-27 22:44:34 -07:00
Austin Wang 6cc224e1e9 Merge pull request #9019 from manaflow-ai/issue-9015-ssh-slot-rejoin-respawned-daemon
Fix SSH reconnect churn after persistent daemon respawn
2026-07-27 22:33:08 -07:00
austinpower1258 8d4cea551f fix: address focus broadcaster review feedback (#8843) 2026-07-27 22:12:35 -07:00
cmux reload-cloud aa943918a8 Add regression test for sticky workspace creation 2026-07-27 21:57:38 -07:00
Austin Wang 7842ec8368 test: cover registered agent resume selectors 2026-07-27 21:36:53 -07:00
cmux reload-cloud c2f7bd63b5 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-9015-ssh-slot-rejoin-respawned-daemon 2026-07-27 20:50:53 -07:00
austinpower1258 5b298b62e9 fix: stop reentrant focus storms (#8843) 2026-07-27 20:30:19 -07:00
austinpower1258 51fa714886 test: cover reentrant focus circuit breaker (#8843) 2026-07-27 20:01:03 -07:00
Austin Wang fe57d57176 Merge pull request #8867 from ejc3/fix/cli-spawn-timeout-not-a-speed-gate
cmuxTests: stop grading the CLI spawn on a five-second stopwatch
2026-07-27 20:00:17 -07:00
Austin Wang 1d2e1fa378 Merge pull request #8692 from manaflow-ai/issue-8652-file-preview-refresh
Add refresh controls for file previews
2026-07-27 19:36:53 -07:00
Austin Wang 84c2c022ba Merge pull request #8851 from ejc3/fix/filedrop-overlay-host-crash
cmuxTests: stop FileDropOverlayViewTests killing its test host on a missing environment object
2026-07-27 19:35:18 -07:00
cmux reload-cloud 383e4332f9 test: clarify auth failure writer fixture 2026-07-27 17:57:01 -07:00
cmux reload-cloud 3e01767137 fix: surface daemon auth response write failures 2026-07-27 17:51:11 -07:00
Austin Wang ff63482be8 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p 2026-07-27 17:48:18 -07:00
Lawrence Chen e50bba33c9 Add horizontally scrolling columns to cmux-tui (#8850)
* Add horizontal scrolling layout to cmux-tui

* Make horizontal panes command-driven

* Add horizontal viewport regression coverage

* Make horizontal viewport columns resilient and resizable

* Make horizontal columns first-class and undoable

* fix(cmux-tui): harden horizontal viewport behavior

* fix(cmux-tui): preserve viewport navigation invariants

* fix(cmux-tui): harden wide viewport interactions

* fix(cmux-tui): close horizontal viewport review gaps

* fix(cmux-tui): preserve viewport compatibility contracts

* fix(cmux-tui): skip unchanged resize mutations

* fix(cmux-tui): harden viewport failure boundaries

* fix(cmux-tui): preserve focus and localize layout failures

* test(cmux-tui): cover resize owner collisions

* fix(cmux-tui): isolate resize transaction owners

* test(cmux-tui): cover clipped browser frame hot paths

* fix(cmux-tui): keep clipped browser editing responsive

* test(cmux-tui): repair current smoke expectations

* test(cmux-tui): cover stack selection across layout undo

* fix(cmux-tui): preserve stack focus through layout undo

* test(cmux-tui): cover clamped viewport ratio updates

* fix(cmux-tui): preserve authoritative resize state

* test(cmux-tui): cover wide status labels beside scrollbar

* fix(cmux-tui): measure status labels in terminal cells

* test(cmux-tui): cover scrollbar anchors and undo flags

* fix(cmux-tui): anchor scrollbar drags and undo flags

* test(cmux-tui): cover stable resize and emoji geometry

* fix(cmux-tui): freeze resize geometry and cell widths

* test(cmux-tui): cover efficient animated chrome rendering

* fix(cmux-tui): isolate animated viewport painting

* test(cmux-tui): cover browser crops and abandoned resize

* fix(cmux-tui): preserve image crops and resize undo fences

* test(cmux-tui): cover zoom focus restoration

* fix(cmux-tui): align restored zoom and focus

* test(cmux-tui): cover exact-fit status labels

* fix(cmux-tui): render exact-fit status labels

* test(cmux-tui): cover swept viewport leases

* fix(cmux-tui): lease swept viewport panes

* test(cmux-tui): cover localized finite width errors

* fix(cmux-tui): localize viewport width errors

* test(cmux-tui): cover distant jumps and wide projections

* fix(cmux-tui): bound jumps and preserve projections

* test(cmux-tui): cover additive binding fields

* fix(cmux-tui): expose additive binding metadata

* test(cmux-tui): cover bounded localized rendering

* fix(cmux-tui): bound and localize render inputs

* test(cmux-tui): cover autoreview regressions

* fix(cmux-tui): close autoreview correctness gaps

* test(cmux-tui): reject invalid SDK pane widths

* fix(cmux-tui): validate SDK pane widths locally

* test(cmux-tui): preserve nullable pane width

* fix(cmux-tui): preserve nullable pane width

* test(cmux-tui): require breaking Rust SDK version

* fix(cmux-tui): version Rust SDK error shape

* test(cmux-tui): reject invalid SDK viewport widths

* fix(cmux-tui): validate remaining SDK viewport widths

* test(cmux-tui): validate Go viewport resize width

* fix(cmux-tui): validate Go viewport resize width

* test(cmux-tui): cover release version and locale contracts

* fix(cmux-tui): synchronize SDK versions and locale

* test(cmux-tui): reject unsafe undo previews

* fix(cmux-tui): reject unsafe undo previews

* test(cmux-tui): enforce complete undo result contracts

* fix(cmux-tui): centralize layout undo decoding

* test(cmux-tui): bound viewport reclip work

* fix(cmux-tui): index animated viewport projections

* test(cmux-tui): type layout undo edge failures

* fix(cmux-tui): type layout undo race failures

* test(cmux-tui): cover missing browser CSS dimensions

* fix(cmux-tui): accept missing browser CSS dimensions

* test(cmux-tui): keep SDK runbook version synchronized

* docs(cmux-tui): update SDK release version
2026-07-27 17:46:33 -07:00
cmux reload-cloud b9cbd0db89 fix: refine daemon auth rejection diagnostics 2026-07-27 17:37:15 -07:00
austinpower1258 2eea0805e0 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-8672-pi-extension-spawnsync-blocking 2026-07-27 17:34:37 -07:00
cmux reload-cloud 812fbb5947 test: isolate layout placement coverage 2026-07-27 17:33:40 -07:00
cmux reload-cloud 803f841da8 review: preserve placeholder replacement order 2026-07-27 17:23:54 -07:00
Lawrence Chen 601ef51758 Fix leaked openThread loops burning ~90% of cmux idle CPU (#9000)
* 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.
2026-07-27 17:23:25 -07:00
cmux reload-cloud 29cb9cb5bd Merge remote-tracking branch 'origin/main' into issue-9015-ssh-slot-rejoin-respawned-daemon 2026-07-27 17:22:27 -07:00
cmux reload-cloud 2692309225 review: scope declarative tab placement 2026-07-27 17:21:05 -07:00
cmux reload-cloud ffaeb08df5 fix: keep daemon heartbeats behind active RPCs 2026-07-27 17:20:57 -07:00
austinpower1258 5b141ba353 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-8672-pi-extension-spawnsync-blocking 2026-07-27 17:20:44 -07:00
cmux reload-cloud c3f0a54316 test: reproduce SSH daemon keepalive churn after respawn 2026-07-27 17:14:39 -07:00
cmux reload-cloud d031a707fa fix: preserve saved layout surface order 2026-07-27 17:11:10 -07:00
Austin Wang 2aa4f6f905 fix: classify exact built-in agent registrations 2026-07-27 17:06:25 -07:00
Austin Wang d74fb67597 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p 2026-07-27 16:56:06 -07:00
cmux reload-cloud 07afbd71cc test: cover saved layout surface order 2026-07-27 16:42:54 -07:00
cmux reload-cloud c7717be2f1 Merge remote-tracking branch 'origin/main' into fix/cli-spawn-timeout-not-a-speed-gate 2026-07-27 16:42:40 -07:00
austinpower1258 bb1488c2c8 Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking
# Conflicts:
#	CLI/cmux.swift
#	Packages/iOS/CmuxMobileTerminalKit/Tests/CmuxMobileTerminalKitTests/TerminalDockKeyboardTransitionPlannerTests.swift
#	cmux.xcodeproj/project.pbxproj
2026-07-27 16:29:18 -07:00
Austin Wang 18396f2c06 Merge pull request #8913 from ejc3/fix/settings-store-appearance-decoupling
Settings store imports appearance at init instead of live-applying it (restores 70bcbda20e)
2026-07-27 16:24:22 -07:00
cmux reload-cloud f13c715f47 Keep CLI regression test below file budget 2026-07-27 16:23:21 -07:00
cmux reload-cloud 1cc5c84026 Bound CLI test hang detection 2026-07-27 16:21:38 -07:00
cmux reload-cloud a79a39a13f Clarify CLI test liveness deadline 2026-07-27 16:14:13 -07:00
cmux reload-cloud 5ea544a2fb cmuxTests: migrate file drop overlay coverage to Swift Testing 2026-07-27 16:13:29 -07:00
Austin Wang 5eafea6c5e Make screenshot recovery test deterministic 2026-07-27 16:09:42 -07:00
Austin Wang a2ae99bf79 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p 2026-07-27 16:09:04 -07:00
Austin Wang e249805069 Merge remote-tracking branch 'origin/main' into fix/four-single-assertion-stale-tests
# Conflicts:
#	cmuxTests/GlobalSearchShortcutSettingsTests.swift
2026-07-27 16:08:56 -07:00
Austin Wang dbea77e4f0 test: cover customized canonical resume registration 2026-07-27 16:07:37 -07:00
cmux reload-cloud 0819dd70c8 Merge remote-tracking branch 'origin/main' into fix/cli-spawn-timeout-not-a-speed-gate 2026-07-27 16:07:26 -07:00
Austin Wang 95c4b5961d Merge origin/main into issue-5919-settings-desktop-notifications-row-stuck-on-p 2026-07-27 15:33:52 -07:00
Austin Wang 34b69aef0a Merge pull request #8911 from ejc3/fix/cli-quoting-and-backquote-test-fixtures
cmuxTests: model shipped behavior in two input/transport fixtures
2026-07-27 15:27:44 -07:00
Austin Wang 21ff8b951c Merge pull request #8914 from ejc3/fix/mermaid-zoom-double-scale
Mermaid diagrams no longer double-scale under viewer zoom on newer WebKit
2026-07-27 15:13:33 -07:00
Austin Wang da95dfb025 Merge pull request #8830 from manaflow-ai/issue-8826-design-mode-payload-tmpfile
Make browser design-mode handoffs readable
2026-07-27 15:10:41 -07:00
Myk Melez 235ead818d Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation
# Conflicts:
#	cmux.xcodeproj/project.pbxproj
2026-07-27 15:09:59 -07:00
austinpower1258 16b8d7f629 fix: preserve design prompt image order 2026-07-27 14:59:04 -07:00
cmux reload-cloud 552790d8a4 cmuxTests: restore debug input hooks from one teardown 2026-07-27 14:54:12 -07:00
austinpower1258 b59c951f23 test: preserve design prompt image order 2026-07-27 14:52:59 -07:00
cmux reload-cloud 890606b34c agents: centralize registered built-in resume policy 2026-07-27 14:51:48 -07:00
Myk Melez 34144df234 Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation
# Conflicts:
#	Sources/AppDelegate.swift
#	Sources/Workspace.swift
#	cmux.xcodeproj/project.pbxproj
2026-07-27 14:44:32 -07:00
cmux reload-cloud 7f8c2ce5d6 fix: classify Mermaid zoom from current viewport 2026-07-27 14:39:32 -07:00
austinpower1258 67f71fbb84 fix: simplify design mode handoff 2026-07-27 14:37:10 -07:00
austinpower1258 4f6a19d68d test: require concise design handoff prompt 2026-07-27 14:31:40 -07:00
austinpower1258 163b358a4c fix: omit design handoff trust warning 2026-07-27 14:22:26 -07:00
austinpower1258 6ff2ab4121 test: keep design handoff warning out of prompt 2026-07-27 14:22:00 -07:00
cmux reload-cloud e9b19c7abb test: cover Mermaid zoom after viewport resize 2026-07-27 14:15:55 -07:00
Austin Wang 1f827652f9 Merge pull request #8968 from manaflow-ai/issue-8921-hooks-feed-hang
Fix hooks feed hangs for unsupported agents
2026-07-27 14:15:00 -07:00
austinpower1258 17d3f29037 fix: preserve completed design drawings on page 2026-07-27 14:08:32 -07:00
austinpower1258 18dc1d7819 test: require completed design ink to remain visible 2026-07-27 14:07:16 -07:00
Austin Wang 08b92dfbea Merge pull request #8663 from djova/fix-sidebar-metadata-links
Render sidebar metadata Markdown links in AppKit
2026-07-27 14:02:36 -07:00
Austin Wang 511565446e Merge pull request #8699 from manaflow-ai/issue-8561-global-search-background-hotkey
Fix foreground scope for Global Search shortcut
2026-07-27 13:59:53 -07:00
Austin Wang 4aac50b385 Merge pull request #8927 from ejc3/fix/focused-read-indicator-survives-markread
Focused-read indicator survives surface-scoped mark-read again
2026-07-27 13:53:26 -07:00
cmux reload-cloud 0e2af9e41b Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey
# Conflicts:
#	Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift
#	Sources/KeyboardShortcutSettings.swift
2026-07-27 13:47:18 -07:00
austinpower1258 afb2036318 fix: keep design region tokens text-only 2026-07-27 13:46:20 -07:00
Austin Wang 4b5e339586 Merge pull request #8764 from manaflow-ai/issue-8752-move-surface-between-panes
Move active surfaces between panes with automatic directional splits
2026-07-27 13:42:50 -07:00
austinpower1258 a988724c9f fix: show design drawings in composer tokens 2026-07-27 13:37:21 -07:00
cmux reload-cloud bbcbf81d20 Fix merged feature flag isolation warning 2026-07-27 13:11:50 -07:00
cmux reload-cloud 96ea2d5204 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-27 13:03:30 -07:00
Austin Wang d9d5cdba29 Merge remote-tracking branch 'origin/main' into issue-8752-move-surface-between-panes 2026-07-27 12:58:02 -07:00
austinpower1258 0d19359680 test: require annotation thumbnail ink in composer 2026-07-27 12:54:02 -07:00
Abdulaziz AlbaharandClaude Opus 4.8 45a4ddff69 Closing a group's anchor keeps the group instead of scattering members to root (#8925)
* 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]>
2026-07-27 14:41:59 -05:00
Austin Wang e01134b515 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-8752-move-surface-between-panes 2026-07-27 12:23:17 -07:00
Austin Wang b6a2d08218 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-8752-move-surface-between-panes
# Conflicts:
#	Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift
#	Sources/KeyboardShortcutSettings.swift
#	Sources/cmuxApp.swift
#	cmux.xcodeproj/project.pbxproj
#	cmuxTests/DockShortcutRoutingTests.swift
2026-07-27 12:23:15 -07:00
austinpower1258 c8825d69b0 fix: avoid design mode resource lookup trap 2026-07-27 12:22:04 -07:00
austinpower1258 d1427e8c77 test: cover missing design mode runtime bundle 2026-07-27 12:22:00 -07:00
cmux reload-cloud 9f9535cc41 Fix legacy hotkey migration test ordering 2026-07-27 12:19:50 -07:00
cmux reload-cloud b030c5f643 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-27 12:17:58 -07:00
Abdulaziz AlbaharandClaude Fable 5 e0cf205136 Put the mobile diff viewer behind a remote feature flag, off by default (#8937)
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]>
2026-07-27 14:02:56 -05:00
Lawrence Chen 357edfe5d9 Merge pull request #9004 from manaflow-ai/fix/tui-native-arm-release-097
CI: verify TUI ARM64 packages on native runners
2026-07-27 05:48:21 -07:00
lawrencecchen ea467c0f71 ci: route native ARM verification through config 2026-07-27 05:42:15 -07:00
lawrencecchen beea4726a8 ci: verify TUI ARM64 packages natively 2026-07-27 05:38:38 -07:00
Lawrence Chen f7a6f559a4 cmux-tui: add familiar terminal shortcuts (#8698)
* test(tui): cover command-k history clearing

* feat(tui): add familiar terminal shortcuts

* fix(tui): request command-modified key events

* test(tui): preserve prompt when clearing history

* fix(tui): preserve prompt when clearing history

* test(tui): cover shortcut review regressions

* fix(tui): close shortcut review gaps

* test(tui): cover autoreview clear-history regressions

* fix(tui): make history clearing protocol-safe

* test(tui): cover final shortcut review regressions

* fix(tui): keep terminal clears surface-local

* test(tui): cover final keyboard review findings

* fix(tui): preserve terminal keyboard semantics

* test(tui): cover shifted shortcut review regressions

* fix(tui): normalize enhanced shifted keys

* test(tui): cover layout and wrapped prompt regressions

* fix(tui): preserve layout and live prompt semantics

* test(tui): cover associated keyboard semantics

* fix(tui): normalize enhanced keyboard input

* test(tui): cover final prompt safety regressions

* fix(tui): harden prompt keyboard startup

* test(tui): cover consumed keyboard regressions

* fix(tui): honor consumed keyboard text

* test(tui): cover final clear-history review gaps

* fix(tui): close final clear-history review gaps

* test(tui): cover final autoreview regressions

* fix(tui): resolve final autoreview blockers

* test(tui): cover serialized prompt redraw

* fix(tui): serialize prompt-safe history clear

* test(tui): read alternate-screen readiness replay

* test(tui): cover consumed Option and Ctrl-Shift metadata

* fix(tui): preserve enhanced keyboard identity

* test(tui): cover authoritative terminal safety

* fix(tui): make terminal shortcut routing authoritative

* test(tui): cover authoritative shortcut encoding

* fix(tui): encode shortcut fallback authoritatively

* test(tui): cover safe shortcut fallback protocol

* fix(tui): validate authoritative key fallback

* test(tui): cover streaming shortcut safety

* fix(tui): make shortcut fallback streaming-safe

* test(tui): reject pre-submit prompt redraws

* fix(tui): sequence prompt and keyboard ownership

* test(tui): cover prompt lifecycle safety

* fix(tui): fail closed on prompt lifecycle

* test(tui): preserve prompt during history clear

* fix(tui): clear history without shell input

* test(tui): rely on executable binding conformance

* fix(tui): preserve clear history lock order

* test(tui): cover terminal shortcut delivery barriers

* fix(tui): preserve alt shortcuts and await host clear

* test(tui): preserve modified associated key events

* fix(tui): retain modified associated key events

* test(tui): reject lossy extended modifiers

* fix(tui): preserve shortcuts at host boundary

* test(tui): cover explicit option routing and compact input

* fix(tui): harden terminal shortcut routing

* test(tui): clear visible rows without prompt metadata

* fix(tui): clear completed rows without prompt metadata

* test(tui): cover terminal-standard clear shortcut

* fix(tui): honor terminal-standard clear shortcut

* fix(macOS): import dock shell activity type

* fix(macOS): make session snapshot types explicit

* fix(macOS): return resume policy explicitly

* test(tui): cover host keyboard protocol edge cases

* fix(tui): validate host keyboard capabilities

* test(tui): cover bounded terminal response reads

* fix(tui): bound host keyboard capability probe

* test(tui): cover remaining shortcut edge cases

* fix(tui): preserve shortcut routing invariants

* test(web): avoid leaking feedback environment mock

* test(tui): expose legacy CSI-u shifted characters

* fix(tui): normalize legacy CSI-u shift input

* fix(macOS): restore dock checks on current main

* test(tui): preserve per-event keyboard metadata

* fix(tui): trust per-event keyboard metadata

* fix(macOS): isolate dock snapshot filtering

* test(tui): cover fallback payload queue accounting

* fix(tui): bound fallback payload queue memory

* test(ci): cover TestFlight variant identities

* test(macOS): import checklist setting in sidebar fixture

* fix(macOS): repair sidebar suspension integration

* test(tui): cover clear-history preservation boundaries

* fix(tui): make clear-history delivery total

* test(tui): cover Caps Lock Alt shortcut routing

* fix(tui): preserve Alt shortcuts under Caps Lock

* fix(macOS): isolate feature flag default evaluation

* test(tui): cover intermediate clear-history peers

* fix(tui): preserve intermediate remote clear support

* test(tui): cover non-atomic remote shortcut routing

* fix(tui): require atomic remote shortcut routing

* test(tui): cover unencodable clear fallback

* fix(tui): report undeliverable clear shortcuts

* test(tui): require bounded keyboard protocol probe

* fix(tui): bound host keyboard protocol probe

* test(tui): preserve meaningful shift chords

* fix(tui): retain meaningful shift modifiers

* test(tui): cover enhanced non-ascii shift binding

* fix(tui): align enhanced shift shortcut identity

* test(tui): reject non-authoritative clear fallback

* fix(tui): reject non-authoritative clear fallback

* test(tui): cover private screen mode restore semantics

* fix(tui): track private screen modes independently

* test(tui): reject ambiguous option shortcuts

* fix(tui): fail closed on ambiguous option input

* test(tui): preserve empty option CSI-u events

* fix(tui): preserve ambiguous CSI-u input

* test(tui): isolate prompt tracking from control strings

* fix(tui): isolate prompt tracking control strings

* test(tui): align alt CSI-u parser expectations

* test(tui): satisfy control string test lint

* test(tui): keep ctrl-l child-owned

* fix(tui): keep ctrl-l child-owned

* test(tui): preserve metadata-free multiline input

* fix(tui): preserve metadata-free visible input

* test(tui): align ctrl-l shortcut expectations

* test(tui): cover enhanced shortcut review gaps

* fix(tui): preserve enhanced shortcut semantics

* test(tui): exercise child-owned ctrl-l directly

* test(tui): preserve fallback base layout identity

* fix(tui): preserve fallback layout metadata

* test(tui): reject clears inside partial VT streams

* fix(tui): guard clear at VT stream boundaries

* test(tui): retain clear intent across partial VT output

* fix(tui): defer clear until VT boundary

* test(tui): cover final clear-history review gaps

* fix(tui): fail closed and settle clear UI

* test(tui): cover metadata fallback and progress wakes

* fix(tui): preserve metadata-free clear fallback

* test(tui): cover prompt continuation in history

* fix(tui): guard prompt continuations in history

* test(tui): cover Unicode VT boundaries and ambiguous clears

* fix(tui): track string UTF-8 and ambiguous clears

* test(tui): distinguish deterministic clear failures

* fix(tui): preserve clear failure delivery certainty

* test(tui): cover clear coalescing and transport ambiguity

* fix(tui): bound clear retries and delivery certainty

* test(tui): preserve fallback repeats without timeout backlog

* fix(tui): share stalled clear wait budgets

* test(tui): cover repeated prompt markers in history

* fix(tui): preserve prompts spanning history

* test(tui): cover output racing clear timeout release

* fix(tui): rearm clear waits after raced output

* test(tui): cover fallback key WebSocket budget

* fix(tui): bound fallback keys to transport budget

* test(tui): cover delayed clear and enhanced key regressions

* fix(tui): preserve enhanced input and newer clear state

* test(tui): cover clear queue and selection races

* fix(tui): isolate clear operations per surface

* test(tui): cover enhanced text and remote clear blocking

* fix(tui): keep remote clear off connection readers

* test(tui): cover scheduler resource and ordering bounds

* fix(tui): bound and order clear schedulers

* test(tui): cover global clear limits and host certainty

* fix(tui): bound server clears and preserve host failures

* test(tui): cover clear liveness and alt certainty

* fix(tui): isolate and bound terminal clear work

* test(tui): cover review closeout regressions

* fix(tui): retain control admission and clear prefixes safely

* test(tui): cover partial host control writes

* fix(tui): preserve ambiguous host control delivery

* test(tui): cover ambiguous clear lane safety

* fix(tui): quarantine ambiguous input lanes

* test(tui): cover graceful drain and rejection certainty

* fix(tui): drain accepted relay requests safely

* test(tui): cover input lane session ownership

* fix(tui): scope input lanes to session generation

* test(tui): cover dead keys and press recovery

* fix(tui): preserve composition and recovery releases

* docs(tui): clarify relay response ordering

* test(tui): cover local press recovery release

* fix(tui): retain ambiguous press recovery release

* test(tui): cover clear admission and input localization

* fix(tui): admit runnable clears and localize input failures

* test(tui): preserve real alt without associated text

* fix(tui): resolve ambiguous option input explicitly

* test(tui): cover saturated clear lane ordering

* fix(tui): preserve clear lane order at worker cap

* test(tui): cover clear fallback lifecycle gaps

* fix(tui): close clear fallback lifecycle gaps

* test(tui): expose legacy clear fallback capability

* fix(tui): gate clear shortcut by active surface

* test(tui): cover blocking clear barrier bypass

* fix(tui): fence blocking clear barrier bypasses

* test(tui): require lock-free clear capability reads

* fix(tui): cache clear capability outside writer lock

* test(tui): require localized option mode warning

* fix(tui): localize invalid option mode warning
2026-07-27 04:09:25 -07:00
cmux reload-cloud 6d11270aed Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey
# Conflicts:
#	skills/cmux-keyboard-shortcuts/SKILL.md
2026-07-27 01:25:13 -07:00
cmux reload-cloud 87c247d515 fix: address hook deadline review feedback 2026-07-27 00:19:25 -07:00
Austin Wang eedc4a62a8 Merge pull request #8963 from manaflow-ai/issue-8955-titlebar-under-menubar
Fit main windows after AppKit frame restoration
2026-07-27 00:17:00 -07:00
cmux reload-cloud 9bf8427bf5 Merge remote-tracking branch 'origin/main' into issue-8921-hooks-feed-hang 2026-07-27 00:12:18 -07:00
Austin Wang 5fa8c88a48 Merge pull request #8967 from manaflow-ai/issue-8918-blank-panes-regression
Fix blank panes after skipped initial layout
2026-07-27 00:10:21 -07:00
Austin Wang 39d92dd9f3 Merge pull request #8966 from manaflow-ai/issue-8741-inherit-cwd
Fix disabled workspace cwd inheritance
2026-07-27 00:08:56 -07:00
Austin Wang 6937828d4d Merge pull request #8964 from manaflow-ai/issue-8953-zsh-wrap-guard
Fix zsh prompt wrap spacer lines
2026-07-27 00:01:57 -07:00
Lawrence Chen 8c8c2bacae Compact CLAUDE.md and skills docs (#8990)
* 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.
2026-07-26 23:16:39 -07:00
Lawrence Chen 7fc56fc002 Render iPhone Simulator panes crisply at real size (#8983)
* Add iPhone Simulator sharpness regressions

* Render iPhone Simulator panes sharply at real size

* Add Simulator resize flash regression

* Preserve Simulator frame during resize

* Address Simulator review feedback

* Add Simulator display scale regression

* Publish the Simulator device display scale

* Add magnified Simulator filtering regression

* Smooth magnified Simulator frames

* Add Simulator backing-scale sampling regression

* Refresh Simulator sampling across displays

* Split the Simulator device-type fixture

* Address final Simulator review feedback
2026-07-26 20:57:49 -07:00
Lawrence Chen 7ed1396af7 Merge pull request #8961 from manaflow-ai/feat-durable-notice-identity
Persist durable provider notice identity
2026-07-26 20:12:10 -07:00
Lawrence Chen 006d74f0b2 Reuse verified artifacts across TUI publishers (#8973)
* test: prevent duplicate TUI release builds

* ci: reuse verified TUI release artifacts

* ci: require tagged TUI artifact runs
2026-07-26 20:06:33 -07:00
Lawrence Chen a9a46c9bb1 Route TUI package verification through configured runner (#8987) 2026-07-26 19:35:18 -07:00
lawrencecchen 4790ddbeaf fix: defer durable notice state requirements 2026-07-26 19:04:01 -07:00
lawrencecchen 963eed1bbe fix: gate durable notice identity after negotiation 2026-07-26 18:32:26 -07:00
lawrencecchen d32f196d64 test: cover capability-gated notice identity 2026-07-26 18:26:29 -07:00
lawrencecchen c58587d7cc fix: lease durable notice cursor per process 2026-07-26 18:07:31 -07:00
lawrencecchen 87ae9fa9b9 test: reject concurrent durable notice cursor owners 2026-07-26 17:51:14 -07:00
Austin Wang 440f5d1ff6 Merge pull request #8962 from manaflow-ai/issue-8924-control-socket-utf8
Preserve UTF-8 across control socket reads
2026-07-26 15:17:03 -07:00
cmux reload-cloud 8a257d76a6 test: allow lifecycle hook launch headroom 2026-07-26 15:03:21 -07:00
cmux reload-cloud 1f7bf6bf56 fix: make legacy hotkey migration single-pass 2026-07-26 14:53:23 -07:00
cmux reload-cloud e8b4c70fc8 test: stabilize hook no-response timing coverage 2026-07-26 14:51:08 -07:00
cmux reload-cloud 2489f6286f fix: bound socket writes by hook deadline 2026-07-26 14:37:03 -07:00
cmux reload-cloud 230cd85863 fix: bound relay reads by hook deadline 2026-07-26 14:33:58 -07:00
cmux reload-cloud 254da69120 fix: preserve deadline on relay reconnect 2026-07-26 14:32:17 -07:00
cmux reload-cloud 8035e82f5d fix: preserve deadline across socket reads 2026-07-26 14:31:41 -07:00
cmux reload-cloud 550685c9ad fix: bound unix hook socket connect 2026-07-26 14:29:26 -07:00
cmux reload-cloud 5b3c3337fe fix: bound relay setup by hook deadline 2026-07-26 14:27:44 -07:00
cmux reload-cloud 530329f9c2 fix: enforce feed hook connection deadline 2026-07-26 14:24:54 -07:00
cmux reload-cloud 1c0d7ca950 Cover zsh prompt resize ownership 2026-07-26 14:22:16 -07:00
cmux reload-cloud 3899441287 test: tighten workspace cwd spawn coverage 2026-07-26 14:21:46 -07:00
cmux reload-cloud 8f22c97517 fix: fit windows after AppKit frame restoration 2026-07-26 14:20:54 -07:00
cmux reload-cloud 9375d80d84 fix: bound feed hook socket setup 2026-07-26 14:20:14 -07:00
Austin Wang 1e53bee1c2 fix(terminal): wait for drawable geometry before presentation 2026-07-26 14:19:58 -07:00
cmux reload-cloud 38b73c30ed test: cover fullscreen exit window fitting 2026-07-26 14:18:24 -07:00
cmux reload-cloud b477df293f Address zsh prompt regression review 2026-07-26 14:17:42 -07:00
Austin Wang 789b2c1fd5 test(terminal): require drawable geometry before presentation 2026-07-26 14:16:25 -07:00
cmux reload-cloud 90262c0562 refactor: inject workspace cwd environment 2026-07-26 14:13:12 -07:00
cmux reload-cloud 406dee4e31 refactor: model workspace cwd policies as values 2026-07-26 14:11:32 -07:00
cmux reload-cloud f08d44c85c fix: honor disabled workspace cwd inheritance 2026-07-26 14:06:03 -07:00
cmux reload-cloud 852fbc0368 Let Ghostty own zsh prompt layout 2026-07-26 14:00:10 -07:00
cmux reload-cloud 241a8a218e Make UTF-8 split regression deterministic 2026-07-26 13:59:42 -07:00
cmux reload-cloud 7a29c237e7 test: cover disabled workspace cwd inheritance 2026-07-26 13:57:08 -07:00
cmux reload-cloud e19d9169e7 fix: make unsupported feed hooks fail neutral 2026-07-26 13:56:54 -07:00
cmux reload-cloud f17479b74f Cover recovery after malformed socket frame 2026-07-26 13:56:14 -07:00
cmux reload-cloud 0c7079d8f7 Prove zsh prompt spacer regression 2026-07-26 13:55:55 -07:00
lawrencecchen 4f25c980ef fix: clarify provider connection recovery 2026-07-26 13:52:44 -07:00
cmux reload-cloud d2786bbd3e Preserve UTF-8 across control socket reads 2026-07-26 13:52:10 -07:00
cmux reload-cloud f3ad09cfa3 test: reproduce hooks feed hangs for incompatible agents 2026-07-26 13:52:02 -07:00
cmux reload-cloud 28475858b9 Add UTF-8 split-read regression test 2026-07-26 13:50:48 -07:00
cmux reload-cloud 5f811c1345 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey
# Conflicts:
#	Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift
#	Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/ShortcutListModel.swift
#	Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/ShortcutListModelTests.swift
#	Sources/AppDelegate.swift
2026-07-26 13:33:40 -07:00
lawrencecchen b84d261d23 fix: serialize provider notice identity creation 2026-07-26 13:30:11 -07:00
cmux reload-cloud 332c4b5983 fix: cache foreground search shortcut routing 2026-07-26 13:26:50 -07:00
lawrencecchen 21e443d090 fix: persist provider notice consumer identity 2026-07-26 13:08:21 -07:00
lawrencecchen 8b3f690946 test: persist provider notice identity across restarts 2026-07-26 13:02:41 -07:00
lawrencecchen f907a8d910 Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon-finalize
# Conflicts:
#	.github/workflows/cmux-tui-build-package.yml
2026-07-26 07:23:49 -07:00
Lawrence Chen aac235180e Add native iPhone and iPad Simulator panes (#7857)
* tighten simulator shortcut and mutation paths

* Harden simulator operation commit boundaries

* Close simulator routing and deadline races

* Reconcile simulator mutations during teardown

* Preserve simulator caller routing context

* Route ios commands from caller pane

* Preserve explicit simulator routing and input recovery

* Serialize simulator text recovery

* Align simulator deadlines and selection rollback

* Close simulator lifecycle commit races

* Reject stale simulator discovery results

* Bound simulator re-enable and batch capture

* Select the integrated simulator display

* Close simulator transition races

* Gate simulator automation on readiness

* Finish simulator selection generation checks

* Bound simulator context work end to end

* Verify simulator descendant identities

* Validate simulator process ancestry

* Bound simulator process shutdown

* Supervise simulator command groups

* Supervise simulator pane sessions

* Contain every simulator subprocess

* Test simulator routing and feature flag regressions

* Fail closed on stale simulator state

* Test simulator lifecycle review regressions

* Bound simulator control lifecycles

* Test simulator cross-pane ownership regressions

* Serialize simulator cross-pane mutations

* Test Web Inspector cross-worker ownership

* Lease Web Inspector targets across workers

* Test Web Inspector stale occupancy handoff

* Refresh Web Inspector occupancy during handoff

* Test Web Inspector occupancy timeout

* Fail closed on incomplete Inspector occupancy

* Test Inspector census and release regressions

* Close Inspector refresh and release races

* Test stale location route teardown

* Preserve location route ownership through teardown

* Test Simulator launch environment privacy

* Test route commit during device switch

* Test feature flag telemetry consent

* Own Simulator mutations through teardown

* Harden Simulator isolation and ownership

* Persist Simulator mutation ownership across processes

* Honor telemetry consent for remote flags

* Preserve failed Simulator cleanup ownership

* Propagate Simulator CLI routing errors

* Fix Simulator operation task syntax

* Inject Simulator ownership and keep context read-only

* Resolve restored Simulator context without booting

* Test late Simulator display identity publication

* Attach Simulator callbacks before display discovery

* Test Simulator mutation ownership boundaries

* Harden Simulator mutation ownership semantics

* Test current Simulator default-screen contract

* Support current Simulator default-screen metadata

* Isolate Simulator camera transport tests

* Test forwarded Simulator screen metadata

* Support forwarded Simulator screen metadata

* Align Simulator overlay lifecycle test with visibility

* Bound Simulator recovery assertion by time

* Make Simulator input recovery test deterministic

* Test Simulator landscape framebuffer direction

* Correct Simulator landscape presentation direction

* Bound Simulator frame publication test wait

* Register Simulator replay state before delivery

* Test Simulator review boundary cases

* Test iOS screenshot surface identity errors

* Normalize Simulator control boundaries

* Isolate Simulator CLI contract environment

* Test native Simulator orientation dialects

* Unify native Simulator orientation semantics

* Test landscape Simulator digitizer coordinates

* Map landscape input into native digitizer space

* Test stable Simulator app switcher hold

* Hold app switcher gesture stationary

* Test Simulator app switcher button timing

* Open Simulator app switcher with double Home

* Test installed iPad DeviceKit chrome fallback

* Test bounded Simulator frame publication

* Scale Simulator frames to pane geometry

* Test Simulator frame ring replacement cleanup

* Release obsolete Simulator frame rings

* Route IndexNow jobs through configured runner

* Remove wall-clock assertions from RPC event tests

* Test frame ring adoption race

* Retire Simulator frame rings after host adoption

* Keep frame completion on MainActor

* Snapshot Simulator activity log before lazy layout

* Regenerate webview assets after main merge

* Test Simulator core readiness ordering

* Stream Simulator before optional capability probes

* Test control-socket Simulator selection ownership

* Exclude active Simulator control action from teardown

* Test natural DeviceKit chrome cap geometry

* Preserve native DeviceKit chrome artwork geometry

* Fix design mode test payload shadowing

* Make Simulator replay tests signal-driven

* Fix Simulator pane test client conformance

* Test immediate Simulator context discovery

* Discover Simulator before context reads

* Test Simulator production edge cases

* Close Simulator production review findings

* Fix Simulator integration test fixtures

* Fix Simulator CLI routing call site

* Fix Simulator focus test fixtures

* Fix Simulator app test fixtures

* Test Simulator capability hydration readiness

* Wait for Simulator capability hydration

* Test Simulator review edge cases

* Close Simulator production review gaps

* Avoid recursive Simulator picker comparison

* Isolate Simulator picker observation

* Test Simulator application row snapshots

* Snapshot Simulator application picker rows

* Test static Simulator frame presentation

* Drive Simulator frames without display callbacks

* Test Simulator visibility remounts

* Keep Simulator frames through host remounts

* Isolate Simulator visibility regression suite

* Test Simulator frame pacing under input load

* Pace Simulator framebuffer readback

* Localize project surface labels

* Test Camera Injector header cache identity

* Invalidate Camera Injector cache for headers

* Test Simulator RPC capability discovery

* Advertise Simulator RPC capabilities

* test(simulator): cover interactive frame priority

* fix(simulator): prioritize frames after pointer input

* test(simulator): cover native tap hold duration

* fix(simulator): hold synthetic taps long enough for iPadOS

* Test immediate Simulator frame presentation

* Present Simulator frames without an extra tick

* Test failed Simulator ownership publication

* Reject unsafe Simulator ownership claims

* test(simulator): cover framebuffer lifecycle bounds

* fix(simulator): bound framebuffer lifecycle work

* test(simulator): cover review lifecycle regressions

* fix(simulator): close review lifecycle gaps

* test(simulator): cover routing pacing and consent

* fix(simulator): scope routing pacing and consent

* test(simulator): cover screenshot and cached log readiness

* fix(simulator): prepare capture without eager lifecycle work

* test(simulator): report capture-ready live state

* fix(simulator): report live capture-ready state

* test(simulator): cover control-plane and frame wakeups

* fix(simulator): signal frames and preserve errored flag cache

* test(simulator): cover publication wakeup races

* fix(simulator): bound publication wakeups

* test(simulator): cover tool editor shortcut focus

* fix(simulator): preserve tool editor focus ownership

* test(simulator): cover flag omission and runner injection

* fix(simulator): inject async owned command execution

* test(simulator): cover file drop proposal readiness

* fix(simulator): validate file drop proposals

* test(simulator): cover compound inspector cleanup failure

* fix(simulator): preserve failed inspector cleanup state

* test(simulator): cover device-scoped tool state

* fix(simulator): scope tool state to selected device

* test(simulator): cover final release blockers

* fix(simulator): close final release blockers

* test(simulator): make frame scheduling assertions deterministic

* test: update Ghostty surface config ABI lock

* fix(simulator): clear final build gates

* fix(build): import Dock lifecycle workspace types

* test(web): isolate feedback route environment

* fix(build): disambiguate Dock snapshot types

* test(simulator): cover review ownership blockers

* fix(simulator): scope camera cleanup ownership

* fix(build): make resume policy returns explicit

* test(simulator): cover camera cleanup ownership retries

* fix(simulator): preserve camera cleanup ownership

* fix(build): clear latest main gates

* test(simulator): cover final review blockers

* fix(simulator): await quit cleanup and index targets

* refactor(simulator): satisfy production policy

* test(simulator): cover camera cleanup on device switch

* fix(simulator): clean camera state before device switch

* test(simulator): retain quit cleanup after panel removal

* fix(simulator): make quit await durable rollback

* test(simulator): cover retained cleanup recovery

* fix(simulator): recover retained camera cleanup

* test(simulator): cover cleanup side effects

* fix(simulator): restore external cleanup state

* refactor(simulator): split camera authorization record

* test(web): respect delegated discovery order

* test(ios): assert transition math deterministically

* fix(ci): repair current-main Swift integration

* fix(ci): return closed workspace restore result

* test(simulator): cover final review regressions

* fix(simulator): close final lifecycle gaps

* test(simulator): cover review lifecycle findings

* fix(simulator): resolve review lifecycle findings

* test(simulator): cover durable recovery journals

* fix(simulator): persist mutation recovery journals

* refactor(simulator): inject durable recovery paths

* test(simulator): cover durable journal transitions

* fix(simulator): make recovery transitions crash consistent

* test(simulator): cover recovery compatibility gaps

* fix(simulator): preserve recovery compatibility

* test(simulator): cover recovery ownership handoff

* fix(simulator): gate recovery ownership handoff

* test(simulator): cover duplicate camera journals

* fix(simulator): suppress stale durable camera journals

* test(simulator): cover journal reconciliation races

* fix(simulator): serialize journal reconciliation

* test(simulator): cover identical journal paths

* fix(simulator): normalize camera journal paths

* test(simulator): cover journal URL hints

* fix(simulator): compare normalized journal paths

* refactor(simulator): split legacy route fixture

* test(simulator): observe journal lock contention
2026-07-26 06:51:38 -07:00
Lawrence Chen bce523c04a Make cmux TUI packages portable across Linux distributions (#8954)
* 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
2026-07-26 06:04:43 -07:00
Lawrence Chen 9d99b556ed Merge pull request #8943 from manaflow-ai/feat-workspace-ports-contract
Add selected-workspace provider actions to cmux TUI
2026-07-26 05:47:35 -07:00
lawrencecchen 904408a1ef Mark workspace action test session active 2026-07-26 04:56:31 -07:00
lawrencecchen 7ce02074cf Bind actions to the active provider session workspace 2026-07-26 04:51:54 -07:00
lawrencecchen 5c8ae458a5 Test session workspace provider action binding 2026-07-26 04:49:24 -07:00
lawrencecchen eba9506647 Cover mixed provider capability negotiation 2026-07-26 04:46:39 -07:00
lawrencecchen c1f4ee8e5e Own provider action localization in sidebar catalog 2026-07-26 04:37:44 -07:00
lawrencecchen 0b091f2f5c Negotiate provider action targets 2026-07-26 04:17:10 -07:00
lawrencecchen 2372417e02 Add action target negotiation regression 2026-07-26 04:08:40 -07:00
lawrencecchen 7752a5090f Address provider action review feedback 2026-07-26 03:53:53 -07:00
lawrencecchen ef78ff1667 Merge remote-tracking branch 'origin/main' into feat-workspace-ports-contract 2026-07-26 03:40:27 -07:00
Lawrence Chen 015b603da2 Merge pull request #8942 from manaflow-ai/feat-cloud-usage-warning-events
Deliver durable cloud usage notices
2026-07-26 03:39:27 -07:00
lawrencecchen c3a18d4631 Coalesce provider snapshot invalidations 2026-07-26 02:51:50 -07:00
lawrencecchen 29a9b5fd10 Test snapshot invalidation burst coalescing 2026-07-26 02:48:24 -07:00
lawrencecchen 79e48f18aa test(tui): await terminal reconnect lifecycle 2026-07-26 02:38:38 -07:00
lawrencecchen 60c4422680 Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon-finalize
# Conflicts:
#	cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui/src/cli.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
#	cmux-tui/docs/README.md
2026-07-26 02:34:39 -07:00
lawrencecchen db49438b37 Defer notice acks during machine actions 2026-07-26 02:31:13 -07:00
lawrencecchen 62fbb98e81 Test durable ack waits for machine action 2026-07-26 02:30:47 -07:00
lawrencecchen d45ef4d948 Buffer durable replay until resume cursor 2026-07-26 02:21:10 -07:00
lawrencecchen 82aa6c3a24 Test replay waits for durable cursor 2026-07-26 02:18:36 -07:00
lawrencecchen 642ac9ee09 Document durable notice click handling 2026-07-26 01:28:59 -07:00
lawrencecchen c2e413a73c Cancel ambiguous pairing on provider selection 2026-07-26 01:23:32 -07:00
lawrencecchen a32e911938 Test scope change cancels ambiguous pairing 2026-07-26 01:22:57 -07:00
lawrencecchen 906c5a2952 Merge remote-tracking branch 'origin/main' into verify-pr8942-chain
# Conflicts:
#	cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs
2026-07-26 01:18:30 -07:00
Lawrence Chen f79e3d8677 Merge pull request #8748 from manaflow-ai/feat-provider-external-pairing
Add outbound external machine pairing
2026-07-26 01:16:46 -07:00
cmux reload-cloud f9de8c780e refactor: observe shortcut updates with Observation 2026-07-26 01:09:39 -07:00
cmux reload-cloud 91a1a299a9 fix: preserve legacy global hotkey migration 2026-07-26 01:02:53 -07:00
lawrencecchen 1ca90a5518 fix: use machine creation terminology 2026-07-26 00:44:49 -07:00
lawrencecchen 202cfc1c72 test: require machine creation terminology 2026-07-26 00:43:44 -07:00
austinpower1258 3e45cb661d refactor: satisfy design handoff ownership policy 2026-07-26 00:41:18 -07:00
cmux reload-cloud 383a0969f1 test: cover legacy global hotkey registration migration 2026-07-26 00:33:54 -07:00
austinpower1258 d787a84f48 fix: scope design artifact removal to owned files 2026-07-26 00:30:38 -07:00
austinpower1258 a99e2049ea test: guard design artifact removal scope 2026-07-26 00:30:12 -07:00
cmux reload-cloud 4422307cd5 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-26 00:27:28 -07:00
austinpower1258 e02ad19a2f fix: bound dropped snapshot callback quarantine 2026-07-26 00:27:02 -07:00
austinpower1258 76ef1b3d07 test: cover dropped design snapshot callback recovery 2026-07-26 00:26:33 -07:00
lawrencecchen 278d92caae fix: serialize machine agent migrations 2026-07-26 00:23:39 -07:00
lawrencecchen 5f44ed3419 test: bound overlapping agent migrations 2026-07-26 00:23:07 -07:00
cmux reload-cloud c0a037c778 fix: preserve promoted search chord state 2026-07-26 00:20:17 -07:00
cmux reload-cloud 2cbd54eeb3 test: cover promoted search chord suffix state 2026-07-26 00:19:52 -07:00
cmux reload-cloud 3ec16e1458 test: cover visible search editing chord suffix 2026-07-26 00:18:51 -07:00
austinpower1258 af51cae907 Merge remote-tracking branch 'origin/main' into issue-8826-design-mode-payload-tmpfile 2026-07-26 00:14:11 -07:00
austinpower1258 ed8f27dc11 fix: close design handoff lifecycle gaps 2026-07-26 00:13:30 -07:00
lawrencecchen 85092c4cf3 fix: honor committed local pairing override 2026-07-26 00:11:57 -07:00
lawrencecchen d199a5de54 test: cover local override of pairing handoff 2026-07-26 00:11:25 -07:00
lawrencecchen 022fea3f1e fix: retain local session during pairing handoff 2026-07-26 00:07:53 -07:00
cmux reload-cloud c5fc201d3c fix: align system-wide shortcut registration policy 2026-07-26 00:06:54 -07:00
lawrencecchen bcc96dc45b test: cover provisioning pairing handoff 2026-07-26 00:04:09 -07:00
lawrencecchen 50688f1063 fix: preserve pairing retry intent 2026-07-25 23:56:05 -07:00
lawrencecchen 9623110d0b test: cover pairing retry state 2026-07-25 23:54:05 -07:00
lawrencecchen 7213a45f75 Merge durable notice prompt route fixture 2026-07-25 23:44:16 -07:00
lawrencecchen 161171863b Bind durable notice prompt fixture to local route 2026-07-25 23:44:11 -07:00
lawrencecchen f609a94a05 Merge pairing and durable notices into selected workspace actions 2026-07-25 23:42:03 -07:00
lawrencecchen c10f0f44bf Merge external pairing candidate into feat-cloud-usage-warning-events 2026-07-25 23:41:21 -07:00
lawrencecchen 36bce017cd Capture machine connect routes in prompts 2026-07-25 23:38:35 -07:00
lawrencecchen defacdc132 Test prompt-captured machine connect routes 2026-07-25 23:36:49 -07:00
lawrencecchen d18ab3fe5e Fail provider action targets closed 2026-07-25 23:31:41 -07:00
lawrencecchen 77b20f832b Add provider action target regressions 2026-07-25 23:30:55 -07:00
lawrencecchen e7b0ca28fc fix(tui): detach streams when hosted terminals exit 2026-07-25 23:27:28 -07:00
lawrencecchen 8fd59f3028 test(tui): reproduce hosted attach exit leak 2026-07-25 23:25:56 -07:00
lawrencecchen 7602cb404a Merge main into feat-provider-external-pairing 2026-07-25 23:22:42 -07:00
lawrencecchen c21d5a9a8b Merge commit '54c141190a3713ebe32dfed3390e6f80c577388f' into feat-cloud-usage-warning-events
# Conflicts:
#	cmux-tui/crates/cmux-tui/src/app.rs
#	cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs
2026-07-25 23:19:47 -07:00
lawrencecchen 54c141190a Preserve machine connect routing intent 2026-07-25 23:15:42 -07:00
lawrencecchen 66d860086d Fix durable cloud notice delivery safeguards 2026-07-25 23:13:34 -07:00
Lawrence Chen 252aa38e0f Scope client sizing to each terminal (#8776)
* test(tui): scope exclusive client size to terminal

* fix(tui): scope client sizing to each terminal

* test(tui): cover surface retirement during attach

* fix(tui): cancel attach when surface retires

* test(tui): cover client sizing review regressions

* fix(tui): address client sizing review findings

* test(tui): cover sizing fallback compatibility

* fix(tui): preserve sizing fallback semantics

* test(tui): cover final sizing review regressions

* fix(tui): close final sizing review gaps

* test(tui): cover stale tree before queued attach

* fix(tui): preserve queued attach failures

* test(tui): cover final review compatibility gaps

* fix(tui): close final compatibility review gaps

* fix(tui): scope resize accounting to target surface

* test(tui): cover unsized attach sizing fallback

* fix(tui): close protocol 10 sizing gaps

* test(tui): preserve explicit size across unsized attach

* fix(tui): unify client sizing ownership

* test(tui): refresh clients after exclusive detach

* fix(tui): refresh clients after exclusive detach

* fix(go): return decoded client list deterministically

* test(sdk): cover protocol 9 sizing compatibility

* fix(sdk): normalize protocol 9 sizing state

* fix(tui): satisfy strict sizing lint

* test(tui): cover unsized client disconnect geometry

* fix(tui): preserve geometry on unsized disconnect

* test(tui): reject sizing policy for closed surfaces

* fix(tui): serialize sizing policy with surface purge

* test(tui): preserve unknown surface sizing errors

* fix(tui): validate sizing surface before client

* fix(bindings): gate sizing e2e on protocol 10

* test(tui): preserve retired attach transport failures

* fix(tui): preserve attach transport recovery

* test(tui): cover final detach and menu surface races

* fix(tui): preserve sizing lifecycle targets

* fix(build): import dock lifecycle types

* fix(build): disambiguate dock snapshot types

* fix(build): return surface resume policy explicitly
2026-07-25 23:07:36 -07:00
lawrencecchen 7d7df96d77 Merge commit 'b13d7b74a0027120e626e004cd3ef2dd748d004c' into feat-cloud-usage-warning-events 2026-07-25 23:02:47 -07:00
lawrencecchen b13d7b74a0 Satisfy connector pool lint 2026-07-25 23:01:45 -07:00
Lawrence Chen 2a78e2235b Make Cmd-Shift-H flash reliably (#8785)
* test: cover focused flash with unread sibling

* fix: honor explicit focused panel flash

* test: keep focused flash coverage in Swift Testing

* test: cover socket-triggered focus flash

* fix: route socket flashes as user initiated

* fix: import dock shell activity state

* fix: restore resume approval switch returns

* fix: type dock session snapshot transforms

* test: pass dock transfer restore source
2026-07-25 22:58:24 -07:00
lawrencecchen c417792773 Fix cloud watchdog writer bound 2026-07-25 22:57:16 -07:00
lawrencecchen 08d351446d Bound machine agent work across reconnects 2026-07-25 22:56:38 -07:00
lawrencecchen 0dd345825c fix(tui): preserve fast terminal exit handshakes 2026-07-25 22:54:21 -07:00
lawrencecchen eaf8f616fe test(tui): reproduce short-lived host launch race 2026-07-25 22:46:20 -07:00
lawrencecchen 7179859e52 Merge commit 'b4699faabb2f5b5ff425c27be2103c979a7b7580' into feat-workspace-ports-contract 2026-07-25 22:45:00 -07:00
lawrencecchen b4699faabb Merge commit '8e39976768c0c4d87993744c2867fae157751954' into feat-cloud-usage-warning-events 2026-07-25 22:44:17 -07:00
lawrencecchen 8e39976768 Preserve pairing recovery state and startup errors 2026-07-25 22:38:40 -07:00
lawrencecchen 675385e4d1 fix(tui): size scoped attaches atomically 2026-07-25 22:33:33 -07:00
lawrencecchen 740ba35388 fix(tui): normalize local OSC 7 working directories 2026-07-25 22:29:48 -07:00
lawrencecchen f2d08d2ae1 Merge commit '8a1598efbe59aa3f640bc1283289c13adbcd5e5c' into feat-workspace-ports-contract 2026-07-25 22:29:26 -07:00
lawrencecchen 30a443ab0f test(tui): reproduce OSC 7 cwd launch failure 2026-07-25 22:27:56 -07:00
lawrencecchen 0a7e6020fc Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon-finalize
# Conflicts:
#	cmux-tui/Cargo.lock
#	cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui-core/src/surface.rs
#	cmux-tui/crates/cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui/src/main.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
2026-07-25 22:27:17 -07:00
lawrencecchen 8a1598efbe Merge commit 'c936aba61ea5905908cdb7126c2252721300adb3' into feat-cloud-usage-warning-events 2026-07-25 22:24:03 -07:00
lawrencecchen c936aba61e Allow bounded concurrent local session opens 2026-07-25 22:20:37 -07:00
lawrencecchen dbd0bea30b Merge external pairing into durable notice delivery
# Conflicts:
#	cmux-tui/crates/cmux-tui-machine-protocol/src/lib.rs
#	cmux-tui/crates/cmux-tui/src/app.rs
#	cmux-tui/crates/cmux-tui/src/machine_provider_client.rs
#	cmux-tui/crates/cmux-tui/src/machine_provider_runtime.rs
#	cmux-tui/crates/cmux-tui/src/ui/mod.rs
#	cmux-tui/spec/machine-provider.md
2026-07-25 22:12:37 -07:00
lawrencecchen 2e24022ff2 Require a pairing terminal before registration 2026-07-25 22:04:30 -07:00
lawrencecchen b50643f33e Keep machine agent responsive during local opens 2026-07-25 22:00:17 -07:00
lawrencecchen de4962f415 Add regression coverage for ambiguous external opens 2026-07-25 21:59:37 -07:00
Lawrence Chen 9c69a4be61 Keep unavailable terminal Copy from reaching the PTY (#8895)
* test: cover unavailable terminal copy shortcut

* fix: keep unavailable terminal copy from reaching PTY

* test: preserve configured Cmd+C binding

* fix: preserve configured Cmd+C bindings

* test: preserve performable Cmd+C bindings

* fix: inspect exact Cmd+C binding action

* chore: sync Ghostty binding probe baseline

* chore: pin GhosttyKit binding probe artifact

* test: cover transient Copy binding resolution

* fix: preserve Copy binding key lifecycle

* chore: pin transactional GhosttyKit artifact

* test: exercise Copy menu-miss routing

* fix: migrate iOS Ghostty userdata lifetime

* fix: stabilize Ghostty binding releases

* chore: pin stabilized GhosttyKit artifact

* fix: order Ghostty action teardown

* chore: pin teardown-safe GhosttyKit artifact
2026-07-25 21:58:12 -07:00
lawrencecchen 9ac7864d75 Reconcile TUI test APIs with current main 2026-07-25 21:48:27 -07:00
lawrencecchen bd164a148d Merge remote-tracking branch 'origin/main' into feat-provider-external-pairing
# Conflicts:
#	cmux-tui/Cargo.toml
#	cmux-tui/README.md
#	cmux-tui/crates/cmux-tui/src/localization.rs
#	cmux-tui/crates/cmux-tui/src/main.rs
2026-07-25 21:47:20 -07:00
Lawrence Chen 8eceeab85f Add pane controls and single-terminal attach to cmux TUI (#8710)
* Add cmux TUI pane and sidebar controls

* Improve TUI action discovery

* Improve TUI prefix help and workspace controls

* Refine TUI shortcut modal and scrollbars

* Prioritize TUI close-tab shortcut

* Unify TUI scrollbar styling

* Fix TUI shortcut routing regressions

* Fix TUI regression test compilation

* Correct TUI shortcut regression coverage

* Prevent TUI render panic deadlock

* Harden TUI shortcut and attach actions

* Test TUI overlay interaction regressions

* Harden TUI overlay action handling

* Test explicit close-tab pane targeting

* Honor explicit close-tab pane targets

* Test TUI scoped event and overlay regressions

* Scope TUI events and finish overlay drags

* Test remaining TUI review regressions

* Fix remaining TUI review regressions

* Test scoped attach subscription regressions

* Fix scoped attach event subscriptions

* Test surface subscription path after tab moves

* Refresh scoped subscriptions after tab moves

* Test scoped attach and explicit zoom regressions

* Fix scoped attach event lifecycle and zoom intent

* Test single-surface action and short ID regressions

* Guard single-surface topology and resolve short IDs

* test(tui): cover reviewed isolation regressions

* fix(tui): isolate attached surface clients

* test(tui): bound reliable browser releases

* fix(tui): bound browser mouse releases

* refactor(tui): expose cell pixel state as production API

* test(tui): cover review queue and attach regressions

* fix(tui): preserve scoped browser input state

* test(tui): cover final browser input regressions

* fix(tui): bound browser release and attach actions
2026-07-25 21:43:14 -07:00
lawrencecchen d2035616cd Fix migration commit and reconnect lifecycle 2026-07-25 21:42:51 -07:00
lawrencecchen 4a303600ce Test reconnect pacing and connect prompts 2026-07-25 21:40:22 -07:00
lawrencecchen 8ad4ad5f34 Document strict agent host verification 2026-07-25 21:33:28 -07:00
lawrencecchen cf5358d071 Cover machine agent stream isolation 2026-07-25 21:32:47 -07:00
lawrencecchen 2143d0834e Harden machine agent stream isolation 2026-07-25 21:31:58 -07:00
lawrencecchen 16fe7a1ab8 Test machine agent SSH host verification 2026-07-25 21:30:24 -07:00
lawrencecchen 3e2729a950 Fix external pairing review gaps 2026-07-25 21:06:33 -07:00
lawrencecchen 5a5e107924 Test external pairing review gaps 2026-07-25 21:03:20 -07:00
lawrencecchen b573764699 Merge current main before external pairing gate 2026-07-25 20:48:47 -07:00
Lawrence Chen 7c82622dfc Merge pull request #8941 from manaflow-ai/task-integrate-web-determinism-repairs
Integrate current-main check repairs
2026-07-25 20:48:28 -07:00
lawrencecchen 17bf72e114 Merge current main before integration gate
# Conflicts:
#	tests/test_ci_self_hosted_guard.sh
#	web/scripts/run-tests.sh
#	web/tests/web-test-runner-isolation.test.ts
2026-07-25 19:55:40 -07:00
Lawrence Chen 265d1763c4 Fix current main Swift CI regressions (#8892)
* fix: repair current main Swift CI

* fix: resolve feature flags on main actor
2026-07-25 19:53:01 -07:00
lawrencecchen 20ee080934 Merge PR 8948 integration review repairs 2026-07-25 19:52:57 -07:00
lawrencecchen 7a26db9e77 test: rely on executed Bun version guard 2026-07-25 19:52:21 -07:00
lawrencecchen 0e9a1794d4 test: cover Bun patch isolation boundary 2026-07-25 19:51:32 -07:00
lawrencecchen 41204b7231 Localize machine-agent startup discovery 2026-07-25 19:49:49 -07:00
lawrencecchen 3f45e2dc11 test: cover localized machine-agent discovery 2026-07-25 19:49:21 -07:00
lawrencecchen 7afcfd0bd5 Fix deterministic web test toolchain 2026-07-25 19:46:51 -07:00
lawrencecchen 165ffb05f6 Protect machine-agent diagnostics and pairing codes 2026-07-25 19:45:37 -07:00
lawrencecchen 5386e68e59 test: cover machine-agent diagnostic safety 2026-07-25 19:44:40 -07:00
Lawrence Chen 5d265828fa Isolate web test module globals (#8891)
* test: require isolated web test globals

* fix: isolate web test module globals

* test: cover shared web runner isolation

* fix: share isolated web test runner

* test: bound shared runner regression

* test: exercise shared runner discovery

* fix: preserve Bun recursive discovery

* fix: sort recursive web test discovery

* fix: preserve option-only test discovery

* fix: preserve optional and live test modes

* test: stabilize runner checks in agent mode

* test: harden web runner cleanup

* fix: fail closed on discovery errors

* fix: honor Bun discovery configuration

* fix: preserve zero-test discovery semantics
2026-07-25 19:40:26 -07:00
lawrencecchen 332431c0ca test: fence mock-isolated Bun execution 2026-07-25 19:39:02 -07:00
lawrencecchen b4a1ab2680 Merge PR 8947 web runner output-order repair 2026-07-25 19:31:27 -07:00
lawrencecchen ac512957de Fix external pairing recovery and diagnostics 2026-07-25 19:30:24 -07:00
lawrencecchen f01097c5a5 test: cover pairing protocol recovery regressions 2026-07-25 19:30:06 -07:00
lawrencecchen f151bd30d5 test: allow concurrent Bun heading order 2026-07-25 19:19:50 -07:00
lawrencecchen 3ed4fa809f fix: preserve zero-test discovery semantics 2026-07-25 19:16:12 -07:00
lawrencecchen 24cc58a1d9 Merge exact version-stable web runner repair 2026-07-25 19:14:10 -07:00
lawrencecchen 55b3b3531e Fix machine-agent CLI and pairing flow 2026-07-25 19:12:54 -07:00
lawrencecchen 6254aa88a5 test: cover localized help and pairing switch 2026-07-25 19:12:04 -07:00
lawrencecchen 30965cefda fix: honor Bun discovery configuration 2026-07-25 19:06:49 -07:00
lawrencecchen 1a125aefbd fix(remote): terminate revoked RPC clients promptly 2026-07-25 19:05:29 -07:00
lawrencecchen 77492f9a35 Fix external pairing failure recovery 2026-07-25 19:00:04 -07:00
lawrencecchen dd502b1ea7 test: cover refresh-time pairing disconnect 2026-07-25 18:59:44 -07:00
lawrencecchen 47b6b7afac test(remote): reproduce delayed revocation termination 2026-07-25 18:58:26 -07:00
lawrencecchen 12ec8e1168 Merge exact Ghostty package-test stub repair 2026-07-25 18:54:30 -07:00
lawrencecchen f97af18fa7 Fix Ghostty render-grid test stub link 2026-07-25 18:51:42 -07:00
lawrencecchen 5e26dd39b3 fix: fail closed on discovery errors 2026-07-25 18:45:23 -07:00
lawrencecchen b340780de2 test: harden web runner cleanup 2026-07-25 18:30:34 -07:00
cmux reload-cloud 256c6245d3 test: cover system-wide hotkey settings policy 2026-07-25 18:23:46 -07:00
lawrencecchen 33a09c724d Merge commit '7e092af21a628ac7d7e54b8a5b599281cddb8b6b' into task-integrate-web-determinism-repairs 2026-07-25 18:22:47 -07:00
lawrencecchen 7e092af21a Fix current Swift warning regressions 2026-07-25 18:17:21 -07:00
lawrencecchen 34775fefa3 test: stabilize runner checks in agent mode 2026-07-25 18:14:56 -07:00
austinpower1258 3958c9ea75 test: cover design handoff review regressions 2026-07-25 18:14:31 -07:00
cmux reload-cloud 40e2d26f94 chore: keep shared shortcut catalog below file limit 2026-07-25 18:03:32 -07:00
cmux reload-cloud adad276131 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey
# Conflicts:
#	Sources/KeyboardShortcutSettingsLookup.swift
2026-07-25 18:01:46 -07:00
lawrencecchen 9c94361435 Tighten provider confirmation state 2026-07-25 18:00:46 -07:00
austinpower1258 f0d519b7a9 Merge remote-tracking branch 'origin/main' into issue-8826-design-mode-payload-tmpfile 2026-07-25 17:57:10 -07:00
austinpower1258 624cd47cb2 fix: bound design mode capture handoff 2026-07-25 17:57:07 -07:00
lawrencecchen b3c17ac557 Add typed workspace provider actions 2026-07-25 17:52:22 -07:00
Lawrence Chen 05c6fb0344 Drain hook stdin before neutral exits (#8863)
* Test Codex disabled hook stdin drainage

* Drain agent hook stdin before no-op exit

* Test Codex hook script overwrite isolation

* Isolate generated Codex hooks by content

* Test content-addressed hook sanitization

* Sanitize content-addressed Codex hooks

* Test embedded hook path preservation

* Share immutable Codex hook names

* Test spaced Codex hook paths

* Recognize spaced generated hook paths

* Test generated hook ownership boundaries

* Fail closed on generated hook ownership

* Test compound hook command preservation

* Reject shell syntax in generated hook paths

* Test escaped hook command preservation

* Reject escaped generated hook commands

* Test multi-token hook command preservation

* Recognize complete Codex hook injection block

* Test legacy Codex hook schema stripping

* Share versioned Codex hook injection schemas

* Document Codex hook schema API
2026-07-25 17:41:26 -07:00
lawrencecchen c9f82e66fc Merge remote-tracking branch 'origin/main' into fix-founders-isolation-review 2026-07-25 17:40:22 -07:00
lawrencecchen ac0ccdd51d fix: preserve optional and live test modes 2026-07-25 17:40:01 -07:00
cmux reload-cloud 364f1895ec fix: keep shared shortcut catalog complete 2026-07-25 17:35:43 -07:00
austinpower1258 f31fa582da test: cover bounded design mode capture handoff 2026-07-25 17:34:35 -07:00
lawrencecchen 5b1372b7e9 Merge commit '8ad567fa96a965b4920973677dfc1f126468321e' into task-integrate-web-determinism-repairs 2026-07-25 17:28:34 -07:00
cmux reload-cloud e7a9227e30 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-8752-move-surface-between-panes
# Conflicts:
#	Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift
#	Sources/ControlSurfaceResumeTarget.swift
#	Sources/DockSplitStore+SessionSnapshot.swift
#	cmux.xcodeproj/project.pbxproj
2026-07-25 17:26:53 -07:00
Austin Wang f193d6ba8c Merge pull request #8841 from manaflow-ai/issue-8772-reopen-closed-workspace
Reopen closed workspaces with sticky repo identity
2026-07-25 17:21:53 -07:00
lawrencecchen 164852e27c fix: preserve option-only test discovery 2026-07-25 17:21:36 -07:00
lawrencecchen 6f455e9e59 fix(pty): preserve macOS child exec failures 2026-07-25 17:21:07 -07:00
lawrencecchen 88e1ad1794 test(pty): reproduce hidden macOS exec failure 2026-07-25 17:17:53 -07:00
austinpower1258 2228ab532b fix: stop unavailable media playback 2026-07-25 17:13:06 -07:00
lawrencecchen c56c8b9063 Add durable provider notice delivery 2026-07-25 17:12:57 -07:00
lawrencecchen 9d03ceba87 Merge commit '9bb1151f3c0e4b09c766ce57ab120103b03df96f' into task-integrate-web-determinism-repairs 2026-07-25 17:12:19 -07:00
lawrencecchen df39455bd3 Merge commit '990bf083e61d4704625d05502728d6f91343a52d' into task-integrate-web-determinism-repairs 2026-07-25 17:12:19 -07:00
Abdulaziz Albaharandcmux reload-cloud 51ddd420e9 Use native workspace-only bottom search on iOS (#8645)
* feat(ios): add workspace-only bottom search

* fix(ios): use native toolbar workspace search

* fix(ios): integrate workspace search with tab bar

* test(ios): cover persistent contextual search

* fix(ios): keep search stable across primary tabs

* test(ios): focus notification search assertion

* fix(ios): make notification search resilient

* fix(ios): close contextual search review feedback

* fix(ios): stabilize contextual search state

* fix(ios): preserve search query across submit cleanup

* fix(ios): address search review lifecycle

* fix(ios): isolate search state and projection work

* fix(ios): split search content from root lifecycle

* fix(ios): guard search activation ownership

* fix(ios): deactivate search before result navigation

* fix(ios): preserve search result navigation ownership

* fix(ios): keep notification search open until commit

* fix(ios): isolate notification search navigation path

* fix(ios): show workspace action failures from search

* fix(ios): bound notification feed search source

* fix(ios): stabilize retained notification feed revisions

* fix(ios): bound notification feed aggregation work

* fix(ios): keep notification feed persistence loadable

* fix(ios): bound notification feed wire recovery

* fix(ios): preserve bounded notification feed revisions

* fix(ios): make notification feed recovery transactional

* fix(ios): bound notification feed recovery scan

* fix(ios): isolate search drafts and recovery metadata

* fix(ios): align notification feed quarantine naming

* fix(ios): bound notification feed history text

* fix(ios): bound notification feed ingress recovery

* fix(ios): coalesce notification feed persistence

* fix(ios): parse notification feed history metadata safely

* fix(ios): recover oversized notification feed metadata from tail

* fix(ios): retain notification feed projections during rebuild

* fix(ios): disable stale notification rows during source rebuild

* fix(ios): bound notification feed migration and decode

* fix(ios): fail closed on oversized feed edge cases

* fix(ios): cancel notification feed decode workers

* fix(ios): cap notification search source state

* fix(ios): normalize primary search queries

* fix(ios): preserve editable search drafts

* fix(ios): preserve filtered notification empty states

* fix(mac): retire oversized history quarantines

* fix(ios): route notification links by search scope

* fix(ios): preserve notification source tails

* fix(mac): fit notification feed frames in one pass

* fix(ios): aggregate scoped notification feeds

* fix(mac): recover oversized history scan exhaustion

* fix(ios): target notification feed bulk scope

* fix(ios): satisfy notification search policy gates

* fix(ios): ignore initial native search cleanup

* fix(ios): use native task composer toolbar on iOS 26

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-25 19:10:52 -05:00
lawrencecchen 9bb1151f3c test: compare dock duration structurally 2026-07-25 17:09:19 -07:00
cmux reload-cloud 8172bcf166 Construct customization store on main actor 2026-07-25 17:08:13 -07:00
lawrencecchen cce59bbbde fix(remote): terminate owner-disconnected sessions 2026-07-25 17:05:50 -07:00
lawrencecchen a091d2175b fix: sort recursive web test discovery 2026-07-25 17:04:52 -07:00
cmux reload-cloud 312abf7ea5 Fix workspace group replacement creation 2026-07-25 17:02:55 -07:00
lawrencecchen 8b36f74982 test(remote): reproduce endless owner disconnect reconnect 2026-07-25 17:02:51 -07:00
lawrencecchen a8b38b308f fix(tui): surface cloud reader spawn failures 2026-07-25 17:00:05 -07:00
lawrencecchen b9e40274bb test(tui): cover cloud reader spawn failure 2026-07-25 16:57:49 -07:00
cmux reload-cloud 4ce7fec278 test: disambiguate shared shortcut catalog 2026-07-25 16:43:55 -07:00
cmux reload-cloud eb9f82b5ff test: cover shared shortcut catalog parity 2026-07-25 16:30:59 -07:00
austinpower1258 aa8eb625b3 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-8826-design-mode-payload-tmpfile
# Conflicts:
#	Sources/DockSplitStore+SessionSnapshot.swift
2026-07-25 16:19:49 -07:00
cmux reload-cloud aef6b0d89e fix: unify shortcut persistence and registration policy 2026-07-25 16:18:05 -07:00
austinpower1258 de8e9d8d1e Harden design mode screenshot capture 2026-07-25 16:17:11 -07:00
cmux reload-cloud d9dafb85e4 test: cover shortcut policy closeout regressions 2026-07-25 16:13:11 -07:00
austinpower1258 17fc92fa64 test: cover unavailable media teardown 2026-07-25 15:54:50 -07:00
austinpower1258 1a3eda15ce Merge commit 'f8693b05c44407d4604af490f8c846ec096d6e0d' into issue-8652-file-preview-refresh 2026-07-25 15:52:58 -07:00
cmux reload-cloud 5d274909ea fix: preserve invalid managed shortcut ownership 2026-07-25 15:40:54 -07:00
Austin Wang f8693b05c4 Merge pull request #8874 from manaflow-ai/issue-2720-browser-profile-flag
Add browser profile targeting to CLI pane creation
2026-07-25 15:38:23 -07:00
cmux reload-cloud da7fc3633f test: cover invalid managed shortcut display 2026-07-25 15:29:39 -07:00
Abdulaziz Albahar 352ece0031 Fix verified replay crash on iOS surface queue (#8910)
* test: cover verified replay surface queue submission

* fix: keep verified replay submission off MainActor
2026-07-25 17:13:04 -05:00
cmux reload-cloud 120b72c794 fix: route system-wide shortcut through shared policy 2026-07-25 15:10:09 -07:00
cmux reload-cloud f958414566 fix: preserve managed shortcut policy invariants 2026-07-25 14:57:14 -07:00
cmux reload-cloud a205c5f475 test: cover shortcut policy review regressions 2026-07-25 14:54:23 -07:00
Austin Wang daa6f683b2 Merge pull request #8839 from manaflow-ai/issue-8837-resume-shell-zshrc
Use the normal terminal shell for agent auto-resume (#8837)
2026-07-25 14:40:04 -07:00
ejc3 38d44e5f12 notifications: surface-scoped mark-read keeps the focused-read indicator
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.
2026-07-25 13:52:28 -07:00
cmux reload-cloud 9303fabcf9 test: fix managed shortcut notification coverage 2026-07-25 11:13:23 -07:00
cmux reload-cloud 5e348b6c56 fix: harden global search shortcut policy 2026-07-25 11:01:26 -07:00
cmux reload-cloud c75ffcb865 test: cover global search shortcut policy edges 2026-07-25 10:59:48 -07:00
cmux reload-cloud 697dc6c9d0 fix: avoid search visibility work for unrelated keys 2026-07-25 10:30:00 -07:00
cmux reload-cloud a184573d3a fix: unify effective shortcut resolution 2026-07-25 10:19:29 -07:00
cmux reload-cloud e977ff9fdd test: cover effective shortcut display policy 2026-07-25 10:14:42 -07:00
cmux reload-cloud 03bf429aa5 test: use valid search monitor shortcut fixture 2026-07-25 09:59:24 -07:00
cmux reload-cloud ff2c167356 fix: import settings recorder state for search routing 2026-07-25 09:44:22 -07:00
cmux reload-cloud 951e8e40f6 fix: route visible global search shortcuts before popover input 2026-07-25 09:37:28 -07:00
cmux reload-cloud ebc8cc8f46 test: isolate global search monitor chain suite 2026-07-25 09:15:34 -07:00
austinpower1258 c378b4fb93 Fix Feed callback test synchronization scope 2026-07-25 09:03:27 -07:00
cmux reload-cloud 1e2a1c2655 test: fix global search suite project wiring 2026-07-25 08:55:02 -07:00
austinpower1258 e1796976c8 Bound fallback scans and keep Feed publication off main 2026-07-25 08:51:07 -07:00
cmux reload-cloud d5aab5856f test: cover global search local monitor routing 2026-07-25 08:48:34 -07:00
austinpower1258 84c91727a1 Add review regression coverage for bounded fallback work 2026-07-25 08:47:52 -07:00
austinpower1258 e4064278ed Allow independent Feed deliveries to progress 2026-07-25 08:26:40 -07:00
cmux reload-cloud 40be8abd72 fix: share global search input ownership policy 2026-07-25 08:19:59 -07:00
cmux reload-cloud fab2a97a57 test: cover shortcut ownership edge cases 2026-07-25 08:16:57 -07:00
austinpower1258 baa6c92bc9 Add cross-session Feed isolation regression 2026-07-25 08:04:17 -07:00
cmux reload-cloud 83a7f4254e fix: enforce persisted shortcut policy 2026-07-25 07:49:28 -07:00
austinpower1258 16b9f78855 Avoid redundant Feed sync result warnings 2026-07-25 07:48:40 -07:00
cmux reload-cloud d6e2312377 test: cover default global search hotkey collision 2026-07-25 07:40:46 -07:00
cmux reload-cloud 726ed9ed01 test: make global search cleanup nonisolated 2026-07-25 07:37:32 -07:00
cmux reload-cloud eba16e9d7c test: fix global search policy suite isolation 2026-07-25 07:27:33 -07:00
cmux reload-cloud 4510e20586 test: reject invalid persisted global search shortcuts 2026-07-25 07:14:32 -07:00
austinpower1258 0cd0465711 Return authoritative Feed acceptance atomically 2026-07-25 06:38:49 -07:00
austinpower1258 8288228764 Add authoritative Feed handoff regression 2026-07-25 06:37:40 -07:00
austinpower1258 24c8eecf05 Keep Feed callbacks outside commit lock 2026-07-25 06:17:28 -07:00
austinpower1258 8b8083725f Add stalled Feed callback deadline regression 2026-07-25 06:16:39 -07:00
austinpower1258 6b152c219a Bound Feed deadline composition 2026-07-25 05:55:21 -07:00
austinpower1258 f2ba6498da Add Feed deadline composition regressions 2026-07-25 05:53:10 -07:00
cmux reload-cloud 76a4595d81 fix: preserve global search dead-key input 2026-07-25 05:13:13 -07:00
cmux reload-cloud f70fcb4235 test: cover option dead keys in global search 2026-07-25 05:11:02 -07:00
cmux reload-cloud baaeb96d8c fix: keep bare space in visible global search 2026-07-25 05:01:02 -07:00
cmux reload-cloud 6fec4592e3 test: cover bare space in visible global search 2026-07-25 05:00:28 -07:00
cmux reload-cloud 7f79228f3a fix: preserve global search editing compatibility 2026-07-25 04:46:16 -07:00
cmux reload-cloud aa5897b8af test: cover global search compatibility gaps 2026-07-25 04:28:57 -07:00
austinpower1258 ff65b1deb3 Order Feed acknowledgments after publication 2026-07-25 04:19:42 -07:00
austinpower1258 de191246a8 Add Feed acknowledgment review regressions 2026-07-25 04:18:05 -07:00
cmux reload-cloud 7422db64ae fix: unify global search shortcut validation 2026-07-25 04:17:42 -07:00
cmux reload-cloud 423c4a2344 test: cover global search settings consistency 2026-07-25 03:59:46 -07:00
cmux reload-cloud f71dc914a3 fix: preserve search editing and shortcut validation 2026-07-25 03:45:59 -07:00
austinpower1258 6105ca937d Fix Pi Feed ownership regression observations 2026-07-25 03:38:28 -07:00
cmux reload-cloud 3d48f54f23 test: exercise settings media key rejection in app target 2026-07-25 03:32:24 -07:00
cmux reload-cloud 3501f6c3fc test: preserve search editing and media key validation 2026-07-25 03:24:43 -07:00
cmux reload-cloud 6d06843ff6 fix: keep search visibility off typing fast path 2026-07-25 03:15:11 -07:00
ejc3 d0c5a4d5a2 markdown: stop double-scaling mermaid diagrams under viewport-scaling pageZoom
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.
2026-07-25 03:07:54 -07:00
cmux reload-cloud 925c401e38 test: make browser shortcut focus deterministic 2026-07-25 02:49:47 -07:00
cmux reload-cloud 35a417a0b0 fix: preserve visible search editing shortcuts 2026-07-25 02:48:03 -07:00
austinpower1258 b3b28d70e6 Run blank Pi target regression in CI 2026-07-25 02:43:43 -07:00
austinpower1258 ab124ec93e Use async transcript deadline proof 2026-07-25 02:40:36 -07:00
ejc3 61fde69054 settings: import appearance at store init, never live-apply it
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.
2026-07-25 02:37:39 -07:00
ejc3 26ce11ad75 agents: keep pi launch arguments when resuming a restored session
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.
2026-07-25 02:34:54 -07:00
cmux reload-cloud fa60bb04b0 test: preserve visible search query editing ownership 2026-07-25 02:34:03 -07:00
austinpower1258 72d9e342b2 Bound transcript and Feed ownership work 2026-07-25 02:28:19 -07:00
cmux reload-cloud de93f04899 test: release omnibar focus before browser shortcut check 2026-07-25 02:23:43 -07:00
cmux reload-cloud e917d43cf5 test: isolate global hotkey registration probe 2026-07-25 02:21:06 -07:00
austinpower1258 a664a380e6 Add final review isolation regressions 2026-07-25 02:21:01 -07:00
ejc3 8dc21c28b2 cmuxTests: model shipped behavior in two input/transport fixtures
- 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.
2026-07-25 02:20:21 -07:00
cmux reload-cloud cc7e7203ff fix: preserve command palette editing shortcuts 2026-07-25 02:11:39 -07:00
cmux reload-cloud 9f4a5a1a0e test: stabilize browser shortcut focus harness 2026-07-25 02:11:11 -07:00
austinpower1258 c51d122d87 Honor CLI deadlines and reject blank Pi targets 2026-07-25 01:58:23 -07:00
austinpower1258 dfeaf664c3 Add final review regression coverage 2026-07-25 01:54:31 -07:00
ejc3 5e40767f6a cmuxTests: catch three suites up to shipped behavior
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.
2026-07-25 01:48:57 -07:00
cmux reload-cloud 4125458e20 test: preserve command palette editing ownership 2026-07-25 01:29:58 -07:00
austinpower1258 cb3420a46e Fix reviewed feed and transcript races 2026-07-25 01:26:09 -07:00
austinpower1258 0f55047d15 Add review regression coverage 2026-07-25 01:24:03 -07:00
cmux reload-cloud d1521334b3 fix: let visible search own its scoped toggle 2026-07-25 01:04:04 -07:00
austinpower1258 5ea12836d8 Make keyboard transition duration test deterministic 2026-07-25 00:54:08 -07:00
cmux reload-cloud 3f27207ffe test: cover scoped visible global search toggles 2026-07-25 00:52:27 -07:00
austinpower1258 fb0805f9fc Coalesce agent transcript fallback resolution 2026-07-25 00:42:53 -07:00
cmux reload-cloud 9c61096447 perf: keep popover lookup off ordinary input 2026-07-25 00:34:07 -07:00
cmux reload-cloud f3468c6687 test: wait for global search popover dismissal 2026-07-25 00:31:30 -07:00
cmux reload-cloud 3a9f219260 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-24 22:18:38 -07:00
lawrencecchen 71a1df0b12 fix(pty): share nonblocking macOS allocation 2026-07-24 22:18:22 -07:00
cmux reload-cloud d7aa02b141 fix: route visible global search popover shortcuts 2026-07-24 22:18:11 -07:00
austinpower1258 33d3af701a Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking 2026-07-24 22:15:38 -07:00
lawrencecchen 0f3e7bfb0e test(tui): reproduce blocked macOS surface spawn 2026-07-24 22:09:43 -07:00
lawrencecchen 58bea4c80f fix(remote): avoid blocking macOS PTY metadata lookup 2026-07-24 22:00:39 -07:00
ejc3 4a11af6f08 cmuxTests: give the CLI mock servers an owned lifecycle and one shared loop
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.
2026-07-24 21:57:33 -07:00
ejc3 71eea9f69f cmuxTests: make CLI mock control sockets headless-robust; refresh vm-new expectations
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.
2026-07-24 21:57:33 -07:00
ejc3 563caf6c04 cmuxTests: give FileDropOverlayViewTests the environment object ContentView requires
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.
2026-07-24 21:55:43 -07:00
ejc3 ed9cde1502 cmuxTests: stop grading the CLI spawn on a five-second stopwatch
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.
2026-07-24 21:55:39 -07:00
lawrencecchen 26da34a37c test(remote): reproduce blocked macOS PTY spawn 2026-07-24 21:54:10 -07:00
Abdulaziz AlbaharandClaude Fable 5 da8a58a1a2 Make iOS terminal scrolling local and buttery with screen-anchored render grids (#8860)
* 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]>
2026-07-24 23:40:18 -05:00
Abdulaziz AlbaharandClaude Fable 5 8d23d338ae iOS: scope device-keyed Mac state to the exact pairing (#8901)
* 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]>
2026-07-24 23:05:12 -05:00
lawrencecchen 86f0733415 fix(remote): bootstrap Iroh auto through relay 2026-07-24 21:03:52 -07:00
lawrencecchen f6c17f062a test(remote): reproduce Iroh auto direct starvation 2026-07-24 21:01:33 -07:00
cmux reload-cloud 9d159c9d84 test: cover visible global search shortcut routing 2026-07-24 20:25:36 -07:00
Abdulaziz AlbaharandClaude Fable 5 4d7144e2b7 Demote QR pairing from primary surfaces (#8821)
* 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]>
2026-07-24 22:17:52 -05:00
austinpower1258 d08618e86b Add failing concurrent Codex history regression 2026-07-24 20:11:46 -07:00
Abdulaziz Albahar 4d9c2389f6 Fix iOS terminal dock keyboard pinning (#8899) 2026-07-24 21:53:36 -05:00
Abdulaziz AlbaharandClaude Fable 5 caf2140cd2 Fix iOS build: form the owned-userdata release callback from a closure literal (#8897)
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]>
2026-07-24 21:26:39 -05:00
lawrencecchen 990bf083e6 fix: preserve Bun recursive discovery 2026-07-24 19:12:03 -07:00
lawrencecchen 8ad567fa96 Fix current Swift warning regressions 2026-07-24 19:09:19 -07:00
Abdulaziz Albahar 46cc01fd1a iOS: keep healthy connections across foreground, stop quick-cycle reconnect stalls (#8825)
* test(ios): cover foreground reconnect recovery

* fix(ios): preserve healthy foreground connections
2026-07-24 21:08:19 -05:00
lawrencecchen 328a2bed2e fix(remote): bound initial carrier attempts 2026-07-24 19:07:23 -07:00
lawrencecchen e493e1ec72 test(remote): reproduce wedged initial route attempts 2026-07-24 19:02:21 -07:00
lawrencecchen eff14c0386 test: exercise shared runner discovery 2026-07-24 18:59:43 -07:00
lawrencecchen fc475d3203 test: bound shared runner regression 2026-07-24 18:51:53 -07:00
Abdulaziz AlbaharandClaude Fable 5 acef72fa45 Hide one computer row without hiding sibling build instances (#8877)
* 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]>
2026-07-24 20:51:34 -05:00
Lawrence Chen f1e1f41b6b Auto-merged main into fix/founders-welcome-test-isolation on deployment. 2026-07-24 18:45:28 -07:00
Abdulaziz AlbaharandClaude Fable 5 f6085bbb92 Adopt the fork's cancellable ConnectAttempt for Iroh client dials (#8878)
* 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]>
2026-07-24 20:43:06 -05:00
lawrencecchen a79e402bba fix: share isolated web test runner 2026-07-24 18:37:45 -07:00
lawrencecchen 35340bda22 test: cover shared web runner isolation 2026-07-24 18:36:07 -07:00
austinpower1258 2adec55c39 Use the normal terminal shell for auto-resume (#8837) 2026-07-24 18:22:02 -07:00
cmux reload-cloud bf27d699f6 test: focus browser through product transition 2026-07-24 18:19:32 -07:00
lawrencecchen 36c99ec537 fix: isolate web test module globals 2026-07-24 18:05:55 -07:00
lawrencecchen 8d07c458c9 fix(remote): retry failed Iroh carrier dials 2026-07-24 18:05:05 -07:00
lawrencecchen 0f9c76cd80 test(remote): reproduce terminal Iroh dial failures 2026-07-24 18:02:16 -07:00
cmux reload-cloud 972beedf85 test: restore hotkey settings from teardown 2026-07-24 17:48:50 -07:00
cmux reload-cloud 9060ec48c2 fix: preserve focused browser editing before search chords 2026-07-24 17:48:38 -07:00
austinpower1258 25fb8658a4 Test auto-resume uses one normal shell startup (#8837) 2026-07-24 17:45:40 -07:00
austinpower1258 26ee8f65f5 Merge origin/main into issue-8872-io-gather-poll-spin 2026-07-24 17:42:56 -07:00
cmux reload-cloud 0332842a9f test: verify opt-in Carbon hotkey registration 2026-07-24 17:38:36 -07:00
austinpower1258 60fcc53765 Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-8672-pi-extension-spawnsync-blocking 2026-07-24 17:35:51 -07:00
cmux reload-cloud 044d41dae5 test: preserve browser editing before search chords 2026-07-24 17:29:39 -07:00
cmux reload-cloud 7b8f38351e Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-24 17:28:43 -07:00
Austin Wang 67d8479e05 Merge pull request #8848 from manaflow-ai/issue-8808-typing-lag-regression
fix: eliminate Ghostty terminal typing lag
2026-07-24 17:26:32 -07:00
Lawrence Chen 590863ffd6 Fix TestFlight demo variant CI guard (#8887)
* Test TestFlight demo variant event gating

* Gate TestFlight demo identity to manual runs

* Test TestFlight summary variant identity

* Report the selected TestFlight variant

* Test TestFlight override summary identity

* Report external TestFlight override identity

* Test TestFlight assignment ownership

* Route TestFlight assignment by upload lane

* Test TestFlight summary lane audience

* Report the selected TestFlight audience
2026-07-24 17:24:05 -07:00
lawrencecchen 499fbe1dd4 test: require isolated web test globals 2026-07-24 17:08:32 -07:00
cmux reload-cloud 10f1510b0b test: remove unreliable inter-app UI harness 2026-07-24 17:00:35 -07:00
cmux reload-cloud bfa654c782 test: expose shortcut probe status to UI automation 2026-07-24 16:48:46 -07:00
cmux reload-cloud caa99c8aa6 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-24 16:30:23 -07:00
lawrencecchen 0b8c52171d fix(remote): preserve dialable Iroh runtime hints 2026-07-24 16:24:53 -07:00
cmux reload-cloud c07f93cddf fix: validate shortcuts before conflicts 2026-07-24 16:22:20 -07:00
lawrencecchen ac29568837 test(remote): reproduce stripped Iroh runtime hints 2026-07-24 16:21:46 -07:00
cmux reload-cloud 05dcae6f94 fix: reject unsupported global search media bindings 2026-07-24 16:07:56 -07:00
cmux reload-cloud 50d3043332 test: reject inert global search media bindings 2026-07-24 16:06:45 -07:00
cmux reload-cloud 6535c54529 test: harden global search shortcut fixtures 2026-07-24 15:41:39 -07:00
cmux reload-cloud aff9586352 build: pin rotated GhosttyKit archive checksum 2026-07-24 15:33:57 -07:00
cmux reload-cloud cfeb3ba64a fix: rotate Ghostty frame leases for terminal echo 2026-07-24 15:28:16 -07:00
cmux reload-cloud 1285cf9ea5 fix: disarm terminal text box Escape on typing fast path 2026-07-24 15:26:28 -07:00
cmux reload-cloud b8ac163a64 fix: respect focused shortcut owners for global search 2026-07-24 15:21:15 -07:00
austinpower1258 cd1884663c Defer transcript fallback scans from Feed ingress 2026-07-24 15:10:42 -07:00
cmux reload-cloud d5d9816034 test: fix global search priority harness 2026-07-24 15:03:39 -07:00
austinpower1258 8f99c91261 Pin GhosttyKit for dead PTY fix 2026-07-24 15:03:03 -07:00
austinpower1258 d4db6cca8e Update Ghostty for dead PTY cleanup 2026-07-24 14:56:11 -07:00
cmux reload-cloud b0389d6438 test: preserve global search shortcut priority 2026-07-24 14:51:44 -07:00
austinpower1258 fc1768bc67 Add failing Codex Feed transcript scan test 2026-07-24 14:49:26 -07:00
cmux reload-cloud 1e48fe2905 test: break text box Escape arm on typing 2026-07-24 14:46:58 -07:00
cmux reload-cloud 0570749ada fix: defer global search to shared input gates 2026-07-24 14:41:57 -07:00
cmux reload-cloud 5aeef7c126 fix: preserve focused input for global search remaps 2026-07-24 14:37:45 -07:00
cmux reload-cloud 00fe273137 test: preserve focused input for global search remaps 2026-07-24 14:35:42 -07:00
cmux reload-cloud 661de99ef1 test: isolate foreground shortcut verification 2026-07-24 14:26:04 -07:00
cmux reload-cloud e5c03bc3e9 test: import remote workspace configuration 2026-07-24 14:22:08 -07:00
cmux reload-cloud 42a0eee09b test: import sidebar fixture dependencies 2026-07-24 14:17:08 -07:00
cmux reload-cloud 21fc8d3722 fix: isolate remote mirror snapshot check 2026-07-24 14:09:53 -07:00
cmux reload-cloud ca5a8b04e5 fix: resolve terminal typing through shortcut window 2026-07-24 14:07:28 -07:00
cmux reload-cloud 57e019778d fix: reject profiles in remote workspaces 2026-07-24 13:58:17 -07:00
cmux reload-cloud 0185c31c59 Merge remote-tracking branch 'origin/main' into issue-8808-typing-lag-regression
# Conflicts:
#	Sources/ControlSurfaceResumeTarget.swift
#	Sources/DockSplitStore+SessionSnapshot.swift
#	cmuxTests/DockWorkingDirectoryInheritanceTests.swift
2026-07-24 13:57:40 -07:00
cmux reload-cloud 120c9b6b3f test: reject profiles in remote workspaces 2026-07-24 13:57:11 -07:00
cmux reload-cloud 1b5051d6e5 test: avoid global shortcut routing seam 2026-07-24 13:56:25 -07:00
cmux reload-cloud fa69752d32 fix: bypass global routing for terminal text 2026-07-24 13:55:30 -07:00
austinpower1258 06db9006f7 Isolate dock snapshot mirror check to main actor 2026-07-24 13:49:45 -07:00
cmux reload-cloud 827861dde1 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-24 13:49:30 -07:00
cmux reload-cloud 8e1a2bb2b2 fix: preserve foreground global search input ownership 2026-07-24 13:49:25 -07:00
cmux reload-cloud 59cba9b4b8 Fix merged sidebar table test import 2026-07-24 13:48:54 -07:00
austinpower1258 6118eab44c Fix dock snapshot actor-isolation warning 2026-07-24 13:47:23 -07:00
cmux reload-cloud 50c338cd8c fix: avoid actor-isolated workspace key path 2026-07-24 13:45:08 -07:00
Abdulaziz AlbaharandClaude Fable 5 3eeed22dec Allow dev.cmux.app.demo as a production APNs topic (#8875)
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]>
2026-07-24 15:43:48 -05:00
cmux reload-cloud 385a12aec0 test: cover global search chord palette precedence 2026-07-24 13:36:51 -07:00
cmux reload-cloud a6fb09a343 Fix merged sidebar suspension test import 2026-07-24 13:21:30 -07:00
cmux reload-cloud ccb2516d30 test: observe terminal shortcut context resolution 2026-07-24 13:19:58 -07:00
cmux reload-cloud e589c8990f test: avoid shortcut fixture macro shadowing 2026-07-24 13:16:07 -07:00
austinpower1258 2a20718e45 Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking
# Conflicts:
#	Sources/ControlSurfaceResumeTarget.swift
#	Sources/DockSplitStore+SessionSnapshot.swift
2026-07-24 13:13:53 -07:00
cmux reload-cloud a6f9fe7e8c test: cover final global search ownership gaps 2026-07-24 13:12:27 -07:00
cmux reload-cloud 9bdc02f864 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey
# Conflicts:
#	Sources/AppDelegate.swift
#	Sources/KeyboardShortcutSettings.swift
2026-07-24 13:11:44 -07:00
Abdulaziz AlbaharandClaude Fable 5 ebf76884c8 Add demo variant to the iOS TestFlight lane (#8876)
* 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]>
2026-07-24 15:10:03 -05:00
cmux reload-cloud 93857cd7f1 test: finish terminal shortcut fixture rename 2026-07-24 13:03:48 -07:00
austinpower1258 1a2507a1e7 Return retryable result while resume approval loads 2026-07-24 13:01:34 -07:00
cmux reload-cloud ae54d2e892 test: compile shortcut fast path fixture 2026-07-24 12:55:27 -07:00
Austin Wangandcmux reload-cloud caf6ef7ef3 Fix sidebar reopen cutoff render (#8626)
* test: reproduce AppKit sidebar visibility remount

* fix: preserve AppKit sidebar across visibility changes

* fix: close sidebar visibility review findings

* fix: restore focus when hiding AppKit sidebar

* fix: scope persistent sidebar lifecycle

* fix: quiesce hidden AppKit sidebar

* fix: release hidden sidebar payloads

* test: avoid spinner debug hooks

* fix: close hidden sidebar lifecycle gaps

* test: construct sidebar workspace on main actor

* test: cover checklist suspension lifecycle

* fix: restore checklist state after sidebar suspension

* test: preserve sidebar row height across suspension

* fix: preserve installed sidebar row heights while hidden

* test: defer sidebar edit actions past view updates

* fix: defer sidebar edit commits past view updates

* test: defer hidden sidebar table reloads

* fix: defer hidden sidebar table reloads

* test: split sidebar lifecycle coverage

* Adapt sidebar lifecycle tests to current main

* Adapt sidebar row suspension fixtures

* test: close sidebar status popover on suspension

* fix: complete hidden sidebar suspension cleanup

* test: defer checklist item commit during sidebar suspension

* fix: defer checklist editor commits during suspension

* test: cover sidebar reveal and drag suspension

* fix: reconcile retained sidebar once on reveal

* test: cover sidebar detachment and full focus boundary

* fix: complete sidebar detachment lifecycle

* fix: remove obsolete sidebar reorder cleanup

* test: cover scaled sidebar focus and teardown flush

* fix: preserve focus and edits through sidebar teardown

* test: cover transient sidebar window reparenting

* fix: scope sidebar cleanup to representable teardown

* test: cover stale sidebar host teardown

* fix: preserve sidebar state across host replacement

* test: cover live host detach and header repaint

* fix: separate sidebar host attachment and teardown

* test: cover pooled cells and checklist reparenting

* fix: suspend all retained sidebar cells

* test: preserve checklist draft when switching items

* fix: commit checklist drafts on editor switches

* test: cover retired sidebar row popovers

* fix: clean up retired sidebar rows

* test: cover sidebar drop-target suspension

* fix: retire sidebar drop targets on suspension

* test: cover retired sidebar context menus

* fix: preserve sidebar menu tracking callbacks

* test: cover stale sidebar focus host callbacks

* fix: ignore windowless sidebar focus hosts

* refactor: centralize sidebar visibility mutations

* Fix AppKit row retirement view typing

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-24 12:55:08 -07:00
austinpower1258 9b319bc360 Route Pi Feed events through Dock ownership 2026-07-24 12:51:31 -07:00
cmux reload-cloud 99f472604c fix: require explicit browser profile values 2026-07-24 12:51:21 -07:00
austinpower1258 8de16632c7 Gate Pi Feed Dock ownership in CI 2026-07-24 12:51:17 -07:00
cmux reload-cloud a09135dc31 test: reject missing browser profile selector 2026-07-24 12:50:37 -07:00
austinpower1258 52ac6e31bc Merge origin/main into issue-8837-resume-shell-zshrc 2026-07-24 12:47:32 -07:00
Austin Wangandcmux reload-cloud 64ab9f1ebb Fix focus history shortcut rebinding (#8853)
* 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]>
2026-07-24 12:46:02 -07:00
cmux reload-cloud cdbda5feab Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-24 12:45:26 -07:00
cmux reload-cloud 8ddeefdff5 fix: honor explicit browser profile intent 2026-07-24 12:44:52 -07:00
austinpower1258 f1bdbf31b1 Preserve trusted resume proposals during secret loading 2026-07-24 12:42:48 -07:00
cmux reload-cloud 1e89ef5cb0 test: match nil-window typing events 2026-07-24 12:41:07 -07:00
cmux reload-cloud 03b4d7680c Merge origin/main into issue-2720-browser-profile-flag 2026-07-24 12:36:08 -07:00
austinpower1258 d28a788038 Add failing Pi Feed Dock ownership tests 2026-07-24 12:34:51 -07:00
austinpower1258 e968a92e17 Gate resume approvals on signing secret readiness 2026-07-24 12:33:51 -07:00
cmux reload-cloud 85e5dd5a8c fix: reject ignored browser profile selectors 2026-07-24 12:32:57 -07:00
Austin Wang 251d609a2d Fix composer attachment thumbnail re-rasterization (#8817)
* test: cover inline attachment render reuse

* fix: cache composer attachment thumbnails

* fix: bound attachment thumbnail work

* fix: cancel deleted attachment thumbnails

* test: cover attachment thumbnail cancellation races

* fix: preserve thumbnail work across attachment reuse

* fix: isolate inline attachment cell rendering

* fix: keep thumbnail task teardown actor-safe

* fix: support Xcode 16 thumbnail value types

* test: isolate attachment deletion undo history

* Fix compile errors after main sync

* test: cover direct attachment deletion undo

* fix: reconcile attachment rendering on undo changes
2026-07-24 12:29:33 -07:00
cmux reload-cloud 242422df61 test: make browser profile coverage resilient 2026-07-24 12:24:02 -07:00
cmux reload-cloud 86397b137b test: cover terminal typing shortcut fast path 2026-07-24 12:22:44 -07:00
cmux reload-cloud 59d1b43dfa feat: target browser profiles from CLI 2026-07-24 12:19:04 -07:00
cmux reload-cloud 43e40bad73 test: launch shortcut probe through workspace 2026-07-24 12:16:38 -07:00
cmux reload-cloud 00a5ce5436 Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey
# Conflicts:
#	Sources/ControlSurfaceResumeTarget.swift
#	Sources/DockSplitStore+RestoredAgentLifecycle.swift
#	Sources/DockSplitStore+SessionSnapshot.swift
2026-07-24 12:12:55 -07:00
cmux reload-cloud 60c59bc053 test: cover browser profile selection plumbing 2026-07-24 12:03:39 -07:00
austinpower1258 7ffbfb81b5 Fix merged Dock transfer test fixture 2026-07-24 11:59:19 -07:00
Abdulaziz AlbaharandClaude Fable 5 518822336a Fix main build: import CmuxWorkspaces in DockSplitStore+RestoredAgentLifecycle (#8873)
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]>
2026-07-24 13:34:42 -05:00
Abdulaziz AlbaharandClaude Fable 5 301ddb630b Make notification feed read state a leading swipe with mark-unread (#8868)
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]>
2026-07-24 13:28:05 -05:00
Abdulaziz AlbaharandClaude Fable 5 34e7fa5df2 Fix main build: two compile errors from PR 8690 (#8869)
* 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]>
2026-07-24 13:19:31 -05:00
d9aca68b19 iOS: native Changes viewer — review workspace diffs from the phone (#8221)
* 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]>
2026-07-24 12:19:10 -05:00
cmux reload-cloud f587d37c04 fix: return typed resume approval policies 2026-07-24 06:38:32 -07:00
cmux reload-cloud ec38aae643 fix: disambiguate Dock snapshot types 2026-07-24 06:33:37 -07:00
cmux reload-cloud c483cbe36b docs: record Ghostty userdata lifetime release 2026-07-24 06:27:22 -07:00
cmux reload-cloud e1f4318c50 fix: preserve focused input ownership for global search 2026-07-24 06:27:02 -07:00
cmux reload-cloud 5f5cb5ff57 fix: import workspace shell activity state 2026-07-24 06:24:45 -07:00
cmux reload-cloud f41507ece2 fix: transfer surface bridge lifetime to Ghostty 2026-07-24 06:22:11 -07:00
cmux reload-cloud d85afb8182 chore: pin Ghostty owned userdata lifetime fix 2026-07-24 06:21:47 -07:00
cmux reload-cloud 26279818c1 test: preserve foreground shortcut input ownership 2026-07-24 06:16:03 -07:00
cmux reload-cloud b5dbe85734 Fix shortcut probe activation in UI tests 2026-07-24 06:05:33 -07:00
cmux reload-cloud 68f7aae8e3 perf: cache foreground global search binding 2026-07-24 05:40:16 -07:00
cmux reload-cloud 6eac2ae31a test: keep global search lookup off typing path 2026-07-24 05:39:10 -07:00
austinpower1258 aefd17b8e2 fix: harden design mode screenshot handoff 2026-07-24 05:33:29 -07:00
cmux reload-cloud 4a07b781ad fix: bind renderer continuations to surface lifetime 2026-07-24 05:30:10 -07:00
cmux reload-cloud 80340026a3 Fix WindowDock snapshot actor isolation 2026-07-24 05:28:33 -07:00
cmux reload-cloud b568932360 fix: route Option-only global search chord prefixes 2026-07-24 05:23:34 -07:00
cmux reload-cloud 6a95b3ea79 docs: record complete Ghostty fork pin 2026-07-24 05:22:31 -07:00
cmux reload-cloud 1e6a0efde9 test: reject stale renderer continuation retargeting 2026-07-24 05:13:34 -07:00
cmux reload-cloud 82ed28eaf7 test: cover Option-only global search chord routing 2026-07-24 05:12:29 -07:00
austinpower1258 fdecf8e863 fix: honor pause during media refresh 2026-07-24 05:10:00 -07:00
austinpower1258 975fef8474 test: cover explicit pause during media refresh 2026-07-24 05:08:14 -07:00
cmux reload-cloud aa1f42bd4d docs: record complete Ghostty renderer fix 2026-07-24 05:01:32 -07:00
cmux reload-cloud 4385da28fa fix: preserve Option-only global search bindings 2026-07-24 05:01:17 -07:00
austinpower1258 a565d4a7df fix: preserve user media transport changes 2026-07-24 04:59:11 -07:00
cmux reload-cloud db99fb9c1e chore: pin complete Ghostty redraw delivery fix 2026-07-24 04:54:51 -07:00
austinpower1258 0a5b19f26f test: cover design mode handoff review findings 2026-07-24 04:49:03 -07:00
cmux reload-cloud 8261522d95 test: cover Option-only global search routing 2026-07-24 04:46:53 -07:00
lawrencecchen 2703cbb48d fix: use one renderer continuation path 2026-07-24 04:43:25 -07:00
lawrencecchen 4f9b443016 Merge remote-tracking branch 'origin/issue-8808-typing-lag-regression' into issue-8808-typing-lag-regression 2026-07-24 04:43:06 -07:00
austinpower1258 3317456139 test: cover media refresh transport changes 2026-07-24 04:41:34 -07:00
cmux reload-cloud 7e3f1a536a Bound repeated directional pane moves 2026-07-24 04:32:22 -07:00
cmux reload-cloud bb5e8b191b perf: gate shortcut context behind stroke match 2026-07-24 04:32:06 -07:00
cmux reload-cloud dcf2f0d0a7 test: cover foreground global search toggling 2026-07-24 04:32:02 -07:00
austinpower1258 c2977d91f5 Merge remote-tracking branch 'origin/main' into issue-8652-file-preview-refresh 2026-07-24 04:31:31 -07:00
cmux reload-cloud 1a6baa33c3 Fix WindowDock snapshot actor isolation 2026-07-24 04:31:28 -07:00
cmux reload-cloud 09b71c332f Test repeated pane-move shortcuts stay bounded 2026-07-24 04:31:08 -07:00
cmux reload-cloud 54acae1795 Fix WindowDock isolation warning 2026-07-24 04:23:27 -07:00
cmux reload-cloud 850c06a0c9 Merge remote-tracking branch 'origin/main' into issue-8752-move-surface-between-panes 2026-07-24 04:21:49 -07:00
cmux reload-cloud a40bc9ad5f Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-24 04:21:16 -07:00
austinpower1258 d4a609a385 Merge remote-tracking branch 'origin/main' into issue-8826-design-mode-payload-tmpfile 2026-07-24 04:20:24 -07:00
69b2c55e27 Bound mobile event emission: synchronous admission, shed-or-close, stall teardown (#8858)
* 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]>
2026-07-24 04:18:46 -07:00
cmux reload-cloud c0b6881385 Clarify adjacent shortcut routing alias 2026-07-24 04:18:39 -07:00
austinpower1258 1d482de4b0 Keep browser handoff leases panel-local 2026-07-24 04:18:12 -07:00
austinpower1258 5fc6df463b Test independent browser handoff leases 2026-07-24 04:10:28 -07:00
austinpower1258 c9df0b6043 fix: preserve Quick Look refresh state 2026-07-24 04:07:11 -07:00
austinpower1258 3e44fd7012 fix: consume iOS renderer continuations 2026-07-24 04:04:14 -07:00
austinpower1258 2eef7f8afa Merge remote-tracking branch 'origin/main' into issue-8826-design-mode-payload-tmpfile 2026-07-24 04:03:09 -07:00
cmux reload-cloud 352f65be86 Finish Dock session restore build repair 2026-07-24 04:02:54 -07:00
lawrencecchen 7c45d92cf6 chore: keep renderer lag fix scoped 2026-07-24 04:02:42 -07:00
austinpower1258 56ebee8f1d Fix design handoff lifecycle races 2026-07-24 04:02:22 -07:00
lawrencecchen 52f57fe60e fix: handle Ghostty renderer continuation actions 2026-07-24 04:00:35 -07:00
lawrencecchen c6ebf033d8 test: exercise renderer continuation on a real surface 2026-07-24 04:00:17 -07:00
lawrencecchen f4ef722963 Merge remote-tracking branch 'origin/main' into issue-8808-typing-lag-regression 2026-07-24 03:58:45 -07:00
cmux reload-cloud cc2a88a6b8 Fix Dock session restore import 2026-07-24 03:56:10 -07:00
cmux reload-cloud c8c74bde9a Merge remote-tracking branch 'origin/main' into issue-8561-global-search-background-hotkey 2026-07-24 03:54:51 -07:00
lawrencecchen 9fa20c02f2 fix(remote): order surface lifecycle behind bulk output 2026-07-24 03:51:21 -07:00
cmux reload-cloud a2622c3f3f Bound workspace recovery persistence work 2026-07-24 03:47:55 -07:00
cmux reload-cloud cc1943dcd7 Fix dock working directory test transfer fixture 2026-07-24 03:47:29 -07:00
Lawrence Chen 9a5ed7e8fc Merge pull request #8854 from manaflow-ai/fix/cloud-native-resumability
Retry cloud provider reconnects and refine shared rails
2026-07-24 03:45:49 -07:00
austinpower1258 864d2901c1 Add design handoff lifecycle regressions 2026-07-24 03:45:13 -07:00
cmux reload-cloud 125880a15c Merge origin/main into issue-8561-global-search-background-hotkey 2026-07-24 03:43:13 -07:00
cmux reload-cloud 019b3aa054 test: activate foreground shortcut probe 2026-07-24 03:39:15 -07:00
lawrencecchen e331424745 test(remote): reproduce surface tail overtaking 2026-07-24 03:38:00 -07:00
cmux reload-cloud 30d71b0e6f Merge remote-tracking branch 'origin/pr-8857' into issue-8752-move-surface-between-panes 2026-07-24 03:37:11 -07:00
cmux reload-cloud d2068988ac Harden workspace recovery migration and bounds 2026-07-24 03:35:22 -07:00
lawrencecchen d512ed062c keep replacement id guard warning-clean 2026-07-24 03:34:47 -07:00
austinpower1258 d26d8f0912 test: scope renderer action regression suite 2026-07-24 03:31:44 -07:00
lawrencecchen 969ef61483 preserve reconnect state on stale replacement settlement 2026-07-24 03:31:18 -07:00
lawrencecchen cfd766b72d test stale replacement settlements preserve reconnect state 2026-07-24 03:30:41 -07:00
cmux reload-cloud 20dbdc9c04 Merge remote-tracking branch 'origin/main' into issue-8752-move-surface-between-panes
# Conflicts:
#	cmux.xcodeproj/project.pbxproj
2026-07-24 03:29:37 -07:00
austinpower1258 938bcd6bf6 Fix Dock session restore compilation 2026-07-24 03:27:50 -07:00
cmux reload-cloud dde0b6a211 Make pane move tests independent of insertion order 2026-07-24 03:26:56 -07:00
austinpower1258 24745dffca test: cover iOS renderer continuation action 2026-07-24 03:26:03 -07:00
cmux reload-cloud 6c4bbd9628 test: build foreground shortcut probe target 2026-07-24 03:25:06 -07:00
austinpower1258 b97c8bd540 Fix Dock resume approval switch 2026-07-24 03:21:43 -07:00
ejc3 94a329e24e control-socket: return the resume-approval switch expression
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.
2026-07-24 03:18:15 -07:00
austinpower1258 4c43359eef Fix merged Dock and web CI regressions 2026-07-24 03:15:26 -07:00
ejc3 3a49357a00 dock: spell out two closure return types the compiler cannot infer
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.
2026-07-24 03:11:09 -07:00
cmux reload-cloud 25a1ed8ba4 Keep sticky customization authoritative on restore 2026-07-24 03:10:16 -07:00
lawrencecchen 8468bcb5d3 preserve user actions during reconnect backoff 2026-07-24 03:08:06 -07:00
lawrencecchen cbca2a4f8f test queued actions survive reconnect failure 2026-07-24 03:07:33 -07:00
cmux reload-cloud 79164e7058 test: compile shortcut probe inside sandbox 2026-07-24 03:05:56 -07:00
austinpower1258 7d5386d6fa fix: return typed resume approval policies 2026-07-24 03:04:44 -07:00
austinpower1258 d055e82ddb Fix Dock lifecycle workspace import 2026-07-24 03:02:55 -07:00
ejc3 941b1eb6eb dock: import CmuxWorkspaces where the restored-lifecycle extension needs it
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.
2026-07-24 03:00:44 -07:00
austinpower1258 3764f5cc4a Merge remote-tracking branch 'origin/main' into issue-8826-design-mode-payload-tmpfile 2026-07-24 03:00:36 -07:00
austinpower1258 c0b494dc84 fix: disambiguate Dock snapshot types 2026-07-24 02:57:27 -07:00
cmux reload-cloud 4c93e17296 Respect workspace customization eligibility 2026-07-24 02:55:24 -07:00
cmux reload-cloud f63bab764a test: use foreground app for shortcut delivery 2026-07-24 02:53:39 -07:00
austinpower1258 68d5ac27d9 Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking
# Conflicts:
#	cmux.xcodeproj/project.pbxproj
#	web/tests/client-config-env.test.ts
2026-07-24 02:53:36 -07:00
lawrencecchen 51df684157 keep Linux workspace path warning-clean 2026-07-24 02:51:24 -07:00
austinpower1258 864bc2fefe fix: localize file preview edit actions 2026-07-24 02:48:55 -07:00
austinpower1258 bf879d108e fix: import workspace shell activity state 2026-07-24 02:46:12 -07:00
lawrencecchen 025663aac8 fix(remote): batch durable object relay frames 2026-07-24 02:46:03 -07:00
austinpower1258 adade4d373 Respect explicit Pi workspace routing 2026-07-24 02:43:12 -07:00
cmux reload-cloud 0b41d43468 Use split lifecycle for moved surface refresh 2026-07-24 02:43:10 -07:00
austinpower1258 25e6609e99 build: pin complete GhosttyKit archive checksum 2026-07-24 02:39:51 -07:00
cmux reload-cloud ee533cab3d test: give Finder a keyboard target 2026-07-24 02:37:37 -07:00
austinpower1258 f292f92da2 Merge remote-tracking branch 'origin/main' into issue-8808-typing-lag-regression
# Conflicts:
#	docs/ghostty-fork.md
2026-07-24 02:37:37 -07:00
austinpower1258 5c8947b060 Merge remote-tracking branch 'origin/main' into issue-8826-design-mode-payload-tmpfile 2026-07-24 02:36:34 -07:00
austinpower1258 4fb42c5104 Fix durable full-page design handoff 2026-07-24 02:36:14 -07:00
cmux reload-cloud 227b81dac4 Harden workspace recovery persistence invariants 2026-07-24 02:35:03 -07:00
lawrencecchen d7633fde1a assert unread dot beside shared active rail 2026-07-24 02:34:15 -07:00
lawrencecchen f97354d5b4 deduplicate personal scope rail label 2026-07-24 02:34:15 -07:00
lawrencecchen 2c3175986c test personal scope label is not duplicated 2026-07-24 02:34:14 -07:00
lawrencecchen 947c499cd6 keep active rail beside machine status 2026-07-24 02:34:14 -07:00
lawrencecchen 0a7e767cbc test active machine keeps shared sidebar rail 2026-07-24 02:34:14 -07:00
lawrencecchen 5f34838214 retry machine provider reconnects with backoff 2026-07-24 02:34:14 -07:00
lawrencecchen 2e61585fe9 test provider reconnect retry after outage 2026-07-24 02:34:14 -07:00
austinpower1258 315c24818b fix: complete Ghostty renderer drain scheduling 2026-07-24 02:34:12 -07:00
lawrencecchen 592f6552c7 test(remote): reproduce durable object message amplification 2026-07-24 02:32:16 -07:00
Austin Wang 4dc00aebb8 Persist Dock panes in session restore (#8690)
* test: cover Dock session persistence

* feat: persist Dock sessions

* test: distinguish Dock snapshot scopes
2026-07-24 02:23:40 -07:00
lawrencecchen b869f0b5bd fix(remote): bound unacknowledged delivery per lane 2026-07-24 02:16:57 -07:00
austinpower1258 3fa365ffac Merge remote-tracking branch 'origin/main' into issue-8652-file-preview-refresh 2026-07-24 02:15:38 -07:00
Austin Wangandcmux reload-cloud 5955bdeea0 Fit same-display restored windows to visible bounds (#8675)
* 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]>
2026-07-24 02:13:57 -07:00
austinpower1258 c9715ccc4b Merge remote-tracking branch 'origin/main' into issue-8837-resume-shell-zshrc 2026-07-24 02:13:56 -07:00
cmux reload-cloud b980cb6ae4 Batch sticky workspace color persistence 2026-07-24 02:11:51 -07:00
lawrencecchen 304b5ccb65 test(remote): reproduce unbounded tunnel delivery window 2026-07-24 02:11:47 -07:00
Austin Wang 39fb7579b0 Fix Codex code mode rollout identity conflicts (#8715)
* test: reproduce Codex code mode rollout identity

* fix: canonicalize Codex rollout parent identity

* fix: harden Codex rollout identity resolution

* fix: preserve single open Codex rollout identity

* fix: bound Codex identity scan work

* fix: index authoritative Codex hook bindings
2026-07-24 02:08:27 -07:00
0f6c0c1e9e Send founders welcome email for every completed checkout (#8846)
* 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]>
2026-07-24 02:05:34 -07:00
austinpower1258 76fb2c4732 fix: prevent Ghostty renderer mailbox starvation 2026-07-24 02:05:04 -07:00
Austin Wangandcmux reload-cloud 162da350c4 Prevent workspace switch renderer freezes (#8793)
* test: cover workspace switch freeze paths

* fix: prevent workspace switch renderer stalls

* fix: make watchdog captures explicit

* build: pin workspace freeze GhosttyKit

* test: import tagged app module in freeze regressions

* fix: address workspace freeze review findings

* fix: harden freeze recovery follow-ups

* fix: preserve render worker retry after replay failure

* fix: retry render worker launch replay as one intent

* test: verify render worker replay fault injection

* test: evaluate watchdog captures before assertions

* test: cover bounded render worker write backlog

* fix: recover stalled sidebar render workers

* test: refresh Ghostty surface config ABI lock

* fix: keep merged main warning-free

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-24 02:03:19 -07:00
cmux reload-cloudandKyler 5b9e97f751 Create missing panes when moving surfaces
Co-authored-by: Kyler <[email protected]>
2026-07-24 02:00:14 -07:00
cmux reload-cloudandKyler 8455d39f93 Test missing directional pane splits
Co-authored-by: Kyler <[email protected]>
2026-07-24 01:58:48 -07:00
lawrencecchen 41dde29de3 fix(remote): avoid admin half-close race 2026-07-24 01:53:03 -07:00
lawrencecchen f449d15164 test(remote): reproduce admin socket close race 2026-07-24 01:51:11 -07:00
cmux reload-cloud bc8f3bc296 Refine workspace recovery ownership and bounds 2026-07-24 01:29:53 -07:00
austinpower1258 a49762ccc8 Make auto-resume return shells explicitly interactive (#8837) 2026-07-24 01:16:11 -07:00
cmux reload-cloud 787b8a5ac5 Merge remote-tracking branch 'origin/main' into issue-8772-reopen-closed-workspace
# Conflicts:
#	Sources/TabManager.swift
2026-07-24 01:11:52 -07:00
Austin Wangandcmux reload-cloud 915e12ca78 Dock surface runtime parity (#8782)
* 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]>
2026-07-24 01:10:54 -07:00
Austin Wangandcmux reload-cloud caf70dfe38 Fix Retina scaling in design mode annotation crops (#8831)
* test(browser): reproduce Retina-scaled screenshot crops

* fix(browser): crop screenshots at native pixel scale

* test: update Ghostty surface config ABI lock

* fix: remove redundant diagnostic log await

* test(browser): require transparent annotation outlines

* fix(browser): keep drawn annotations over live page

* test(browser): verify cropped annotation pixels

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-24 01:10:19 -07:00
Abdulaziz AlbaharandClaude Fable 5 22e4173725 Kill corpse-redials and make Iroh client dials single-flight per peer (#8840)
* 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]>
2026-07-24 03:06:28 -05:00
cmux reload-cloud f5af960e01 Add closed workspace recovery and sticky identity 2026-07-24 00:48:05 -07:00
Abdulaziz Albaharandcmux reload-cloud 0efce0df20 Add workspace identity customization to the iOS sidebar (#8636)
* Add iOS workspace metadata customization

* Expose workspace customization to VoiceOver

* Expose workspace customization in the live iOS list

* Expose workspace save to VoiceOver

* Simplify iOS workspace color circles

* Use regular weight for workspace descriptions

* Add workspace customization regression tests

* Make workspace customization saves consistent

* Keep workspace customization baseline stable

* Use leading rail for iOS workspace colors

* Center iOS unread dot beside rail

* Batch iOS workspace customization refresh

* Harden iOS workspace metadata saves

* Address workspace metadata review findings

* Address mobile metadata review blockers

* Fix workspace action localization import

* Cache mobile description projections

* Harden mobile description conflict handling

* Center mobile unread dot in row height

* Harden mobile metadata refresh paths

* Address mobile metadata review findings

* Bound mobile description projection work

* Fix workspace description revision access

* Fix mobile workspace row actions

* Address mobile metadata review findings

* Tighten mobile metadata review cleanup

* Avoid stale workspace customization conflicts

* Keep workspace customization retries intact

* Keep retry baselines authoritative

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-24 02:38:43 -05:00
austinpower1258 35faa6b058 Add failing return-shell startup regression test (#8837) 2026-07-24 00:36:09 -07:00
austinpower1258 ebade6a03b Test atomic design handoff artifacts 2026-07-24 00:33:35 -07:00
austinpower1258 3a4fbd3157 Test offscreen design handoff captures 2026-07-24 00:27:44 -07:00
lawrencecchen 4411616ce0 fix(remote): decouple mux lanes under backpressure 2026-07-24 00:20:47 -07:00
lawrencecchen 22ede7a5e3 test(remote): reproduce mux backpressure coupling 2026-07-24 00:14:49 -07:00
austinpower1258 1eaee41cfd Merge remote-tracking branch 'origin/main' into issue-8826-design-mode-payload-tmpfile 2026-07-24 00:12:08 -07:00
austinpower1258 658ba3ba36 Require complete design handoff artifacts 2026-07-24 00:12:00 -07:00
austinpower1258 43692b32a4 Test complete browser design handoff artifacts 2026-07-24 00:07:01 -07:00
lawrencecchen 20aee413bf fix(remote): route render traffic over bulk lane 2026-07-24 00:04:11 -07:00
lawrencecchen 035364d661 test(remote): reproduce render traffic lane inversion 2026-07-24 00:02:51 -07:00
bed33ac812 Fix TextBox IME composition rendering (#8688)
* 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]>
2026-07-24 00:00:30 -07:00
Abdulaziz AlbaharandClaude Fable 5 ed7720c3b4 iOS: show per-build entries with build tags in computer pickers (#8816)
* 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]>
2026-07-24 01:59:50 -05:00
Abdulaziz AlbaharandClaude Fable 5 e513686a97 Name lane-failure and send-queue-overflow iroh session close reasons (#8834)
* 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]>
2026-07-24 01:51:47 -05:00
austinpower1258 c68b57c267 Fix design mode app test compilation 2026-07-23 23:48:27 -07:00
austinpower1258 ade5b308da Preserve readable design handoff artifacts 2026-07-23 23:47:07 -07:00
austinpower1258 db08cde4f7 Test stable browser design handoff paths 2026-07-23 23:44:19 -07:00
austinpower1258 d5c3a23396 Write browser design context to temporary JSON 2026-07-23 23:29:20 -07:00
Revanth Reddy Airre 60cb463c0b Preserve surface IDs in workstream events (#8703) 2026-07-23 23:25:10 -07:00
lawrencecchen 8768253b20 fix(remote): isolate partial physical link attempts 2026-07-23 23:18:27 -07:00
austinpower1258 75e3ebdd3e Test readable browser design mode handoff 2026-07-23 23:14:36 -07:00
lawrencecchen 79d9e3bef4 test(remote): reproduce stale partial link poisoning 2026-07-23 23:13:00 -07:00
Abdulaziz AlbaharandClaude Fable 5 86c56b7a68 Promote the Iroh relay-only override to a real cross-platform setting (#8824)
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]>
2026-07-24 01:03:48 -05:00
lawrencecchen 8164bbe2c8 fix(remote): backpressure replay window saturation 2026-07-23 22:54:04 -07:00
lawrencecchen 1c19277640 test(remote): reproduce replay pressure stream loss 2026-07-23 22:49:53 -07:00
Abdulaziz AlbaharandClaude Fable 5 b5fac3145f Make every rate-limit env var optional and fail open on deleted rules (#8818)
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]>
2026-07-24 00:33:05 -05:00
lawrencecchen 065e6d4e70 fix(remote): ignore closing relay sockets for capacity 2026-07-23 22:21:46 -07:00
lawrencecchen ece0a29cf7 test(remote): reproduce closing relay socket capacity 2026-07-23 22:19:37 -07:00
Lawrence Chen 745534f0dc Route Get Pro directly to Stripe checkout (#8813)
* test initial Pro checkout destination

* route Pro pricing CTA directly to checkout
2026-07-23 22:18:36 -07:00
Lawrence Chen 91222ccdd4 ci: allow required browser GitHub runner (#8815) 2026-07-23 22:15:45 -07:00
Lawrence Chen d3dda7b5a9 Test optional Iroh limiter configuration (#8761) 2026-07-23 22:12:42 -07:00
Abdulaziz AlbaharandClaude Fable 5 a4e555219a Deleted Macs are not hidden: clear legacy markers, retire legacy recovery (#8778)
* 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]>
2026-07-24 00:11:57 -05:00
Abdulaziz Albahar 73d4119a02 Use appearance-specific onboarding screenshots (#8803)
* Use appearance-specific onboarding screenshots

* Regenerate matched onboarding screenshots

* Require explicit onboarding screenshot appearance
2026-07-24 00:11:13 -05:00
Abdulaziz Albahar 4eb5295ad7 Limit CMUX INTERNAL builds to iOS changes (#8812)
* Limit CMUX INTERNAL builds to iOS changes

* Harden TestFlight path filter guard

* Validate TestFlight trigger keys exactly
2026-07-24 00:04:00 -05:00
Lawrence Chen fc0a930635 Prevent pricing CTA loading layout shifts (#8806)
* test pricing checkout loading layout

* fix pricing checkout loading layout

* test idle pricing checkout content

* test pricing checkout spinner presence
2026-07-23 21:52:17 -07:00
lawrencecchen 6aaa6e25d1 fix(remote): retry link-ready carrier loss 2026-07-23 21:46:03 -07:00
lawrencecchen 45f5789677 test(remote): reproduce link-ready carrier loss 2026-07-23 21:44:07 -07:00
Abdulaziz Albahar a0ba9299ed Launch workspace initial commands via the user's login shell (#8801)
* 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.
2026-07-23 23:40:21 -05:00
Austin Wang bd044e67a4 Fix blurred Google Sheets canvas rendering (#8697)
* test: cover remote webview reveal geometry

* fix: avoid reveal geometry nudge for remote webviews

* fix: keep reveal nudge policy synced

* fix: leave opener reveal policy unchanged for popups

* test: avoid live network in webview reveal regression

* fix: restore remote reveal nudge synchronously

* test: cover embedded webview user agent

* fix: preserve embedded WebKit user agent
2026-07-23 21:26:32 -07:00
Abdulaziz Albahar f5365dff29 Add iOS haptic feedback setting (#8797)
* Add iOS haptic feedback setting

* Keep iOS haptic preference live

* Make display settings own haptic state
2026-07-23 23:06:39 -05:00
Lawrence Chen 8f1a959695 Add superrepo blog post and author bylines (#8790)
* 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
2026-07-23 20:56:35 -07:00
lawrencecchen 167e0f9215 fix(remote): retry transient relay startup loss 2026-07-23 20:48:29 -07:00
lawrencecchen 2e5c9b3bd4 test(remote): reproduce relay startup carrier loss 2026-07-23 20:45:41 -07:00
lawrencecchen c167aea705 fix(remote): retry transient startup carrier loss 2026-07-23 19:59:59 -07:00
lawrencecchen fa9becff1d test(remote): reproduce transient startup carrier loss 2026-07-23 19:49:54 -07:00
Lawrence Chen ea51d55aa8 Land cmux TUI backend, durable workspaces, renderer processes, and Browser GPL slices (#8717)
Lands the cumulative, main-synchronized stack validated by Rust workspace tests, strict Clippy/formatting, TUI and attach smoke tests, TypeScript/web suites, Browser host protocol suites, GPL policy checks, and pinned Ghostty XCFramework validation.
2026-07-23 19:48:05 -07:00
Lawrence Chen ef52e63b50 Pin cumulative GhosttyKit release 2026-07-23 19:30:56 -07:00
Lawrence Chen 4e0c990482 Merge main into cumulative Browser and TUI integration 2026-07-23 19:22:52 -07:00
lawrencecchen 8af0adb710 fix(remote): survive public websocket carrier loss 2026-07-23 19:15:48 -07:00
lawrencecchen 74dd151186 test(remote): reproduce public websocket failures 2026-07-23 19:14:10 -07:00
Lawrence Chen c7c8b58578 Update Bonsplit for full tab bar drops (#8794) 2026-07-23 18:45:42 -07:00
lawrencecchen 6934c1b84c test(remote): make SSH cancellation carrier loss deterministic 2026-07-23 18:17:17 -07:00
lawrencecchen b870844399 fix(relay): flush queued frames before disconnect 2026-07-23 18:08:27 -07:00
lawrencecchen 01252f5ead test(relay): reproduce queued frame loss on peer close 2026-07-23 18:06:58 -07:00
lawrencecchen 36ffa781e0 fix(remote): preserve sessions across relay peer loss 2026-07-23 17:49:07 -07:00
lawrencecchen 0cbf897f3c test(remote): reproduce relay control reconnect failure 2026-07-23 17:46:40 -07:00
Lawrence Chen a73714c8b9 Keep TUI tab bars visible at minimum pane heights (#8765)
* test(tui): expose disappearing tab bars

* fix(tui): preserve tab bars at minimum pane height

* test(tui): cover constrained pane regressions

* fix(tui): preserve bars under infeasible pane minima
2026-07-23 17:30:44 -07:00
Lawrence Chen 9312ce64af ci(browser): enforce desktop GPL policy 2026-07-23 17:22:21 -07:00
Abdulaziz AlbaharandClaude Fable 5 2c38c069ad Name online-admission session closes and reject nearly expired leases at admission (#8775)
* 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]>
2026-07-23 18:52:24 -05:00
Abdulaziz AlbaharandClaude Fable 5 0c8a390203 Make CMUX_RELAY_TOKEN_RATE_LIMIT_ID optional in the env schema (#8771)
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]>
2026-07-23 18:51:06 -05:00
7f12724433 Sidebar: native NSTableView drop path for workspace reorder (#8605)
* 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]>
2026-07-23 18:35:27 -05:00
lawrencecchen 1436099fe6 fix(remote): stabilize PTY process lifecycle 2026-07-23 16:34:25 -07:00
cmux reload-cloud 3cc930d315 test: log background shortcut injection 2026-07-23 16:31:49 -07:00
cmux reload-cloud 0975a77973 test: post background shortcut through system event tap 2026-07-23 16:26:41 -07:00
Abdulaziz AlbaharandClaude Fable 5 e8786fff4b iOS: keep the Settings sheet open across the startup reconnect resolving (#8766)
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]>
2026-07-23 18:24:19 -05:00
lawrencecchen 87c2dd2bca fix(remote): cancel reconnect bootstrap on client shutdown 2026-07-23 16:23:24 -07:00
lawrencecchen e094871b95 test(remote): exercise unprepared no-install fallback 2026-07-23 16:22:05 -07:00
lawrencecchen 163cd84294 test(remote): reproduce reconnect shutdown stalls 2026-07-23 16:17:36 -07:00
lawrencecchen 2a7e5f6c4b test(remote): reproduce PTY lifecycle races 2026-07-23 16:13:40 -07:00
cmux reload-cloud 16cef5001e test: launch cmux without foreground activation 2026-07-23 16:12:48 -07:00
Abdulaziz Albahar 832c32f4e4 Show more notification rows in onboarding (#8774) 2026-07-23 18:12:40 -05:00
Abdulaziz AlbaharandClaude Fable 5 6723b864b6 Make relay rate limiter fail open on missing rule and skip when unconfigured (#8773)
* 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]>
2026-07-23 18:11:50 -05:00
austinpower1258 bc6a898ebd Align Pi privacy tests with route acknowledgments 2026-07-23 16:11:17 -07:00
Austin Wang 6e71dd226f Preserve workspace IDs across session restore (#8695)
* Add workspace identity restore regression test

* Preserve restored workspace identities

* Address restore review and deterministic test guard

* Fix restored workspace initializer panel ids

* Fix restored window context lookup

* Move restore identity helper to workspaces package

* Fix workspace creation test overrides

* Use value service for restore identity selection

* Fix agent resume liveness test fixture

* Fix liveness fixture enum inference

* Fix restore identity test fixtures

* Avoid ambiguous closed-history remaps

* Skip closed-history remaps for live workspace collisions

* Reserve global workspace IDs for closed restores

* Skip closed workspace history remaps for live IDs

* Include recoverable routes in closed restore IDs
2026-07-23 16:07:50 -07:00
cmux reload-cloud 17d86a42ed Merge remote-tracking branch 'origin/main' into issue-8752-move-surface-between-panes 2026-07-23 16:00:46 -07:00
cmux reload-cloud f17accd321 Fail closed without palette workspace window 2026-07-23 15:59:55 -07:00
cmux reload-cloud 452c0b906b Fix redundant await warning 2026-07-23 15:56:39 -07:00
cmux reload-cloud bca1833569 test: continue after headless activation failure 2026-07-23 15:55:51 -07:00
Abdulaziz Albahar d2d0ec43e4 Fix iOS task route picker colors (#8767)
* fix(ios): unify task route picker colors

* fix(ios): use semantic chevron color
2026-07-23 17:46:54 -05:00
lawrencecchen 8a99426baf fix(tui): publish final render frame before pty removal 2026-07-23 15:45:38 -07:00
austinpower1258 5cf24f326d Fix Feed ingress test closure signature 2026-07-23 15:44:21 -07:00
lawrencecchen 78a5b6ac45 test(tui): reproduce final render loss on pty exit 2026-07-23 15:43:12 -07:00
Abdulaziz AlbaharandClaude Fable 5 bd316e9eaa Keep the normal shell when every Mac is hidden (#8763)
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]>
2026-07-23 15:35:41 -07:00
cmux reload-cloud 15dd2f9d6b test: verify background global search delivery directly 2026-07-23 15:34:20 -07:00
cmux reload-cloud f99573ab97 Fix palette window routing visibility 2026-07-23 15:29:21 -07:00
austinpower1258 b1888aea16 test: isolate explicit Pi workspace routing 2026-07-23 15:29:02 -07:00
lawrencecchen a0d2aac5c7 Test optional Iroh limiter configuration 2026-07-23 15:20:05 -07:00
austinpower1258 30a3150d09 Restore Feed closure return context 2026-07-23 15:15:28 -07:00
cmux reload-cloud 254ebbd23e Address pane movement review findings 2026-07-23 15:15:14 -07:00
cmux reload-cloud 4a2aacaf4e test: force main-window mode for global search UI coverage 2026-07-23 15:07:32 -07:00
austinpower1258 e4bdfaf49a Fix Feed main-actor closure inference 2026-07-23 15:07:17 -07:00
cmux reload-cloud e53143d32b Add shortcuts to move surfaces between panes 2026-07-23 15:04:55 -07:00
austinpower1258 88787c0abb Merge origin/main into issue-8672-pi-extension-spawnsync-blocking 2026-07-23 15:00:47 -07:00
austinpower1258 459579cd89 Linearize Feed commits with synchronous deadlines 2026-07-23 14:53:41 -07:00
lawrencecchen eccb7f6876 fix(remote): preserve resume state after tunnel loss 2026-07-23 14:44:54 -07:00
lawrencecchen e56989d062 test(remote): reproduce relay tunnel resume loss 2026-07-23 14:41:11 -07:00
austinpower1258 e20452ff10 Run legacy Pi privacy regression in focused CI 2026-07-23 14:37:44 -07:00
Austin Wang 5dc99c6da5 Fix Vault sidebar beachball at large session counts (#8680)
* test: require viewport-bounded Vault rows

* fix: keep Vault rendering and scans off main thread

* fix: let the Vault table fill the sidebar viewport

* fix: close Vault CI and review gaps

* fix: preserve bounded Antigravity history paging

* fix: defer Vault table mutations past layout

* fix: close final Vault review feedback

* fix: preserve bounded Antigravity history access

* test: cover Vault paging review regressions

* fix: bound Vault history paging work

* fix: keep Antigravity history single-root

* test: cover Vault byte and viewport boundaries

* fix: preserve Vault boundaries during paging

* chore: leave legacy XCTest suite untouched

* docs: clarify Vault JSONL paging offsets
2026-07-23 14:35:02 -07:00
lawrencecchen 144a5ce3f0 test(remote): preserve session after tunnel carrier loss 2026-07-23 14:34:20 -07:00
austinpower1258 1f2da3ffdf Sanitize legacy Pi tool results at CLI boundary 2026-07-23 14:33:35 -07:00
Austin Wangandcmux reload-cloud 5bf7e36dac Fix restored resume workspace titles (#8687)
* test: cover restored resume workspace title

* Fix restored automatic workspace titles

* test: cover panel title chrome refresh

* Fix panel title chrome refresh

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-23 14:30:28 -07:00
austinpower1258 5c72d7c56d Make Feed deadline test deterministic 2026-07-23 14:21:04 -07:00
Abdulaziz AlbaharandClaude Fable 5 419910b32a Replace iOS computer deletion with per-phone hide (#8760)
* 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]>
2026-07-23 14:15:26 -07:00
austinpower1258 c0707c9be6 Return Feed delivery result 2026-07-23 14:15:16 -07:00
austinpower1258 b77d6f10a1 Bound Feed admission and decision deadlines 2026-07-23 14:05:15 -07:00
austinpower1258 9fcb8795c7 Fix optional Iroh limiter env test 2026-07-23 13:59:38 -07:00
austinpower1258 b788287cf6 Bound synchronous Feed delivery waits 2026-07-23 13:53:03 -07:00
austinpower1258 990da127f9 Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking 2026-07-23 13:43:20 -07:00
austinpower1258 aed373307f Harden Feed overflow and socket deadlines 2026-07-23 13:43:06 -07:00
lawrencecchen e6d527baac refactor(tui): centralize external machine connects 2026-07-23 13:26:44 -07:00
Abdulaziz Albahar 4253cc2884 iOS: preserve terminal input ordering under fast typing (#8682)
* 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
2026-07-23 15:18:54 -05:00
Abdulaziz Albahar 143091e3bf Refine iOS onboarding visuals and copy (#8756)
* Use real screenshots in iOS onboarding

* Restore original onboarding connection preview

* Restore original page three connection UI

* Load onboarding screenshots off the main actor
2026-07-23 15:14:21 -05:00
lawrencecchen cb1c7f852b fix(tui): diagnose machine reconnect failures safely 2026-07-23 13:08:43 -07:00
lawrencecchen 1b53edc937 fix(tui): make external pairing retries authoritative 2026-07-23 13:02:51 -07:00
Abdulaziz Albahar fc093e03a4 Upload cmux INTERNAL for every main push (#8694)
* Test TestFlight upload for every main push

* Upload cmux INTERNAL for every main push

* Harden TestFlight upload ordering

* Remove stale TestFlight schedule event
2026-07-23 13:32:14 -05:00
Abdulaziz AlbaharandClaude Fable 5 dd5883f1ec Name every iroh disconnect: classify client read errors + host close reasons (#8716)
* 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]>
2026-07-23 13:24:19 -05:00
Abdulaziz Albaharandcmux reload-cloud d1df5069ff Improve iOS New Task recovery and naming (#8554)
* test(ios): require contextual task failure banner

* fix(ios): contextualize task composer failures

* test(ios): require optional task workspace name

* feat(ios): add optional task workspace name

* test(ios): cover task composer refinements

* fix(ios): refine task composer defaults and recovery

* test(ios): enable task composer integration path

* test(ios): update terminal surface fixtures

* fix(ios): preserve active directory across templates

* refactor(ios): scope task failure title to composer

* fix(mac): correct actor initializer after base merge

* test(mac): check pagination by query key

* Fix task submission recovery state

* Debounce task recovery comparison

* Move workspace name into task context

* test(ios): require requested text field focus

* test(ios): cover deterministic task recovery

* Fix task recovery edit transitions

* Split task composer recovery views

* fix(ios): align task machine selection callback

* Split task recovery states

* Fix task composer policy imports

* Clarify task recovery helper ownership

* Clarify Japanese workspace failure messages

* Isolate task composer UI test host

* Address task composer review findings

* test(ios): cover task composer visual refinements

* fix(ios): simplify task composer chrome

* fix(ios): blend directory shortcuts into picker

* fix(ios): refine task composer leading spacing

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-23 13:21:51 -05:00
Abdulaziz AlbaharandClaude Opus 4.8 214383d2ad Remove iroh rate limiting: make limiter optional and fail open (#8714)
* 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]>
2026-07-23 12:57:36 -05:00
lawrencecchen 7a02cb5d83 Merge origin/main into remote daemon transport 2026-07-23 09:38:18 -07:00
lawrencecchen 137227a195 fix(tui): keep machine handoff control nonblocking 2026-07-23 09:29:34 -07:00
lawrencecchen 4e8604a0f5 fix(tui): harden external machine pairing 2026-07-23 09:10:45 -07:00
lawrencecchen b3ed81437a test(tui): isolate startup helper socket 2026-07-23 08:48:52 -07:00
lawrencecchen 5f4d368b29 test(tui): wait for complete provider fixtures 2026-07-23 08:44:20 -07:00
lawrencecchen 70a6b05ca6 feat(tui): add outbound machine pairing agent 2026-07-23 08:31:28 -07:00
austinpower1258 a88ba17aca test: preserve session-critical Feed overflow 2026-07-23 07:59:17 -07:00
austinpower1258 f8bc9c19d1 Fix Feed acknowledgment closure captures 2026-07-23 07:47:04 -07:00
austinpower1258 43a54e52dd Split Pi Feed ownership test helpers 2026-07-23 07:37:38 -07:00
lawrencecchen fc6939d064 feat(tui): route external machine pairing through providers 2026-07-23 07:35:55 -07:00
austinpower1258 8475d85e50 Align Feed reserve with session-critical events 2026-07-23 07:32:25 -07:00
austinpower1258 99bd622d13 test: align Feed session-critical reserve 2026-07-23 07:31:47 -07:00
austinpower1258 56b8201e11 Preserve Feed lifecycle chronology 2026-07-23 07:22:42 -07:00
austinpower1258 661a6f8f31 test: preserve Feed lifecycle chronology 2026-07-23 07:13:32 -07:00
austinpower1258 b55dd7b517 Preserve bounded Feed telemetry FIFO 2026-07-23 06:57:41 -07:00
austinpower1258 c6f1da3d5d test: preserve bounded Feed telemetry FIFO 2026-07-23 06:56:30 -07:00
austinpower1258 ca7b45709c Fix Feed delivery result inference 2026-07-23 06:46:23 -07:00
austinpower1258 32e4ab6346 Bound best-effort Feed delivery 2026-07-23 06:40:40 -07:00
austinpower1258 feb3d66a4d test: bound Feed telemetry and numeric results 2026-07-23 06:37:27 -07:00
austinpower1258 f63d4751dc Preserve ordered Pi Feed results 2026-07-23 06:13:48 -07:00
austinpower1258 b4d183df01 test: cover Pi Feed result and ingress ordering 2026-07-23 06:01:48 -07:00
austinpower1258 7b9d7b1457 Move Pi event publication off main actor 2026-07-23 05:45:08 -07:00
lawrencecchen 9128a509a7 fix(remote): integrate process snapshot protocol 2026-07-23 05:39:06 -07:00
austinpower1258 053487d108 test: cover Pi event delivery boundaries 2026-07-23 05:38:39 -07:00
lawrencecchen 42daac3c68 test(remote): cover scoped multi-client lifecycle 2026-07-23 05:31:26 -07:00
lawrencecchen 728bdc9666 feat(remote): add process catalog and terminal snapshots 2026-07-23 05:26:52 -07:00
lawrencecchen 552145cea1 fix(remote): clear failed remove recovery state 2026-07-23 05:26:01 -07:00
lawrencecchen cae4086c6d test(remote): reject phantom remove recovery paths 2026-07-23 05:25:46 -07:00
austinpower1258 333c79a723 Include active manager in workspace lookup 2026-07-23 05:25:14 -07:00
lawrencecchen 5d5f957adc fix(remote): close restored-mtime remove bypass 2026-07-23 05:22:21 -07:00
lawrencecchen 0869925356 test(remote): expose process discovery and terminal snapshot gaps 2026-07-23 05:20:44 -07:00
lawrencecchen 09e0e72dc6 test(remote): expose restored-mtime remove bypass 2026-07-23 05:20:10 -07:00
lawrencecchen b1bfbf2618 test(remote): measure interactive latency under bulk 2026-07-23 05:19:14 -07:00
lawrencecchen 3762c8259b fix(remote): close restored-mtime CAS bypass 2026-07-23 05:18:31 -07:00
lawrencecchen 15759ed779 test(remote): require complete reservation cleanup 2026-07-23 05:17:49 -07:00
lawrencecchen 48100e471e fix(remote): keep protocol UUIDs wasm-safe 2026-07-23 05:14:51 -07:00
austinpower1258 8971252498 Accept authoritative Pi relay targets 2026-07-23 05:14:26 -07:00
lawrencecchen 0632d9a6c7 fix(remote): stabilize request and stream errors 2026-07-23 05:12:45 -07:00
lawrencecchen 338e52df23 test(remote): expose restored-mtime CAS bypass 2026-07-23 05:12:34 -07:00
lawrencecchen 111038174f fix(remote): track partial patch mutation outcomes 2026-07-23 05:11:16 -07:00
austinpower1258 5f5b073be1 Sanitize Pi feed decode errors 2026-07-23 05:10:29 -07:00
lawrencecchen 2980315f01 test(remote): cover request identity and stream rejection 2026-07-23 05:08:09 -07:00
lawrencecchen b1b9640c9f fix(remote): harden process replay lifecycle 2026-07-23 05:01:41 -07:00
lawrencecchen 0a331fb9ed fix(remote): sanitize persisted runtime routes 2026-07-23 05:00:56 -07:00
lawrencecchen 1fcd8a645c test(remote): expose persisted route credentials 2026-07-23 05:00:27 -07:00
lawrencecchen 361d2598fa fix(remote): preserve non-route daemon names 2026-07-23 05:00:24 -07:00
lawrencecchen 279fc181bb test(remote): cover partial patch mutation outcomes 2026-07-23 04:59:32 -07:00
lawrencecchen 1a81031035 fix(remote): sanitize route-shaped daemon labels 2026-07-23 04:59:07 -07:00
lawrencecchen ca023f1b26 refactor(remote): normalize route query ownership 2026-07-23 04:58:35 -07:00
lawrencecchen 2ad739bc4b fix(remote): redact remaining route-shaped diagnostics 2026-07-23 04:57:38 -07:00
lawrencecchen 8be11efc3e fix(remote): validate staged guarded-write snapshot 2026-07-23 04:57:06 -07:00
lawrencecchen 545bfb6618 fix(remote): parse daemon stop arguments strictly 2026-07-23 04:55:52 -07:00
lawrencecchen 7cb076bddc test(remote): make route redaction assertions deterministic 2026-07-23 04:55:05 -07:00
lawrencecchen 1cc8f43fc4 test(remote): expose staged guarded-write mutation 2026-07-23 04:54:34 -07:00
lawrencecchen 542a5edfe8 fix(remote): enforce CLI daemon identity semantics 2026-07-23 04:54:19 -07:00
lawrencecchen 261ca9d845 fix(remote): redact route diagnostic state 2026-07-23 04:50:23 -07:00
lawrencecchen 5a5e7f0a19 fix(remote): fingerprint dirty build sources 2026-07-23 04:49:11 -07:00
lawrencecchen 34d8ab8e70 fix(remote): preserve mutation recovery state 2026-07-23 04:47:39 -07:00
lawrencecchen 6dbc729fd9 test(remote): expose route credential diagnostics 2026-07-23 04:45:23 -07:00
lawrencecchen 11a5779420 test(remote): expose CLI identity correctness gaps 2026-07-23 04:45:16 -07:00
lawrencecchen a73e0654de test(remote): distinguish dirty build identities 2026-07-23 04:44:35 -07:00
lawrencecchen 08b5013f09 test(remote): cover guarded mutation recovery failures 2026-07-23 04:40:50 -07:00
austinpower1258 a2cff23313 Validate workspace-only Pi feed targets 2026-07-23 04:36:39 -07:00
austinpower1258 3c04c69c07 test: validate workspace-only Pi feed ownership 2026-07-23 04:34:58 -07:00
lawrencecchen 2b78a791ed fix(remote): preserve secure terminal drain state 2026-07-23 04:34:10 -07:00
lawrencecchen 613a431784 fix(remote): offload oversized RPC errors 2026-07-23 04:31:51 -07:00
lawrencecchen 34d9a472e6 test(remote): expose secure terminal drain masking 2026-07-23 04:29:34 -07:00
lawrencecchen 276f77bec3 fix(remote): validate guarded write snapshots 2026-07-23 04:25:38 -07:00
lawrencecchen 0fd09c87cd test(remote): expose same-inode guarded write race 2026-07-23 04:25:13 -07:00
lawrencecchen 2b99af976a fix(remote): drain admitted control on terminal 2026-07-23 04:23:41 -07:00
lawrencecchen bbbfa4a5d8 chore(remote): gate platform mutation helpers 2026-07-23 04:23:39 -07:00
lawrencecchen 13afdfb79f fix(remote): retain raw stat during identity conversion 2026-07-23 04:20:32 -07:00
lawrencecchen e277ab5083 test(remote): expose process identity lifecycle gaps 2026-07-23 04:20:05 -07:00
lawrencecchen 8cdf8ebd20 fix(remote): harden guarded workspace mutations 2026-07-23 04:19:28 -07:00
austinpower1258 38cc07f8b1 Stabilize aggregate Pi feed dispatch test 2026-07-23 04:18:42 -07:00
lawrencecchen 5e4b0fc969 test(remote): preserve oversized RPC retries 2026-07-23 04:17:54 -07:00
lawrencecchen d887dc37b3 test(remote): expose terminal drain lifecycle gaps 2026-07-23 04:16:56 -07:00
lawrencecchen a811c07f77 fix(remote): bound RPC response encoding 2026-07-23 04:08:21 -07:00
lawrencecchen 136d8e385b test(remote): expose guarded mutation edge races 2026-07-23 04:06:37 -07:00
lawrencecchen e885842a56 test(remote): bound oversized RPC responses 2026-07-23 04:04:03 -07:00
lawrencecchen affc58e00b fix(remote): bound SSH bootstrap output 2026-07-23 04:03:36 -07:00
lawrencecchen 16245c3633 fix(remote): remove unused SSH ingress constructor 2026-07-23 04:02:26 -07:00
lawrencecchen 7d4025927c fix(remote): exhaustively classify client auth 2026-07-23 04:02:23 -07:00
austinpower1258 2fa937ce49 Reuse existing Pi hook warning 2026-07-23 04:02:00 -07:00
lawrencecchen f78563e84c fix(remote): bound relay websocket ingress 2026-07-23 04:01:58 -07:00
lawrencecchen 394bb9d214 fix(tui): skip unsupported fallback routes 2026-07-23 04:01:39 -07:00
lawrencecchen ff5f616887 test(tui): preserve supported route fallback 2026-07-23 04:01:00 -07:00
lawrencecchen 28fc744379 fix(remote): pin verified workspace root identity 2026-07-23 04:00:02 -07:00
lawrencecchen 3e2e6d956a fix(remote): ignore rename timestamp updates in CAS 2026-07-23 03:59:13 -07:00
lawrencecchen e346cb86a0 fix(remote): bound websocket reassembly 2026-07-23 03:57:59 -07:00
lawrencecchen 8be1bbe982 fix(remote): make process output loss explicit 2026-07-23 03:57:53 -07:00
lawrencecchen cfbf88283c fix(tui): use enrolled auth for reconnect routes 2026-07-23 03:57:08 -07:00
lawrencecchen b9a313ca5e test(tui): label invitation reconnect as enrolled 2026-07-23 03:56:35 -07:00
lawrencecchen 4095cc37ea fix(remote): classify raced parents as conflicts 2026-07-23 03:55:09 -07:00
lawrencecchen a08f11fbc0 fix(remote): make workspace mutations race resistant 2026-07-23 03:54:22 -07:00
lawrencecchen 7b1c011bc5 refactor(remote): carry typed ingress through authorization 2026-07-23 03:53:51 -07:00
lawrencecchen acfb3ba622 test(remote): bound SSH bootstrap output 2026-07-23 03:51:36 -07:00
lawrencecchen bb86f925d8 test(remote): bound fragmented websocket messages 2026-07-23 03:51:30 -07:00
lawrencecchen bbd940af7f fix(tui): resolve client transports through registry 2026-07-23 03:51:03 -07:00
austinpower1258 300b97c61c Isolate unavailable Pi session targets 2026-07-23 03:49:20 -07:00
lawrencecchen 1689f628e6 fix(remote): type inbound carrier authentication 2026-07-23 03:49:11 -07:00
lawrencecchen bf8ffdaf39 test(remote): handle provider rejection without Debug 2026-07-23 03:47:58 -07:00
austinpower1258 443adef9d2 test: isolate stale Pi session targets 2026-07-23 03:45:19 -07:00
lawrencecchen c4605b33b4 fix(remote): type client transport auth capabilities 2026-07-23 03:44:13 -07:00
lawrencecchen 13255e0352 test(remote): expose silent process output loss 2026-07-23 03:43:44 -07:00
lawrencecchen 63f967baa7 fix(remote): make lane mux failures terminal 2026-07-23 03:43:00 -07:00
lawrencecchen b8a6b88757 test(remote): expose workspace mutation path races 2026-07-23 03:40:30 -07:00
lawrencecchen 2e143f3fe9 test(remote): reject carrier auth on network routes 2026-07-23 03:40:21 -07:00
lawrencecchen d4127ed406 test(remote): require typed inbound carrier evidence 2026-07-23 03:39:44 -07:00
austinpower1258 2970a8539a Scope Pi feed failures to session lifecycle 2026-07-23 03:34:20 -07:00
lawrencecchen d92a8c0786 fix(remote): fold reconnect source selection 2026-07-23 03:33:07 -07:00
lawrencecchen e3f9014de7 fix(tui): authenticate stdio proxy responder 2026-07-23 03:33:02 -07:00
lawrencecchen 830b3d5928 fix(tui): redact route failure endpoints 2026-07-23 03:32:57 -07:00
lawrencecchen 3048ee2d22 test(tui): require peer auth before stdio proxying 2026-07-23 03:31:22 -07:00
lawrencecchen 4c57e49752 test(tui): verify packaged SSH build identity 2026-07-23 03:31:11 -07:00
lawrencecchen 81f9dd7319 test(tui): expose endpoint secrets in route failures 2026-07-23 03:30:58 -07:00
lawrencecchen 5c7fbe77a7 test(remote): cover PTY continuity across reconnect 2026-07-23 03:30:00 -07:00
lawrencecchen caefb767b3 fix(remote): redact endpoint diagnostics 2026-07-23 03:28:59 -07:00
lawrencecchen 8b6aa4f485 test(remote): cover TCP forwarding end to end 2026-07-23 03:28:48 -07:00
lawrencecchen 353c9c95fc fix(tui): reserve tunnel receive capacity 2026-07-23 03:26:53 -07:00
lawrencecchen 458336e7e9 fix(tui): report SSH build identity 2026-07-23 03:26:19 -07:00
lawrencecchen 20ada3e9e6 fix(remote): bind raw SSH bootstrap to source revision 2026-07-23 03:25:58 -07:00
lawrencecchen 21f98e8e92 test(remote): reserve tunnel receive capacity 2026-07-23 03:25:40 -07:00
lawrencecchen 8cf1125101 test(remote): cover WSS certificate verification 2026-07-23 03:24:38 -07:00
lawrencecchen 3a712f7d72 test(remote): preserve admitted frames before lane EOF 2026-07-23 03:23:45 -07:00
lawrencecchen e6c9cd5989 fix(tui): bootstrap SSH fallback routes 2026-07-23 03:23:30 -07:00
austinpower1258 5fed0105a4 Fail closed for saturated Pi feed dispatch 2026-07-23 03:22:14 -07:00
lawrencecchen ffeb8ac02e test(remote): expose endpoint secret diagnostics 2026-07-23 03:21:44 -07:00
lawrencecchen abec330a8a fix(remote): route process events over bulk lane 2026-07-23 03:21:08 -07:00
lawrencecchen 066c6e0ff7 test(tui): expose Bulk starving Tunnel budget 2026-07-23 03:21:02 -07:00
austinpower1258 74eac86089 test: fail closed on Pi feed overload 2026-07-23 03:20:38 -07:00
lawrencecchen 295cf736c7 test(remote): expose lane mux terminal lifecycle gaps 2026-07-23 03:19:51 -07:00
lawrencecchen c152179d95 fix(relay): bound signed ticket lifetime 2026-07-23 03:15:55 -07:00
lawrencecchen 09ad1fa46f test(remote): expose same-version SSH bootstrap reuse 2026-07-23 03:14:16 -07:00
austinpower1258 1be896b8ab Bound authoritative Pi Feed routing 2026-07-23 03:13:27 -07:00
lawrencecchen 6b24921341 fix(remote): derive structured diff paths from Git metadata 2026-07-23 03:04:44 -07:00
lawrencecchen 75a49d1173 test(tui): expose SSH bootstrap route regressions 2026-07-23 03:04:00 -07:00
austinpower1258 4b4a56bd26 test: surface authoritative Pi feed targets 2026-07-23 03:03:14 -07:00
lawrencecchen 360bdad324 fix(remote): prioritize shared physical writers 2026-07-23 02:58:29 -07:00
lawrencecchen 8612aca4f2 test(remote): require bulk process event lane 2026-07-23 02:56:53 -07:00
lawrencecchen dc537e85bf test(relay): expose replayable ticket lifetimes 2026-07-23 02:56:45 -07:00
lawrencecchen 7eb2915e11 fix(remote): isolate process control requests 2026-07-23 02:56:22 -07:00
lawrencecchen cdc1c03479 test(remote): expose quoted Git diff paths 2026-07-23 02:55:55 -07:00
lawrencecchen bb9ac6ce10 fix(tui): reserve remote receive budget by priority 2026-07-23 02:54:29 -07:00
lawrencecchen e305fb5c10 test(remote): expose blocked process control lane 2026-07-23 02:54:02 -07:00
lawrencecchen e37d632ea9 test(remote): expose shared writer priority inversion 2026-07-23 02:52:18 -07:00
lawrencecchen 7089c27f62 test(remote): expose blocked process control lane 2026-07-23 02:52:18 -07:00
lawrencecchen b4afe063cb fix(tui): surface fatal SSH bootstrap cause 2026-07-23 02:52:18 -07:00
austinpower1258 3ec02eca70 test: bound and rehome Pi feed dispatch 2026-07-23 02:52:12 -07:00
lawrencecchen 3f0f453cdd fix(remote): isolate lane mux ingress queues 2026-07-23 02:45:57 -07:00
austinpower1258 b393bec6e6 Make Pi Feed acceptance authoritative 2026-07-23 02:33:41 -07:00
austinpower1258 2c7b7448ce test: avoid redundant Pi feed target preflight 2026-07-23 02:28:02 -07:00
austinpower1258 30fa465ac9 Bound Pi batch work on the main actor 2026-07-23 02:24:44 -07:00
austinpower1258 e059f1aaaf test: coalesce Pi batch transcript updates 2026-07-23 02:23:43 -07:00
austinpower1258 0e43965881 Reject unavailable Feed targets before acknowledgment 2026-07-23 02:23:07 -07:00
austinpower1258 fc9a251d3c test: reject stale feed without event request id 2026-07-23 02:18:02 -07:00
austinpower1258 9e134e5673 Bound Pi feed snapshots before dispatch 2026-07-23 01:37:18 -07:00
austinpower1258 c5460aee8a test: preserve unserializable Pi feed events 2026-07-23 01:30:12 -07:00
austinpower1258 d30fc76306 Preserve Pi stop after feed failure 2026-07-23 01:29:40 -07:00
austinpower1258 42a17bccbc test: preserve Pi stop after feed failure 2026-07-23 01:28:12 -07:00
austinpower1258 4ce13000ad Validate inherited Pi feed targets 2026-07-23 01:20:55 -07:00
austinpower1258 132bef8cdd test: reject stale ambient Pi feed targets 2026-07-23 01:20:46 -07:00
austinpower1258 4765ad00e9 Localize Pi feed validation errors 2026-07-23 00:50:22 -07:00
austinpower1258 f204612f27 Make Pi feed acceptance authoritative 2026-07-23 00:38:56 -07:00
austinpower1258 4a2b182889 test: cover authoritative Pi feed completion 2026-07-23 00:21:38 -07:00
austinpower1258 13083de8af Index Pi feed queues by session 2026-07-23 00:14:47 -07:00
austinpower1258 b8776c0ad7 Fail closed on explicit Pi workspaces 2026-07-23 00:12:59 -07:00
austinpower1258 2275654a9d test: reject missing explicit Pi workspace 2026-07-23 00:08:21 -07:00
austinpower1258 9798bd30c0 Make Pi feed delivery authoritative 2026-07-23 00:00:54 -07:00
austinpower1258 04316e4834 Address Pi hook routing review feedback 2026-07-22 23:31:15 -07:00
austinpower1258 3524d8e091 Strengthen Pi feed acknowledgment regression 2026-07-22 23:27:20 -07:00
austinpower1258 9cc22f147c Preserve live Pi surface ownership through Feed 2026-07-22 23:27:05 -07:00
austinpower1258 ac5843bfee test: preserve Pi surface ownership end to end 2026-07-22 23:17:54 -07:00
austinpower1258 1cc2c4d9ec Require authoritative bounded relay responses 2026-07-22 22:44:33 -07:00
austinpower1258 1cb01d993a test: require authoritative bounded relay responses 2026-07-22 22:43:05 -07:00
austinpower1258 d136cc0c61 Route acknowledged Feed insertion through main actor 2026-07-22 22:25:58 -07:00
austinpower1258 b8b11f83bf Exercise relay authentication within batch deadline 2026-07-22 22:21:32 -07:00
austinpower1258 3f3660536c test: exercise relay authentication deadline 2026-07-22 22:20:02 -07:00
austinpower1258 ee4e59c4ad Preserve bounded Pi routing ownership 2026-07-22 22:15:19 -07:00
austinpower1258 f1460af9c2 test: preserve bounded Pi routing ownership 2026-07-22 22:09:18 -07:00
austinpower1258 7ac98fe686 Await authoritative bounded Pi feed ingestion 2026-07-22 21:55:27 -07:00
austinpower1258 8360edda08 test: require authoritative bounded Pi feed acknowledgment 2026-07-22 21:53:12 -07:00
austinpower1258 1fafe87572 Bound relay Pi feed round trips 2026-07-22 21:42:33 -07:00
austinpower1258 7f85061087 test: bound relay Pi feed round trips 2026-07-22 21:41:21 -07:00
austinpower1258 b67bbd87f1 Document failed Pi feed settlement 2026-07-22 21:38:56 -07:00
austinpower1258 b071382612 Preserve queued Pi completions on timeout 2026-07-22 21:33:45 -07:00
austinpower1258 072d0d5961 test: preserve queued Pi completions on timeout 2026-07-22 21:32:37 -07:00
austinpower1258 5a7405b655 Preserve live Pi feed routing 2026-07-22 21:22:27 -07:00
austinpower1258 7c8844d61d test: preserve live Pi feed routing 2026-07-22 21:20:15 -07:00
austinpower1258 9b80298bf6 Bound relay Pi feed batch deadline 2026-07-22 21:17:46 -07:00
austinpower1258 479e048af4 test: bound relay Pi feed batch deadline 2026-07-22 21:17:08 -07:00
austinpower1258 e2978b4a25 Use at-most-once Pi terminal feed delivery 2026-07-22 21:05:15 -07:00
austinpower1258 3706af3d73 test: reject ambiguous Pi feed retries 2026-07-22 21:03:53 -07:00
austinpower1258 7db876eec7 Preserve failed Pi terminal feed delivery 2026-07-22 20:54:11 -07:00
austinpower1258 2e4b4027b1 test: preserve failed Pi terminal feed delivery 2026-07-22 20:53:00 -07:00
cmux reload-cloud 92ae691331 test: launch global search coverage in UI test mode 2026-07-22 20:48:18 -07:00
austinpower1258 ac0f2994c6 Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking 2026-07-22 20:45:10 -07:00
austinpower1258 eab60a3ea7 Pipeline authoritative Pi feed batches 2026-07-22 20:44:36 -07:00
austinpower1258 29e179c886 test: require authoritative pipelined Pi feed batches 2026-07-22 20:36:44 -07:00
Abdulaziz Albahar 7652d3b1cf Fix iOS deleted computer recovery empty state (#8712)
* Fix deleted computer recovery empty state

* Centralize deleted computer recovery state

* Tighten deleted computer recovery outcomes

* Fail closed on unknown deleted-computer recovery state

* Close team-switch recovery race

* Keep recovery button busy through reload
2026-07-22 22:27:32 -05:00
austinpower1258 1c1bc2fb87 Harden Pi hook trust boundaries 2026-07-22 20:22:45 -07:00
austinpower1258 7fc2655c6a test: harden Pi hook trust boundaries 2026-07-22 20:21:05 -07:00
austinpower1258 4fddfc1e48 Require strict Pi hook targets 2026-07-22 20:10:32 -07:00
austinpower1258 d919eb643b test: require strict Pi hook targets 2026-07-22 20:09:34 -07:00
austinpower1258 652006a5cc Await Pi feed ingestion 2026-07-22 19:55:55 -07:00
austinpower1258 3fe5c855e8 test: await Pi feed ingestion 2026-07-22 19:53:27 -07:00
austinpower1258 fa829c562b fix: ignore read-induced preview metadata 2026-07-22 19:45:39 -07:00
austinpower1258 912254e854 test: ignore preview metadata-only changes 2026-07-22 19:44:55 -07:00
Lawrence Chen 34bb725b1d test(browser): cover terminal host protocol slice 2026-07-22 19:42:59 -07:00
Lawrence Chen 95c3e6d956 feat(browser): import terminal host protocol core
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.
2026-07-22 19:42:45 -07:00
Lawrence Chen bf91480ac1 ci(browser): run public protocol host tests 2026-07-22 19:36:08 -07:00
austinpower1258 904a6dcfb8 Isolate Pi feed session execution 2026-07-22 19:34:39 -07:00
austinpower1258 f335816e2e test: isolate Pi feed session deadlines 2026-07-22 19:33:15 -07:00
austinpower1258 4d87d64ddc fix: serialize canceled preview loads 2026-07-22 19:29:57 -07:00
austinpower1258 ca16dc148b test: cover bounded refresh cancellation 2026-07-22 19:29:48 -07:00
lawrencecchen 4c6891ac2a fix(tui): keep remote route attempts isolated 2026-07-22 19:24:43 -07:00
austinpower1258 5905853a33 Bound Pi feed overload routing 2026-07-22 19:23:35 -07:00
bencollins2andClaude Fable 5 38b8ca7963 Support respawn-pane in Go relay __tmux-compat for SSH sessions (#8660)
* 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]>
2026-07-22 19:23:01 -07:00
lawrencecchen 995605530b test(tui): cover isolated remote route attempts 2026-07-22 19:19:57 -07:00
austinpower1258 5912f80b07 Merge remote-tracking branch 'origin/main' into issue-8652-file-preview-refresh 2026-07-22 19:19:17 -07:00
lawrencecchen bceafd389f fix(remote): authenticate Unix socket responders 2026-07-22 19:18:28 -07:00
austinpower1258 429a823c4a fix: bound automatic preview refreshes 2026-07-22 19:18:24 -07:00
lawrencecchen 41dd6cb523 test(remote): expose lane mux ingress blocking 2026-07-22 19:15:24 -07:00
lawrencecchen fb3ad110e5 test(remote): reject wrong-uid Unix responders 2026-07-22 19:15:06 -07:00
Lawrence Chen 4dca8287c9 Add pluggable machine sidebar and provider transport (#8622)
* Define machine provider protocol v1

* Add authenticated machine provider client

* Add pluggable machine rail transports

* Avoid phantom provider workspaces

* Harden cloud provider launch contract

* Add stable keys to atomic workspace run

* Make provider transport tests portable

* Stabilize browser tab event assertion

* Protect provider credentials after exec

* Harden machine connector configuration

* Share bounded process diagnostics

* Harden machine provider diagnostics

* Format keyed workspace insertion

* Add provider-managed workspace lifecycle

* Add managed machine lifecycle controls

* Test lifecycle fallback with legacy providers

* Negotiate managed lifecycle capabilities

* Test managed workspace mutation isolation

* Guard provider-managed workspace mutations

* Test provider workspace authority isolation

* Authorize provider workspace mirror commits

* test: cover TUI grapheme text editing

* fix: make TUI text inputs grapheme-aware

* feat: manage provider authority without mux restarts

* test: require provider connect negotiation

* Gate provider machine connect on negotiation

* Add client-local Cloud machine overlay

* Document local Cloud machine composition

* fix: distinguish provider protocol from transport faults

* test: cover future provider protocol mismatch

* Fix PTY test winsize mutability lint

* Preserve machine mode when reusing sessions

* Scrub provider secrets before runtime dispatch

* test: expose tombstone switch session leak

* fix: harden machine session boundaries

* test: expose startup provider secret leak

* fix: scrub provider secrets before config load

* test: expose blocking machine actions

* fix: move machine actions off the event loop

* test: expose provider cleanup and locale gaps

* fix: bound provider cleanup and localize failures

* test: expose detached provider diagnostics hang

* fix: cancel detached provider diagnostics

* test: expose provider read-ahead secret copies

* fix: eliminate provider request read-ahead

* test: keep provider framing regression lint-clean

* test: require complete provider management frames

* fix: require complete management frames

* test: expose provider replacement and mirror gaps

* fix: commit provider replacements and surface mirror gaps

* test: cover provider replacement state ordering

* fix: preserve provider replacement state

* test: cover provider connection and scope races

* fix: preserve provider session identity

* test: cover provider control event delivery

* fix: preserve provider control events

* test: cover provider guard and catalog locale

* fix: require confirmed provider workspace guard

* test(tui): cover localized machine color failures

* fix(tui): localize machine color failures

* test(tui): cover provider SSH and event bounds

* fix(tui): harden provider transport boundaries

* test(tui): cover inherited SSH security options

* fix(tui): enforce strict SSH forwarding policy

* test(tui): cover provider lifecycle review gaps

* fix(tui): close provider lifecycle gaps

* test(tui): cover every accepted provider mutation

* fix(tui): preserve accepted provider effects

* test(tui): cover provider selection reconciliation

* fix(tui): reconcile provider selection state

* test(tui): preserve reconnect provider notices

* fix(tui): retain notices on reconnect failure

* test(tui): cover replacement notice masking

* fix(tui): preserve replacement mirror errors

* test(tui): cover stream ticket diagnostic leak

* fix(tui): redact stream tickets in diagnostics

* fix(tui): gate Unix diagnostics reader

* test(tui): cover inherited ssh diagnostics handle

* fix(tui): bound static ssh cleanup
2026-07-22 19:14:35 -07:00
austinpower1258 5901413b79 test: bound Pi feed overload routing 2026-07-22 19:13:47 -07:00
Lawrence Chen 475c353aed Fix sendable shell executable check (#8707) 2026-07-22 19:13:31 -07:00
EJandejc3 9f0613e23a remote-daemon: tear a PTY session down once (#8643)
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]>
2026-07-22 19:12:45 -07:00
EJandejc3 d81aaff477 cmuxTests: pass the liveness the Entry initializer requires (#8651)
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]>
2026-07-22 19:11:53 -07:00
lawrencecchen 341fcb783f test(tui): expose missing transport priority reserves 2026-07-22 19:11:22 -07:00
Lawrence Chen 8a66e56d76 Fix CmuxTerminal hibernation test hang (#8674)
* test: bound terminal teardown event waits

* fix: register hibernation teardown test surface

* fix: cancel teardown wait deadlines

* test: signal teardown waiter registration
2026-07-22 19:11:09 -07:00
Lawrence Chen a6ce92b40b Make sidebar scheduler test deterministic (#8676)
* test: inject clock into sidebar release scheduler

* test: synchronize sidebar manual clock

* test: expose completed sleeper cancellation markers

* test: bound manual clock cancellation markers

* test: cover pending sleeper idle state

* test: include pending sleepers in idle state

* test: gate pending cancellation after handler install

* test: signal sidebar idle waiter registration

* test: cover overdue sleeper registration waiters

* test: release overdue sleeper waiters
2026-07-22 19:10:21 -07:00
Lawrence Chen fd978bfa92 test(browser): add public protocol host gate 2026-07-22 19:07:14 -07:00
Lawrence Chen e5c9a93aa4 feat(browser): import cmux TUI protocol core
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.
2026-07-22 19:07:14 -07:00
lawrencecchen 519ae8d891 fix(tui): bound remote stream backlogs by bytes 2026-07-22 19:06:02 -07:00
lawrencecchen 035a67b67a test(tui): expose remote stream mailbox mismatch 2026-07-22 19:01:55 -07:00
Lawrence Chen e897f771e1 docs(browser): bound commercial license scope 2026-07-22 19:01:35 -07:00
austinpower1258 39930e62db Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking 2026-07-22 18:57:34 -07:00
austinpower1258 8074bf606d test: cover dirty preview mode transitions 2026-07-22 18:57:19 -07:00
austinpower1258 f006fcc773 test: pass preview revision to Quick Look sessions 2026-07-22 18:57:15 -07:00
austinpower1258 76560e045c Preserve Pi feed failure status 2026-07-22 18:56:56 -07:00
austinpower1258 6c95ebb972 test: preserve Pi feed failure status 2026-07-22 18:53:54 -07:00
cmux reload-cloud 315daae184 test: cover foreground-scoped global search shortcut 2026-07-22 18:50:43 -07:00
lawrencecchen ee186a8dba test(tui): stress concurrent relay resumes 2026-07-22 18:49:21 -07:00
lawrencecchen 26e74c0f10 fix(tui): recover relay control after cancellation 2026-07-22 18:48:38 -07:00
austinpower1258 87dda3db5c Redact compacted Pi terminal results 2026-07-22 18:45:01 -07:00
lawrencecchen 1ff6d5c573 test(tui): cover cancelled relay reconnects 2026-07-22 18:44:15 -07:00
austinpower1258 ca824641c5 test: keep compacted Pi feed metadata only 2026-07-22 18:42:42 -07:00
austinpower1258 b9098c82ec Merge remote-tracking branch 'origin/main' into issue-8652-file-preview-refresh 2026-07-22 18:42:27 -07:00
austinpower1258 80ef478bef fix: preserve file preview state on refresh 2026-07-22 18:42:20 -07:00
austinpower1258 45c1c3c1ce Deliver compacted Pi terminal feed events 2026-07-22 18:37:00 -07:00
austinpower1258 bf5d542a5f test: cover compacted Pi feed delivery 2026-07-22 18:32:55 -07:00
austinpower1258 a4a2d29986 fix: wire preview sources as file references 2026-07-22 18:31:11 -07:00
austinpower1258 48488da6f5 fix: quote file preview reload project path 2026-07-22 18:27:01 -07:00
austinpower1258 bd8028fabb Bound Pi terminal feed completion 2026-07-22 18:19:45 -07:00
Lawrence Chen d8536accd3 docs(browser): define rights-controlled license boundary 2026-07-22 18:19:27 -07:00
Lawrence Chen 3640831564 docs(browser): gate bundled resource provenance 2026-07-22 18:19:27 -07:00
Lawrence Chen d175142c7e docs(browser): gate future relicensing on ownership 2026-07-22 18:19:27 -07:00
Lawrence Chen 2f238e12df docs(browser): record Bonsplit provenance 2026-07-22 18:19:27 -07:00
Lawrence Chen 10a9935874 docs(browser): establish public import gates 2026-07-22 18:19:27 -07:00
austinpower1258 8da769d3cc test: preserve native preview state across refresh 2026-07-22 18:18:23 -07:00
ccfcaf8b61 Fix Dock terminal working-directory inheritance (#8691)
* 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]>
2026-07-22 18:16:11 -07:00
austinpower1258 a63d4cdea2 test: cover bounded Pi feed completion 2026-07-22 18:14:07 -07:00
lawrencecchen 1d3a1c29d2 fix(tui): harden remote transport publication and diagnostics 2026-07-22 18:02:28 -07:00
austinpower1258 5eb252cce8 refactor: isolate file preview refresh state 2026-07-22 17:58:10 -07:00
austinpower1258 68a4ff671b test: split Pi feed lifecycle checks 2026-07-22 17:57:16 -07:00
Austin Wangandcmux reload-cloud d19f59aa29 Fix remote PTY PATH inherited from cmuxd (#8677)
* Add failing remote PTY PATH regression test

* Ensure remote PTYs inherit a system PATH

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-22 17:53:09 -07:00
Austin Wangandcmux reload-cloud cf3d0fdd04 Resolve executable login shell before terminal spawn (#8681)
* Add failing shell resolution regression tests

* Resolve executable user shell before terminal spawn

* Add launch command and remote exit regression tests

* Preserve shell command and remote exit lifecycle

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-22 17:52:20 -07:00
lawrencecchen 42df658797 test(tui): run rollback regression in release mode 2026-07-22 17:49:32 -07:00
austinpower1258 389f66e934 fix: await authoritative preview reloads 2026-07-22 17:36:04 -07:00
austinpower1258 ff64934882 Serialize terminal Pi feed draining 2026-07-22 17:35:39 -07:00
austinpower1258 1ee843bad1 test: cover preview mode transition completion 2026-07-22 17:35:32 -07:00
austinpower1258 994a898779 test: cover serialized Pi terminal feed ordering 2026-07-22 17:34:04 -07:00
austinpower1258 342df5c45f Keep Pi feed serialization best effort 2026-07-22 17:25:24 -07:00
austinpower1258 4adfbd2978 test: cover best-effort Pi feed serialization 2026-07-22 17:24:24 -07:00
austinpower1258 2e0bdc5144 fix: bound preview refresh ingress 2026-07-22 17:22:56 -07:00
austinpower1258 d611ed5df5 Scope Pi dispatch state to each runtime 2026-07-22 17:15:43 -07:00
austinpower1258 5f80154d5a test: cover Pi extension runtime isolation 2026-07-22 17:15:43 -07:00
austinpower1258 af78c8a903 test: cover preview reload ingress races 2026-07-22 17:14:29 -07:00
austinpower1258 ffd6294fdc Merge remote-tracking branch 'origin/main' into issue-8672-pi-extension-spawnsync-blocking 2026-07-22 17:07:09 -07:00
lawrencecchen 1659380e60 test(tui): cover production remote transport failures 2026-07-22 17:05:30 -07:00
austinpower1258 20f2dea611 Fix blocking Pi hook dispatch 2026-07-22 17:04:39 -07:00
austinpower1258 ae1a9a6ba0 fix: bound file preview reload work 2026-07-22 17:01:40 -07:00
cmux reload-cloud a41fcd3b51 test: stay within hotkey policy file budget 2026-07-22 17:01:36 -07:00
cmux reload-cloud c21223c572 test: avoid production shortcut policy seam 2026-07-22 17:00:12 -07:00
austinpower1258 3ac3546b3d test: cover final file preview reload invariants 2026-07-22 16:56:41 -07:00
cmux reload-cloud ebe79a9cb0 fix: scope global search shortcut to foreground 2026-07-22 16:47:51 -07:00
cmux reload-cloud 0a8c4cbf2d test: require foreground-only global search shortcut 2026-07-22 16:47:51 -07:00
austinpower1258 282d0dae57 fix: conflate expensive preview reloads 2026-07-22 16:34:29 -07:00
austinpower1258 c372d09025 test: cover latest preview load conflation 2026-07-22 16:32:49 -07:00
austinpower1258 b759fb133c Merge remote-tracking branch 'origin/main' into issue-8652-file-preview-refresh 2026-07-22 16:30:59 -07:00
Álvaro Fernández González b17a87adad Fix: exclude .attrib from watched filesystem events (#8659) 2026-07-22 16:24:06 -07:00
austinpower1258 aa7c503868 fix: route watched changes through target state 2026-07-22 16:18:50 -07:00
austinpower1258 c7f3a44e9c test: cover observed file change routing 2026-07-22 16:18:31 -07:00
austinpower1258 477def238f Merge remote-tracking branch 'origin/main' into issue-8652-file-preview-refresh 2026-07-22 16:17:04 -07:00
austinpower1258 bd4ffd5f5a test: make preview reload checks event-driven 2026-07-22 16:16:35 -07:00
austinpower1258 c1b837c603 fix: preserve file preview state during refresh 2026-07-22 16:15:38 -07:00
austinpower1258 f9cff1ea8d test: cover file preview reload state 2026-07-22 16:15:21 -07:00
Abdulaziz Albahar 5220e7e449 Fix iOS release settings toast compile
Fix release archive compile by keeping ToastCenter available to the production Settings toast toggle and explicitly binding self.toasts.
2026-07-22 18:01:17 -05:00
austinpower1258 f01eec6d69 Merge remote-tracking branch 'origin/main' into issue-8652-file-preview-refresh 2026-07-22 15:58:35 -07:00
austinpower1258 e352a94431 feat: refresh file previews from disk 2026-07-22 15:49:42 -07:00
Abdulaziz Albahar 70224d239f Fix iOS deleted Iroh Mac recovery
Adds explicit signed-in account recovery for deleted Iroh Macs, updates iOS recovery copy, and covers success, failure, and mixed-route recovery.
2026-07-22 17:43:45 -05:00
austinpower1258 2eaee01a44 test: cover file preview disk reloads 2026-07-22 15:21:42 -07:00
Abdulaziz Albaharandcmux reload-cloud 8370a8ac78 Show progress while verifying sign-in code (#8666)
* Show progress while verifying sign-in code

* Adapt verification spinner to appearance

* Preserve verification button accessibility label

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-22 17:20:53 -05:00
austinpower1258 693b1a0727 test: cover nonblocking Pi hook dispatch 2026-07-22 15:17:12 -07:00
Mark Rohan f56d8c7e29 Notify only after Pi agent settles (#8574)
* Add Pi settled notification regression test

* Notify when Pi agent settles

* Add Pi completion compatibility coverage

* Preserve legacy Pi completion hooks

* Test npm-linked legacy Pi detection

* Resolve npm-linked Pi package versions

* fixup! Resolve npm-linked Pi package versions

* fixup! Resolve npm-linked Pi package versions

* fixup! Resolve npm-linked Pi package versions

* fixup! Resolve npm-linked Pi package versions

* fixup! Resolve npm-linked Pi package versions

* fixup! Resolve npm-linked Pi package versions
2026-07-22 15:10:50 -07:00
7b4f8305ff Allow internal TestFlight bundle id for APNs device-token registration (#8679)
* 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]>
2026-07-22 15:02:09 -07:00
Austin Wang f5361074f6 Fix DispatchWorkItem chain stack overflow (#8615)
* Add regression guard for ContentView work item chains

* Break ContentView DispatchWorkItem replacement chains

* Tighten ContentView work item regression guard

* Remove GCD scheduling from work item chain fix

* Detect multiline State work item declarations

* Preserve sidebar cursor release delay

* Split command palette focus restore coordinator

* Test command palette focus restore coalescing

* Address work item review feedback

* Bound stale command palette focus restores

* Bound command palette focus restore retries

* Scope command palette restore retries to target focus

* Guard command palette restore reentry

* Make focus restore stale-target policy testable

* Cancel sidebar cursor release replacements

* Preserve deferred cursor release timing
2026-07-22 13:54:13 -07:00
0618534277 fix(ios): persist Iroh broker backpressure (#8609)
* 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]>
2026-07-22 15:00:40 -05:00
lawrencecchen 1f2a96803d Merge remote-tracking branch 'origin/main' into feat-cmux-tui-remote-daemon 2026-07-22 12:46:09 -07:00
lawrencecchen 8c6c1fcb80 feat(tui): add authenticated remote daemon and clients 2026-07-22 12:46:04 -07:00
Abdulaziz Albahar bcec24d5d5 Honor broker Retry-After across iOS endpoint activations (#8557) 2026-07-22 14:26:56 -05:00
Dusan Jovanovic bd48fa7f31 Keep sidebar Markdown links out of focus chain 2026-07-22 13:49:13 -04:00
Dusan Jovanovic 85806c2b84 Test sidebar Markdown links preserve keyboard focus 2026-07-22 13:47:56 -04:00
dusan.jovanovic 5ca447c018 Render sidebar metadata Markdown links in AppKit 2026-07-22 13:20:44 -04:00
dusan.jovanovic 957b87d818 Test AppKit sidebar Markdown metadata links 2026-07-22 13:20:44 -04:00
Lawrence Chen f616cecf3b Add workspace-only focus history setting (#8654)
* 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
2026-07-22 05:12:53 -07:00
Lawrence Chen 184d61e2d8 Fix AppKit sidebar shortcut hint animation (#8589)
* 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
2026-07-22 01:13:59 -07:00
Lawrence Chen 81ec541183 Add cmux Cloud billing return page (#8647)
* Add cmux Cloud billing return page

* Honor locale preferences on Cloud billing returns

* Honor equal-weight language order
2026-07-22 00:56:31 -07:00
cmux-lawrence ab5028a2c3 Merge latest viewer lease base 2026-07-21 22:50:09 -07:00
cmux-lawrence 59dfa84d2e Merge latest durable state root base 2026-07-21 22:50:04 -07:00
cmux-lawrence 5dbd5762cc Merge latest renderer input barrier base 2026-07-21 22:50:01 -07:00
cmux-lawrence bb38dd9f66 Merge latest resize pixel metrics base 2026-07-21 22:49:57 -07:00
cmux-lawrence 43e971711d Merge latest socket path base 2026-07-21 22:49:54 -07:00
cmux-lawrence 02f7680f4c Merge latest canonical placement base 2026-07-21 22:49:50 -07:00
cmux-lawrence 43b52ba3e6 Merge latest cmux/main into canonical terminal placement stack 2026-07-21 22:49:47 -07:00
cmux-lawrence 97229d106a Merge post-main CI fixes from viewer lease base 2026-07-21 22:45:24 -07:00
cmux-lawrence 0f7db9cc21 test(tui): satisfy strict clippy after main sync 2026-07-21 22:45:14 -07:00
cmux-lawrence e56884ce27 Merge post-main close-result fix from durable state base 2026-07-21 22:44:18 -07:00
cmux-lawrence cef10bde1f Merge post-main close-result fix from input barrier base 2026-07-21 22:44:03 -07:00
cmux-lawrence 46ab94b7d5 Merge post-main close-result fix from resize base 2026-07-21 22:43:48 -07:00
cmux-lawrence 0fb544e70e Merge post-main close-result fix from socket base 2026-07-21 22:43:25 -07:00
cmux-lawrence 0db51102aa Merge post-main close-result fix from canonical base 2026-07-21 22:43:04 -07:00
cmux-lawrence 9210cb10c1 fix(tui): handle close results after main sync 2026-07-21 22:42:52 -07:00
cmux-lawrence 38e90ccadb Merge updated viewer lease release base
# Conflicts:
#	cmux-tui/spec/commands.md
#	ghostty
2026-07-21 22:41:34 -07:00
cmux-lawrence 37e047ef90 Merge updated durable state root base
# Conflicts:
#	cmux-tui/crates/cmux-tui/src/app.rs
2026-07-21 22:39:55 -07:00
Austin WangandClaude Sonnet 5 49a43bfa0f Stop firing duplicate agent resumes on relaunch (#8619)
* 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]>
2026-07-21 22:38:30 -07:00
cmux-lawrence b7785116c1 Merge updated renderer input barrier base 2026-07-21 22:35:26 -07:00
cmux-lawrence b39d4ceb57 Merge updated resize pixel metrics base 2026-07-21 22:34:31 -07:00
cmux-lawrence 87f01f31ce Merge updated socket path contract base 2026-07-21 22:33:16 -07:00
Austin Wang 89985a9082 Persist per-tab terminal zoom across restarts (#8543)
* 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
2026-07-21 22:32:57 -07:00
cmux-lawrence a7e3b8e2ab Merge updated canonical terminal placement base 2026-07-21 22:32:06 -07:00
cmux-lawrence 84ba146651 Merge cmux/main into canonical terminal placement stack
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/mux.rs
#	cmux-tui/spec/commands.md
#	ghostty
2026-07-21 22:31:59 -07:00
cmux-lawrence 9d7f6838bd fix(tui): preserve nullable Ghostty cursor blink 2026-07-21 22:10:11 -07:00
cmux-lawrence 7b5aeda046 test(tui): parse outer cursor control strings 2026-07-21 21:58:29 -07:00
cmux-lawrence 771303963f docs(ghostty): distinguish browser fork pin 2026-07-21 21:54:30 -07:00
cmux-lawrence ba26773c22 Merge cmux/main into TUI frontend parity stack 2026-07-21 21:52:05 -07:00
cmux-lawrence 26547ed187 fix(tui): deduplicate terminal host pwd updates 2026-07-21 21:38:50 -07:00
cmux-lawrence 63439dff8f fix(tui): prefer pinned Ghostty config resolver 2026-07-21 21:33:00 -07:00
572d25ccce Restore todo/completion-status parity in the AppKit sidebar (#8552)
* 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]>
2026-07-21 23:15:17 -05:00
cmux-lawrence 768c8d3f99 test(tui): parse indexed color smoke commands 2026-07-21 20:52:00 -07:00
cmux-lawrence ee515fcfb9 fix(tui): restore frontend smoke parity 2026-07-21 20:30:31 -07:00
Lawrence Chen 4f89fb878a cmux-tui: make visible indexes zero-based (#8603)
* test(tui): expect zero-based screen numbers

* fix(tui): number screens from zero

* docs(tui): clarify screen index selection

* test(tui): expect zero-based tabs and workspaces

* fix(tui): use zero-based tabs and workspaces

* test(tui): cover zero-based layout workspace names

* fix(tui): zero-index layout workspaces
2026-07-21 20:17:35 -07:00
Austin Wang 364e8bd366 Fix workspace shortcuts from hosted tmux terminals (#8621)
* Fix terminal key-equivalent routing for hosted surfaces

* Align hosted terminal focus reconciliation

* Align hosted terminal focus reconciliation

* Align hosted terminal focus reconciliation

* Align hosted terminal focus reconciliation

* Align hosted terminal focus reconciliation
2026-07-21 20:14:54 -07:00
3e66d6165e iOS: native soft scroll edge effect under the workspace list chrome (#8575)
* 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]>
2026-07-21 22:01:37 -05:00
eeb4866b17 iOS: stop open menus from flickering when their parent views re-render (#8611)
* 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]>
2026-07-21 21:50:12 -05:00
7edd8365c2 fix(git): drop convenience from actor initializer to restore the build (#8616)
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]>
2026-07-21 21:44:28 -05:00
Austin Wang 954c75a80f Fix Cmd-click link opening in Dock terminals (#8594)
* 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
2026-07-21 19:41:06 -07:00
Austin Wangandcmux reload-cloud 7d87efec7a Restore Kimi Code sessions after relaunch (#8584)
* test: cover Kimi session resume pipeline

* fix: restore Kimi Code sessions

* test: cover Kimi approval resume policy

* fix: preserve Kimi session approval state

* test: cover Kimi Vault compatibility

* fix: keep Kimi Vault overrides compatible

* test: preserve custom Kimi snapshot ownership

* fix: preserve custom Kimi snapshot ownership

* test: preserve Kimi launch options on restore

* fix: retain Kimi launch options on resume

* refactor: colocate Kimi resume builder extension

* test: pin Kimi resumes to launch directory

* fix: pin built-in Kimi resumes to launch directory

* test: cover Kimi resume ownership gaps

* test: fix Kimi review regression harness

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 19:33:28 -07:00
Austin Wangandcmux reload-cloud 9a06a69881 Shield persistent remote PTY children from relay hangups (#8438)
* test: reproduce PTY foreground child hangup

* fix: shield persistent PTY children from hangup

* fix: serialize PTY descriptor teardown

* test: cover PTY background group cleanup

* fix: terminate complete PTY process sessions

* perf: yield between PTY session scans

* fix: remove timing from PTY session teardown

* test: preserve anonymous PTY hangup semantics

* fix: scope hangup shielding to persistent PTYs

* test: cover interactive PTY child hangup reset

* fix: preserve persistent PTY agents across hangups

* test: cover bootstrap signal mask loss

* fix: protect final persistent PTY shell exec

* test: cover staged persistent PTY bootstrap helper

* fix: default staged PTY helper to bundled CLI

* Make interactive Bash test readiness deterministic

* test: cover persistent PTY helper lifecycle regressions

* test: cover known and custom bare PTY shells

* test: synchronize PTY leader-exit cleanup fixture

* fix: close persistent PTY lifecycle gaps

* test: cover scoped PTY shielding and single teardown

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 19:32:36 -07:00
Austin Wang 90ad023aba Fix renderer presentation for background-created surfaces (#8540)
* test(terminal): cover first renderer presentation

* fix(terminal): present hidden-born renderers on reveal

* test(terminal): cover renderer presentation idempotency

* fix(terminal): bound renderer presentation repair

* fix(terminal): bound immediate presentation retries

* test(terminal): isolate renderer presentation stubs

* test(terminal): wait for renderer activity before repair

* refactor(canvas): inject terminal visibility transition

* test(canvas): require attachment before terminal visibility

* fix(terminal): repair presentation after renderer activity

* test(terminal): require occlusion before renderer release

* fix(terminal): occlude hidden runtimes before release

* test(terminal): defer presentation until window attachment

* fix(terminal): wait for presentation attachment

* refactor(terminal): expose presentation readiness transition
2026-07-21 19:32:15 -07:00
Austin Wangandcmux reload-cloud 158435c145 Fix remote tmux seed/live output ordering (#8436)
* Add failing remote tmux seed transport regression

* Make remote tmux pane seeding transport-independent

* Simplify remote tmux seed capture state

* Add failing single-pane seed grow regression

* Repaint remote tmux panes after verified grid growth

* Fix remote tmux seed test actor isolation

* Add failing remote tmux blank-row parser regression

* Preserve blank rows in remote tmux captures

* Add failing remote tmux reconnect cutover regressions

* Reset remote tmux output cursor before pane seeds

* Add failing quoted pane cursor reset regression

* Quote remote tmux pane cursor reset arguments

* Add failing remote tmux repaint coalescing regression

* Coalesce remote tmux pane repaint seeds

* Add failing remote tmux grid-growth history regression

* Gate remote tmux repaint until grid growth applies

* Add failing exited pane seed regression

* Avoid reconnecting when seeded pane exits

* Add failing reconnect history boundary regression

* Avoid reconnect redraw kick after pane reseed

* Add failing safe pane reseed regressions

* Make remote tmux pane reseeds backlog safe

* Add failing bounded reconnect seed regressions

* Bound remote tmux seed recovery

* Add failing remote tmux seed replacement regressions

* Fix remote tmux seed cutover ordering

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 19:32:00 -07:00
Austin Wangandcmux reload-cloud a3ab095ec3 Fix browser navigation for terminal-wrapped URL pastes (#8601)
* test: reproduce wrapped omnibar URL paste

* fix: navigate terminal-wrapped omnibar URLs

* fix: preserve omnibar paste boundaries

* test: cover wired omnibar paste editor

* test: cover typed omnibar paste path

* fix: sanitize typed omnibar paste paths

* test: keep newline-separated text as search

* fix: preserve newline-separated omnibar searches

* test: cover safe omnibar authority compaction

* fix: keep omnibar authority compaction safe

* test: keep scheme-less authority immutable

* fix: preserve scheme-less omnibar authority

* test: reject scheme-less omnibar userinfo

* fix: reject scheme-less omnibar userinfo

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 19:28:40 -07:00
Lawrence Chenandcmux-lawrence b63b760149 Support indented hard-wrapped links (#8391)
* fix: support indented hard-wrapped links

* build: pin wrapped-link GhosttyKit archive

* fix: align wrapped-link hover and click bounds

* build: pin exact wrapped-link Ghostty resolver

* build: pin reviewed wrapped-link GhosttyKit

* build: drop invalid intermediate GhosttyKit pin

* build: pin combined wrapped-link GhosttyKit

* build: pin reviewed wrapped-link GhosttyKit

* test: lock Ghostty surface config ABI

* fix: pin hardened wrapped-link Ghostty runtime

---------

Co-authored-by: cmux-lawrence <[email protected]>
2026-07-21 19:28:27 -07:00
Austin Wangandcmux reload-cloud f43c6ccbec Fix browser automation recovery after load failures (#8548)
* test: cover browser recovery navigation commit

* test: require browser navigation commit barrier

* fix: await browser navigation commits

* fix: bound browser navigation transaction state

* fix: preserve browser navigation semantics

* fix: hand off browser navigation transactions

* fix: cover browser navigation handoff outcomes

* fix: validate deferred browser navigation targets

* fix: complete browser download navigations

* fix: preserve browser navigation results

* fix: correlate browser navigation terminal paths

* fix: preserve browser policy navigation identity

* fix: distinguish browser navigation policy signals

* fix: sanitize browser navigation failures

* fix: correlate deferred browser navigation handoffs

* fix: bound browser navigation outcome ownership

* fix: separate same-document navigation signals

* fix: correlate browser policy outcomes exactly

* fix: normalize browser navigation targets

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 19:18:43 -07:00
Austin Wangandcmux reload-cloud 950e60bc97 Skip surface resume for exited agent processes (#8547)
* test: cover exited agent surface resume

* fix: retire exited agent resume bindings

* fix: revalidate cached agent liveness

* docs: clarify cached resume liveness

* fix: recognize newer agent resume generations

* test: isolate newer resume generation states

* fix: keep cached agent exits authoritative

* fix: confirm live agent relaunch identities

* fix: require session-qualified agent liveness

* fix: prioritize confirmed runtime evidence

* refactor: model restorable process observations

* test: cover restorable process generation transitions

* test: load process generation fixtures from hook override

* fix: validate restorable process generations

* test: cover binding and process generation ownership

* fix: authenticate agent process generations

* test: model live PID-less resume compatibility

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 19:17:23 -07:00
Abdulaziz Albaharandcmux reload-cloud 2250252fdb Redesign iOS onboarding around live agent handoff (#8418)
* Redesign iOS onboarding around agent handoff

* Test accurate iOS onboarding handoff

* Make iOS onboarding match automatic Iroh discovery

* Test blank iOS onboarding feature slot

* Reserve onboarding page for a shipped feature

* Test directional onboarding transitions

* Animate onboarding scenes as a continuous flow

* Test persistent onboarding page chrome

* Page onboarding content within persistent chrome

* Make onboarding paging direction deterministic

* Harden onboarding paging regression test

* Introduce notification feed in iOS onboarding

* Address onboarding review findings

* Keep release UI test config builds compiling

* Close onboarding review gaps

* Resolve final onboarding review findings

* Coalesce onboarding connection retries

* Centralize onboarding retry and scanner handoff

* Test onboarding retry coalescing

* Give redesigned onboarding independent progress state

* Bound onboarding retries and share scanner handoff

* Align onboarding helpers with package policy

* Embed sign in cleanly in onboarding

* Keep reconnect restoration state sticky

* Fix onboarding replay recovery paths

* Align onboarding scanner cancellation

* Refresh onboarding discovery on entry

* Start onboarding discovery after sign in

* Track pairing origin and mirror onboarding pages

* Clarify onboarding fallback and scanner handoff

* Harden onboarding reconnect deadline state

* Separate launch and onboarding reconnect deadlines

* Document reconnect deadline ownership

* Keep reconnect deadline testable without a production seam

* Use internal reconnect state in tests

* Close reconnect ownership gaps

* Keep pairing sheet recovery deterministic

* Preserve pairing work during sheet mode changes

* Fix current main actor initializer build

* Avoid cancelling successful scanner pairing

* Isolate pull request HTTP test fixtures

* Avoid onboarding connection fallback flash

* Keep onboarding reconnect deadline authoritative

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 21:06:26 -05:00
fml09 bbae0b80b5 Fix Settings and main window zombies under AeroSpace (#8513)
* test: cover closed window retirement

* fix: retire closed AppKit windows

* test: require weak SwiftUI window state

* fix: weaken SwiftUI native window references

* fix: address window lifecycle review feedback
2026-07-21 18:59:09 -07:00
cmux-lawrence 386d73f817 Merge branch 'codex/cmux-tui-viewer-lease-release' into codex/cmux-tui-frontend-parity 2026-07-21 18:52:50 -07:00
cmux-lawrence e74b7583e8 Merge branch 'codex/cmux-tui-durable-state-root' into codex/cmux-tui-viewer-lease-release 2026-07-21 18:52:47 -07:00
cmux-lawrence 273f345084 Merge branch 'codex/cmux-tui-renderer-input-barrier-contract' into codex/cmux-tui-durable-state-root 2026-07-21 18:52:44 -07:00
cmux-lawrence c228ed2d69 Merge branch 'codex/cmux-tui-resize-pixel-metrics' into codex/cmux-tui-renderer-input-barrier-contract 2026-07-21 18:52:42 -07:00
cmux-lawrence 08cd1bb3c3 Merge branch 'codex/cmux-tui-socket-path-contract' into codex/cmux-tui-resize-pixel-metrics 2026-07-21 18:52:39 -07:00
cmux-lawrence 2de1121890 Merge branch 'codex/cmux-tui-canonical-terminal-placement' into codex/cmux-tui-socket-path-contract 2026-07-21 18:52:36 -07:00
cmux-lawrence 7ce3aa2506 Merge remote-tracking branch 'cmux/main' into codex/cmux-tui-canonical-terminal-placement 2026-07-21 18:52:33 -07:00
EJandejc3 b563183374 CmuxGit: drop 'convenience' from the coordinator's public init (#8607)
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]>
2026-07-21 18:33:55 -07:00
cmux-lawrence cbac0eb054 Merge commit 'ce228c2f8693bc5f98628db13e9f3e6db6e82a15' into codex/cmux-tui-frontend-parity 2026-07-21 16:17:10 -07:00
cmux-lawrence ce228c2f86 Merge commit '94cbd986a6d13c5929acd5c887a5b878ee652565' into codex/cmux-tui-viewer-lease-release 2026-07-21 16:16:59 -07:00
cmux-lawrence 94cbd986a6 Merge commit '76c79ecca06e7d16e23374ab3632599c070a43d5' into codex/cmux-tui-durable-state-root
# Conflicts:
#	cmux-tui/crates/cmux-tui/src/main.rs
2026-07-21 16:16:50 -07:00
cmux-lawrence 76c79ecca0 Merge commit 'cd273e45f8b323b761ad85c4e7cc504bbd97c961' into codex/cmux-tui-renderer-input-barrier-contract 2026-07-21 16:15:47 -07:00
cmux-lawrence cd273e45f8 Merge commit 'b01b9cdeef98515a52fe3672cfd04cbc7ef5b212' into codex/cmux-tui-resize-pixel-metrics 2026-07-21 16:15:37 -07:00
cmux-lawrence b01b9cdeef Merge commit 'a977f9aa6e01601721a0c559177338d61f27d5d9' into codex/cmux-tui-socket-path-contract 2026-07-21 16:12:52 -07:00
cmux-lawrence a977f9aa6e Merge commit '4daa93725694d51feae628426e878ccb73eabe6f' into codex/cmux-tui-canonical-terminal-placement
# Conflicts:
#	cmux-tui/crates/cmux-tui/src/main.rs
#	cmux-tui/crates/cmux-tui/tests/cli.rs
2026-07-21 16:12:08 -07:00
Lawrence Chen 4daa937256 Attach plain cmux-tui launches to existing sessions (#8598)
* test(tui): reproduce occupied-session launch

* fix(tui): attach plain launches to live sessions

* fix(tui): address attach review feedback

* fix(tui): preserve configured server startup
2026-07-21 15:48:47 -07:00
cmux-lawrence 883fc2f497 Merge commit 'fd66cc6faa98fb252da35814624e3b84edc95a3f' into codex/cmux-tui-frontend-parity 2026-07-21 15:40:19 -07:00
cmux-lawrence fd66cc6faa Merge commit '7c5c4bb3d8115d3dafeb97e68eb1277fea33c69d' into codex/cmux-tui-viewer-lease-release 2026-07-21 15:40:11 -07:00
cmux-lawrence 7c5c4bb3d8 Merge commit '3ceea8f7aa9e238d29f22f88e80d0e5c2c4968d0' into codex/cmux-tui-durable-state-root 2026-07-21 15:39:59 -07:00
cmux-lawrence 3ceea8f7aa Merge commit '7c45cdb1078f63fb20a0342872e6658dbeb81c07' into codex/cmux-tui-renderer-input-barrier-contract 2026-07-21 15:39:51 -07:00
cmux-lawrence 7c45cdb107 Merge commit 'cd5919f18a7d4f44462565cf794af545cd31daf0' into codex/cmux-tui-resize-pixel-metrics 2026-07-21 15:39:41 -07:00
cmux-lawrence cd5919f18a Merge commit '36208fff93ac287b87f50df21398c732ad60ece8' into codex/cmux-tui-socket-path-contract 2026-07-21 15:39:33 -07:00
cmux-lawrence 36208fff93 Merge commit '20a103a0f8a07498cf5693ae600cc4d84cebbd45' into codex/cmux-tui-canonical-terminal-placement 2026-07-21 15:38:49 -07:00
cmux-lawrence c2f07c092e Merge commit '8ca809e1c37dd0087feacd8e9962eb0bfb005d1e' into codex/cmux-tui-frontend-parity
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/mux.rs
2026-07-21 15:34:23 -07:00
cmux-lawrence 8ca809e1c3 Merge commit 'c9dc448b433f20f4a009f8f9bb1482fc2256306c' into codex/cmux-tui-viewer-lease-release 2026-07-21 15:32:39 -07:00
cmux-lawrence c9dc448b43 Merge commit '1ee36dbe636e0c2a1c05380f38164d7ad9b668ec' into codex/cmux-tui-durable-state-root 2026-07-21 15:31:55 -07:00
cmux-lawrence 1ee36dbe63 Merge commit '8d8fc86aea76bf4e392ed6587dd7760b1b46c7be' into codex/cmux-tui-renderer-input-barrier-contract 2026-07-21 15:31:15 -07:00
cmux-lawrence 8d8fc86aea Merge commit 'e3d5d9af53a24fcf54d1dfd9730269f8511ebaab' into codex/cmux-tui-resize-pixel-metrics 2026-07-21 15:29:56 -07:00
cmux-lawrence e3d5d9af53 Merge commit '5ecd858b984d0882b54bbc52527577719d4e7b47' into codex/cmux-tui-socket-path-contract 2026-07-21 15:28:32 -07:00
cmux-lawrence 5ecd858b98 fix(tui): initialize merged pane focus recency 2026-07-21 15:28:20 -07:00
cmux-lawrence cc52873dea Merge commit '3c1a6c2f6126671cad80f992566e1d054fecc348' into codex/cmux-tui-canonical-terminal-placement
# Conflicts:
#	ghostty
2026-07-21 15:26:37 -07:00
Josh FreeandCopilot 20a103a0f8 [PERF] pr-poller: stop re-downloading every repo's full PR list on each poll (unchanged branches now 304 properly) (#8521)
* 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]>
2026-07-21 15:12:53 -07:00
Austin Wang 3c1a6c2f61 Make workspace group CLI deletion safe by default (#8542)
* test: cover safe workspace group CLI behavior

* fix: make workspace group CLI removal explicit

* fix: respect workspace group option terminator

* test: simplify workspace group safety fixture

* fix: address workspace group review findings
2026-07-21 15:02:23 -07:00
Austin Wang 89da124ac3 Restore socket discovery for legacy external clients (#8545)
* test: expose stale legacy socket discovery

* fix: preserve legacy socket marker discovery

* refactor: keep socket marker resolution stateless

* refactor: reuse socket marker resolver
2026-07-21 14:33:56 -07:00
Lawrence Chen 12de7ddd86 Render localized social previews deterministically (#8420)
* 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
2026-07-21 14:33:17 -07:00
a8446785c4 Update Set Up Computer and Switch Computer copy for zero-touch pairing (#8570)
* 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]>
2026-07-21 16:30:25 -05:00
Austin Wang a17d31c81d Fix identify caller recovery for restored shells (#8549)
* test: cover identify caller recovery from live tty

* fix: recover identify caller from live tty

* fix: resolve identify caller through nested tty

* fix: require fresh tty reports for identify fallback

* fix: bind tty fallback to terminal session

* fix: validate identify tty at trust boundary
2026-07-21 14:23:30 -07:00
20f2be6b17 iOS: stop seeding fake placeholder workspaces on sign-out (#8571)
* 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]>
2026-07-21 16:14:30 -05:00
Abdulaziz Albaharandcmux reload-cloud e3827a46ae iOS: make terminal folder-path taps a setting (Open Folders on Tap) (#8524)
* Gate iOS terminal folder taps behind setting

* Expose folder-tap flag through WorkspaceDetailView wrapper

The @Environment displaySettings property is private to WorkspaceDetailView.swift,
so the terminal-artifacts extension file reads the flag through an internal
wrapper, matching terminalFilesChipEnabled.

* fix(mobile): authorize directory artifact stats

* Log stat authorization diagnostics on denial (DEBUG)

* Temp: dump escaped text sample in stat deny diagnostics

* Temp: dump deny authorization text to file

* test(agent-chat): cover VT escapes in path detection

* fix(agent-chat): strip VT escapes before path detection

* test: cover terminal folder tap review findings

* fix: fail closed on terminal folder taps

* test: cover round-two terminal artifact findings

* fix: bound terminal artifact tap classification

* test: cover noncooperative folder tap stats

* fix: preserve terminal folder tap behavior

* test: cover final artifact tap review findings

* fix: close final artifact tap review findings

* fix: close round five artifact tap findings

* test: cover closing artifact review findings

* fix: close round six artifact review findings

* fix: revalidate mobile artifact taps

* Fix artifact access race conditions

* fix: preserve mobile terminal tap ordering

* Simplify artifact tap classification

* test: cover forbidden terminal artifact taps

* fix: open forbidden terminal artifact taps

* Use an injected clock for the classification deadline

* Restore ghostty submodule pointer to main's revision

* test: cover typed FIFO artifact previews

* fix: reject typed special artifacts and stale taps

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-21 16:12:53 -05:00
f39d83b490 iOS: unified animated toast system (CmuxMobileToast) (#8376)
* 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]>
2026-07-21 15:08:07 -05:00
1cf6b7f946 Mobile state sync v2: per-record deltas replace the invalidate-and-refetch loop (#8284)
* 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]>
2026-07-21 14:58:35 -05:00
4d1bb5a454 Tell coding agents workspace todos are user-owned (#8566)
* 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]>
2026-07-21 12:51:55 -05:00
Abdulaziz Albaharandcmux reload-cloud 1de332747f Keep iOS terminal replay atomic until presentation is verified (#8106)
* Test atomic iOS terminal replay presentation

* Present verified iOS terminal replays atomically

* Test verified replay recovery barriers

* Fence verified replay recovery

* Test stale verified replay completion

* Test adjacent verified replay submission

* Tie verified replay to exact Metal presentations

* Polish verified replay lifecycle

* Test verified replay full-frame recovery

* Require full frames for verified recovery

* Test verified replay IOSurface seed advancement

* Accept presented IOSurface seed advancement

* Refine verified replay ownership boundaries

* Test verified replay geometry and transport latching

* Fence verified replay geometry and retain transport

* Test stale transport fallback isolation

* Isolate verified transport fallback by connection

* Test verified replay detach isolation

* Reject stale verified replay freezes

* Test verified replay cold attach drain

* Skip impossible cold replay drain

* Test verified replay capability dependency

* Require base grid for verified replay

* Move replay drain policy onto surface view

* Test verified replay routing authority

* Reject unauthorized verified replay routes

* Update Ghostty presentation token support

* Test IOSurface seed-straddled replay presentation

* Fence replay by IOSurface allocation

* Pin verified replay GhosttyKit checksum

* Test replay token replacement after geometry changes

* Resubmit verified replay after geometry changes

* Test token-only verified replay geometry restart

* Keep token-only verified replay ready after resize

* Refactor verified replay presentation helpers

* test(ios): cover exact verified replay geometry fit

* test(ios): cover replay gaps inheriting active style

* fix(ios): verify replay against exact mounted visuals

* Test physical pairing rejects untrusted routes

* Fail closed on untrusted phone pairing tickets

* Rotate staging relay policy verification key

* Test local relay signer fallback

* Recover redacted local relay signer

* Test shared dev relay backend override

* Share trusted dev backend across Mac and iOS

* Pin verified replay GhosttyKit checksum

* Fix verified replay theme source after main merge

* test(ios): cover stale resize and scroll replay races

* fix(ios): order resize and scroll presentation

* test(ios): cover frozen replay timeout recovery

* fix(ios): clear frozen replay before timeout recovery

* fix(ios): keep frozen replay snapshots coherent

* test(ios): cover detached terminal ownership

* fix(ios): scope terminal ownership to visible mount

* test(ios): require viewport priming before replay

* fix(ios): size replay before terminal attach

* test(mac): reject replay during viewport transition

* fix(mac): fence replay behind viewport resize

* test(ios): require recovery to clear frozen replay

* fix(ios): clear replay freeze during renderer recovery

* test(ios): cover paused replay recovery cleanup

* fix(ios): clear replay freeze before recovery pause

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-20 23:48:44 -05:00
fca2a2d45e Attach workspace rename and color alerts to their window (#8544)
* 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]>
2026-07-20 22:31:55 -05:00
Abdulaziz Albaharandcmux reload-cloud 6f7e3dccdc Stabilize iOS workspace list scrolling during live updates (#8451)
* test(ios): cover live workspace updates during scroll reversal

* fix(ios): stabilize live workspace list scrolling

* fix(ios): preserve idle workspace update animations

* test(ios): exercise workspace list reversal boundaries

* test(ios): cover workspace list rebinding during scroll

* fix(ios): harden workspace scroll update lifecycle

* test(ios): cover pan-end deceleration ordering

* fix(ios): wait for authoritative scroll completion

* test(ios): isolate workspace scroll state injection

* test(ios): require native workspace pan lifecycle

* test(ios): cover redundant workspace refresh work

* fix(ios): let UIKit own workspace list scrolling

* test(ios): clarify native scroll ownership coverage

* test(ios): cover native workspace scroll edge

* fix(ios): use native workspace scroll edge effect

* perf(ios): reuse workspace table item index

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-20 21:44:48 -05:00
Austin Wangandcmux reload-cloud 3414079fb8 Add remote-aware resume bindings for SSH workspaces (#8441)
* test: cover remote SSH resume bindings

* feat: add remote-aware SSH resume bindings

* fix: authenticate remote resume relay provenance

* fix: compile relay provenance authentication

* fix: address remote resume review findings

* refactor: satisfy remote resume file policy

* test: cover legacy remote resume migration

* fix: migrate legacy remote resume bindings

* test: cover workspace-id-free legacy resume

* fix: recover legacy remote workspace identity

* test: remove unused remote resume helper inputs

* test: cover resume with unsupported remote shell

* test: import remote configuration module

* test: disambiguate resume command decoding

* fix: resume before unsupported remote shells

* test: cover escaped remote resume method

* fix: preserve unsupported remote login shells

* fix: classify relayed resume methods structurally

* test: remove unnecessary remote resume throws

* fix: integrate remote resume bootstrap API

* test: fix merged remote transport coverage

* test: fix remote prompt diagnostics

* fix: run resume before unsupported remote shells

* Expand remote resume verification coverage

* Fix remote resume verifier assertions

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-20 19:27:28 -07:00
Austin Wang 95e334cabb Prevent idle AppKit sidebar layout livelock (#8532)
* test: cover deferred sidebar table mutations

* Defer AppKit sidebar table mutations

* test: await deferred sidebar viewport mutations

* Split sidebar table apply input

* test: await sidebar mutation callback boundary
2026-07-20 17:17:59 -07:00
Austin Wang 12798dc4fa Fix sidebar status URL clicks (#8528)
* test: reproduce sidebar status URL click regression

* fix: restore sidebar status URL actions
2026-07-20 17:11:21 -07:00
Austin Wangandcmux reload-cloud 97c3986a6e Fix remote daemon upload without SFTP (#8434)
* test: cover daemon upload without SFTP

* fix: upload remote daemon through SSH exec

* test: avoid remote session helper name collisions

* test: assert remote daemon upload transaction

* test: cover SSH uploads with StdinNull configured

* fix: preserve file stdin across SSH configuration

* test: cover cleanup after daemon finalization failure

* fix: clean failed remote daemon finalization

* Organize remote upload test fixture type

* test: cover sanitized daemon upload errors

* fix: sanitize daemon upload launch errors

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-20 17:06:31 -07:00
7a54e8ce09 Add chronological notification feed to iOS (#8210)
* Add chronological iOS notification feed

* Fix notification feed UI test selector

* Use native tab queries in feed UI test

* test(ios): cover notification return and search refresh

* fix(ios): preserve notification feed context

* test(ios): reproduce stale notification target

* fix(ios): make notification feed context durable

* test(ios): reproduce buried workspace context

* fix(ios): promote workspace in notification rows

* test(ios): reproduce dense notification rows

* fix(ios): compact notification feed rows

* test(ios): reproduce incomplete workspace search

* fix(ios): make workspace search match its context

* 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(ios): keep search refresh fixture stable on main

* Fix escaping-closure capture in sidebar slot view tint (#8329)

The NSImage drawing handler is escaping, so referencing the enclosing
view's color without an explicit capture fails to compile; capture the
color by value. Unbreaks clean Debug builds of current main carried into
this branch.

Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>

* test: cover unread feed actions and centered toolbar

* feat(ios): add unread feed actions and compact bulk read

* test(ios): lock notification toolbar geometry

* test(ios): require semantic notification row labels

* feat(ios): simplify notification feed rows

* test: cover notification feed merge regressions

* fix: harden notification feed state and rendering

* test: remove notification history settling delay

* test: repair merged mosh coverage

---------

Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-20 18:32:35 -05:00
Austin Wangandcmux reload-cloud 98a701ffd9 Add first-class Mosh transport for remote workspaces (#8442)
* Add Mosh transport for remote workspaces

* Localize Mosh docs across supported locales

* Align Mosh transport error fallback

* Test Mosh persistent PTY normalization

* Disable persistent PTY state for Mosh terminals

* Add first-class mosh and mosh-tmux profiles

* Test remote mosh-server resolution outside PATH

* Stage managed bootstrap for Mosh terminals

* Test Mosh management lane activation

* Activate Mosh management lane before terminal

* Test tmux workspace rebinding on reattach

* Rebind tmux sessions to current remote workspace

* Test unsupported Mosh snapshot restore

* Use canonical Mosh restore support gate

* Test new tmux session workspace binding

* Bind new tmux sessions to current workspace

* Test bounded Mosh fallback launcher

* Keep Mosh fallback launcher bounded

* Test tmux default command window target

* Target tmux default command at session window

* Preserve user ZDOTDIR in tmux shells

* Test remote relay Git metadata reporting

* Test tmux relay metadata parity

* Report remote shell metadata through relay

* Test remote prompt relay metadata parity

* Synchronize prompt relay behavior tests

* Run remote prompt metadata through relay

* Test tmux initial command delivery

* Deliver initial command to new tmux session

* Align Mosh builders with package policy

* Test tmux session target separators

* Reject tmux session target separators

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-20 14:54:55 -07:00
f937305251 iOS: launch agent workspaces from the task composer (#7670)
* 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]>
2026-07-20 16:34:52 -05:00
Abdulaziz Albahar ce90c0b684 Merge pull request #8499 from manaflow-ai/task-iroh-relay-token-rollover
Prove live Iroh relay token rollover before release
2026-07-20 14:10:51 -05:00
cmux reload-cloud b2899a11a2 fix(iroh): reuse verified discovery for first dial 2026-07-20 11:26:14 -07:00
cmux reload-cloud 009a5eb027 test(iroh): expose duplicate discovery rate-limit outage 2026-07-20 11:22:49 -07:00
34cc2ba511 Lawrence Sidebar: down-then-up highlight, optimistic-paint reconcile, diagnosis probes (#8461)
* 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]>
2026-07-19 23:19:25 -05:00
cmux-lawrence b3e87d44c2 fix(tui): keep hosted terminal mirrors response-silent 2026-07-19 19:28:17 -07:00
cmux reload-cloud c783a9cff4 chore(iroh): consume home relay continuity release 2026-07-19 19:20:38 -07:00
cmux-lawrence e7c972a911 fix(tui): integrate pane focus memory with canonical state 2026-07-19 19:08:42 -07:00
cmux-lawrence 90f39e7099 fix(tui): follow dynamic color resets across mirrors 2026-07-19 19:03:45 -07:00
cmux-lawrence a1f76e79ad fix(tui): preserve exact terminal state across process mirrors 2026-07-19 19:03:45 -07:00
cmux-lawrence 68dddc692d fix(tui): preserve legacy live cursor state 2026-07-19 19:03:45 -07:00
cmux-lawrence 12fca3bf81 test(tui): use canonical workspace keys in fixtures 2026-07-19 19:03:45 -07:00
cmux-lawrence f1578ce6f6 fix(tui): preserve legacy cursor stream compatibility 2026-07-19 19:03:45 -07:00
cmux-lawrence 72b8a1c566 fix(tui): replay resolved cursor visuals across hosts 2026-07-19 19:03:45 -07:00
cmux-lawrence 8e93acaafd docs(tui): document canonical frontend contracts 2026-07-19 19:03:45 -07:00
cmux-lawrence 8c45b2acf0 fix(tui): preserve armed mouse releases across routing refresh 2026-07-19 19:03:45 -07:00
cmux-lawrence 431470740a fix(tui): require canonical workspace keys 2026-07-19 19:03:45 -07:00
cmux-lawrence 72c99e29fd feat(tui): support fenced daemon handoff 2026-07-19 19:03:44 -07:00
cmux-lawrence 3228105d2f fix(tui): accept shifted backtab events 2026-07-19 19:03:44 -07:00
cmux-lawrence 4de64f476b fix(tui): publish attached frontend selection 2026-07-19 19:03:44 -07:00
cmux-lawrence 293b538afe fix(tui): tolerate zero-width attach startup 2026-07-19 19:03:17 -07:00
cmux-lawrence 5031e228f9 fix(tui): decouple durable state from socket path 2026-07-19 19:03:17 -07:00
cmux-lawrence eec36c6787 test(tui): prove renderer input cutover fence 2026-07-19 19:03:17 -07:00
cmux-lawrence b64c07beb8 fix(tui): encode renderer resize replay length 2026-07-19 19:03:17 -07:00
cmux-lawrence 42a2887a40 fix(tui): shorten oversized default socket paths 2026-07-19 19:03:17 -07:00
cmux-lawrence 3959059506 fix(tui): integrate canonical hosts with current main 2026-07-19 19:03:17 -07:00
cmux-lawrence 15bd4ee3d9 fix(tui): forward remote terminal color state 2026-07-19 19:03:17 -07:00
cmux-lawrence 653e79f042 test(tui): expose zero-width attach panic 2026-07-19 19:03:17 -07:00
cmux-lawrence aaeb6b60c2 perf(tui): acknowledge unchanged viewer sizes 2026-07-19 19:03:17 -07:00
cmux-lawrence 12fed110b9 fix(tui): release stale daemon viewer leases 2026-07-19 19:03:17 -07:00
cmux-lawrence da1a66d3f2 fix(tui): preserve cell pixels across resize 2026-07-19 19:03:17 -07:00
cmux-lawrence ab9bbb0d3a test(tui): expose stale daemon viewer lease 2026-07-19 19:03:17 -07:00
cmux-lawrence b56878261f fix(tui): retain fast-exiting terminal results 2026-07-19 19:02:52 -07:00
cmux-lawrence de9a04a4c8 test(tui): preserve canonical workspace event ordering 2026-07-19 19:02:52 -07:00
cmux-lawrence c9e3c8f764 fix(tui): preserve final workspace move indices 2026-07-19 19:02:52 -07:00
cmux-lawrence cc7cbff42c test(tui): reap durable CLI fixture hosts 2026-07-19 19:02:52 -07:00
cmux-lawrence 5c9e7cd7aa fix(tui): harden terminal host lifecycle 2026-07-19 19:02:52 -07:00
cmux-lawrence 28cd74b520 fix(tui): yield terminal cursor when sidebar is focused 2026-07-19 19:02:52 -07:00
cmux-lawrence 24b7582f5c fix(tui): match canonical terminal colors and mouse grid 2026-07-19 19:02:52 -07:00
cmux-lawrence 0a4446e1a4 fix(tui): commit terminal closes before topology 2026-07-19 19:02:37 -07:00
cmux-lawrence c6857e9173 feat(tui): make terminal topology crash consistent 2026-07-19 19:02:26 -07:00
cmux-lawrence 4c87bd3b38 feat(tui): harden resilient terminal hosts 2026-07-19 19:02:26 -07:00
cmux-lawrence dfcdba64ea feat(tui): harden canonical terminal lifecycle 2026-07-19 19:02:26 -07:00
cmux-lawrence 36d5f96ff3 feat(tui): run terminals in resilient host processes 2026-07-19 18:59:10 -07:00
cmux-lawrence 9d6e0a069d feat(tui): make terminal placement canonical 2026-07-19 18:59:10 -07:00
cmux-lawrence c40073a6ca feat(tui): harden durable workspace registry 2026-07-19 18:59:10 -07:00
cmux-lawrence cabf636fd7 feat(tui): establish terminal host protocol 2026-07-19 18:59:10 -07:00
cmux-lawrence 38dc200bf5 Persist canonical workspace registry 2026-07-19 18:59:10 -07:00
Lawrence Chen acdbbcf6ae Add Zellij-style pane focus memory to cmux-tui (#8449)
* Add pane focus memory to cmux-tui

* Route TUI focus through mux pane history

* fix(tui): keep directional focus client-local

* fix(tui): preserve stack focus candidates

* fix(tui): stamp remote view switches

* refactor(tui): share focus-only pane recency

* fix(tui): isolate and prune focus history

* fix(tui): stamp implicit focus transitions

* fix(protocol): default pane focus recency

* fix(tui): serialize focus history updates

* perf(tui): index directional focus recency

* fix(java): preserve Pane constructor compatibility

* fix(tui): require shared edge overlap

* perf(tui): reconcile focus history on tree changes

* perf(tui): track pane membership from tree deltas

* refactor(tui): own pane membership revision in mux

* feat(sdk): expose pane membership revision
2026-07-19 18:18:16 -07:00
cmux reload-cloud aa74f7a061 fix(iroh): recognize canonical macOS temp paths 2026-07-19 17:49:05 -07:00
cmux reload-cloud a327baa8d9 test(iroh): expose canonical artifact path alias 2026-07-19 17:48:30 -07:00
cmux reload-cloud 192ea04c23 fix(iroh): publish artifact path before readiness scan 2026-07-19 17:34:31 -07:00
cmux reload-cloud c806bf5b99 test(iroh): expose artifact scan visibility gap 2026-07-19 17:34:01 -07:00
cmux reload-cloud 9a4352ffe4 fix(iroh): await required relay credentials on startup 2026-07-19 16:54:06 -07:00
cmux reload-cloud 159737b36c test(iroh): expose relay-only startup retry gap 2026-07-19 16:51:56 -07:00
cmux reload-cloud 45824f3265 refactor(iroh): expose initial relay readiness policy 2026-07-19 16:51:11 -07:00
cmux reload-cloud f2bbc8a050 fix(iroh): preserve same-account activation ownership 2026-07-19 16:50:54 -07:00
cmux reload-cloud 6a8dbaf8bf test(iroh): expose duplicate auth activation race 2026-07-19 16:50:32 -07:00
Austin Wangandcmux reload-cloud 9fb1b4b54c Fix stable IDs in mirror workspace CLI output (#8437)
* test(cli): cover stable mirror workspace IDs

* fix(cli): retain stable workspace inspection IDs

* test(cli): assert IDs for every workspace row

* fix(cli): preserve only workspace inspection UUIDs

* test(cli): cover non-workspace top identifiers

* fix(cli): preserve only exact workspace handles

* fix(cli): simplify exact workspace ref check

* test(cli): cover plural workspace identifiers

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 16:48:08 -07:00
cmux reload-cloud 4ba8b71402 refactor(iroh): isolate auth reconcile policy 2026-07-19 16:44:13 -07:00
cmux reload-cloud 8d585d3f07 fix(iroh): make attach readiness gate observable 2026-07-19 16:43:27 -07:00
cmux reload-cloud d0eb7fc8ed test(iroh): expose attach readiness deadline gap 2026-07-19 16:41:19 -07:00
cmux reload-cloud 52b335b502 fix(iroh): await artifact readiness before rollover 2026-07-19 16:39:00 -07:00
cmux reload-cloud 01b0b40709 test(iroh): expose artifact completion race 2026-07-19 16:27:56 -07:00
cmux reload-cloud 416a49eb9a refactor(iroh): isolate artifact gate preparation 2026-07-19 16:26:55 -07:00
cmux reload-cloud f3bd98b423 fix(iroh): forward deferred continuity evidence 2026-07-19 15:38:25 -07:00
cmux reload-cloud 0f5a38dc22 test(iroh): expose deferred continuity gap 2026-07-19 15:37:25 -07:00
cmux reload-cloud 1e416950db fix(iroh): isolate release gate RPC accessors 2026-07-19 15:05:56 -07:00
cmux reload-cloud 9b133f8144 fix(iroh): bound release gate observation 2026-07-19 14:50:56 -07:00
cmux reload-cloud 5e91dbc088 fix(iroh): observe relay expiry closure directly 2026-07-19 14:45:13 -07:00
cmux reload-cloud 81bdb30531 test(iroh): verify relay credential rollover 2026-07-19 14:21:50 -07:00
Abdulaziz Albahar ddcc37410f Merge pull request #8498 from manaflow-ai/fix-iroh-tailscale-staggered-upgrade
Preserve secure Tailscale pairings across staggered Iroh upgrades
2026-07-19 16:17:55 -05:00
cmux reload-cloud cc0b2111da Merge remote-tracking branch 'origin/main' into fix-iroh-tailscale-staggered-upgrade 2026-07-19 13:56:08 -07:00
cmux reload-cloud 2a3885cab1 fix(ios): preserve existing Tailscale pairings securely 2026-07-19 13:55:56 -07:00
Abdulaziz Albaharandcmux reload-cloud b76237be6c Dedupe Iroh selected-path diagnostics (#8497)
* test(iroh): expose duplicate selected path diagnostics

* fix(iroh): dedupe selected path diagnostics

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 15:48:02 -05:00
cmux reload-cloud df219b097e test(ios): preserve pre-Iroh Tailscale pairing 2026-07-19 13:23:23 -07:00
Abdulaziz Albaharandcmux reload-cloud 22f9e5e4d1 docs: correct Iroh relay catalog source (#8496)
Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 15:00:02 -05:00
Abdulaziz Albaharandcmux reload-cloud 9ded923159 feat(iroh): stream artifacts over peer-bound lanes (#8494)
* test(iroh): cover artifact lane ownership

* feat(iroh): stream artifacts over peer-bound lanes

* Polish artifact transport error copy

* test(iroh): preserve artifact consumer errors

* fix(iroh): preserve artifact consumer failures

* fix(iroh): make artifact file reads cancellable

* test(iroh): preserve descriptor issue failures

* fix(iroh): classify descriptor issue failures

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 14:35:47 -05:00
Abdulaziz Albaharandcmux reload-cloud 23d7185520 Fix Iroh release-gate readiness race (#8493)
* test(iroh): reproduce premature Mac readiness

* fix(iroh): await trusted Mac route after launch

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 13:16:33 -05:00
Abdulaziz Albaharandcmux reload-cloud 36af623f63 Gate provider-neutral private Iroh paths (#8492)
* test(iroh): exercise live custom private paths

* ci(iroh): gate provider-neutral private paths

* ci(iroh): harden private-path release gate

* test(iroh): distinguish private route rejection outcomes

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 13:13:05 -05:00
Abdulaziz Albaharandcmux reload-cloud b468f0661e Gate Iroh rollout on Tailscale version skew (#8490)
* ci(iroh): gate Tailscale version skew

* refactor(iroh): attach legacy policy to auth context

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 12:42:56 -05:00
Abdulaziz Albaharandcmux reload-cloud dc3cff264c Normalize production Iroh gate temporary paths (#8491)
* test(iroh): reproduce non-normalized production state path

* fix(iroh): normalize production gate temp paths

* fix(iroh): normalize root temporary directory

* test(iroh): check protected temp mode portably

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 12:04:04 -05:00
Abdulaziz Albaharandcmux reload-cloud b5060bbca7 ci(iroh): make direct-only release gate deterministic (#8489)
* test(iroh): require a deterministic direct-only gate

* ci(iroh): make direct-only gate deterministic

* fix(ci): tolerate Simulator device drift

* test(iroh): reject empty direct gate runs

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 11:56:01 -05:00
Abdulaziz Albaharandcmux reload-cloud 1b7e433ff5 Add authenticated production Iroh release gate (#8486)
* Add authenticated production Iroh release gate

* chore: annotate indirect secret getters

* fix(iroh): preserve staging gate state

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 11:32:20 -05:00
Abdulaziz Albaharandcmux reload-cloud ebf6327e06 Add authenticated custom private Iroh paths (#8487)
* feat(ios): add authenticated custom private Iroh paths

* fix(ios): bound custom private path validation

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 11:25:31 -05:00
Abdulaziz Albaharandcmux reload-cloud 725da9f8db Prove broker-bound Iroh relay round trip (#8488)
* test(iroh): prove broker-bound relay roundtrip

* test(iroh): isolate live relay runs

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 11:24:28 -05:00
Abdulaziz Albaharandcmux reload-cloud 11038e67dd ci: test Iroh in Intel iOS Simulator (#8485)
Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-19 10:46:41 -05:00
Lawrence Chenandcmux-lawrence fd6199bc39 feat(tui): add shared workspace registry protocol (#8340)
* test(web): retain sizing across attach recovery

* fix(web): preserve smallest sizing across recovery

* style(tui): satisfy detach resize lint

* fix(web): align byte fallback terminal font

* test(tui): keep input independent from sizing

* fix(tui): remove sizing from input path

* test(web): reject sizing while render stream is detached

* fix(web): gate sizing on active render stream

* test(tui): wait for builtin sidebar reload

* test(tui): wait for plugin removal before sidebar input

* fix(tui): preserve builtin sidebar focus

* test(tui): cover viewer lifecycle sizing

* fix(tui): settle resize claims before recovery

* test(tui): require settled sidebar removal

* test(tui): reproduce concurrent viewer resize race

* fix(tui): serialize shared viewer sizing

* test(tui): reproduce early mutation settlement

* fix(tui): make worker settlement authoritative

* test(tui): scale PTY waits under valgrind

* Add connected client sizing controls

* Track client sizing by visible tab

* Test self-disconnect menu lifecycle

* Fix self-disconnect client lifecycle

* Stabilize sidebar plugin lifecycle test

* Allow sidebar lifecycle tests under load

* Keep TUI verification gates deterministic

* feat(tui): add shared workspace registry identity

* feat(tui): guard workspace mutations by revision

* feat(tui): create terminals by workspace key

* build(tui): align Ghostty with browser renderer

* feat(tui): bind viewer sizes to attach streams

* build(tui): pin combined external Metal renderer

* build(tui): report pinned Ghostty revision

* build(tui): expose pinned revisions in identity

* Return exact workspace mutation revisions

* Address workspace registry review findings

* Complete workspace registry compatibility

* Index workspace registry lookups

* Complete workspace event compatibility

* Materialize empty workspace for run

* test(tui): cover failed attach announcement

* fix(tui): announce clients after attach setup

* test(tui): cover workspace move and attach cleanup

* fix(tui): preserve workspace move and attach semantics

* feat(tui): expose initial attach sizing in SDKs

* test(tui): preserve explicit empty Go argv

* fix(tui): retain explicit empty Go argv

* test(tui): cover drag and attach protocol gaps

* fix(tui): align drag and attach entrypoints

* docs(tui): mark render attach implemented

* fix(tui): preserve registry protocol invariants

* fix(tui): serialize registry close mutations

* build(tui): use supported Ghostty revision

* test(tui): cover registry and attach negotiation races

* fix(tui): negotiate attach sizing and commit lifecycle events

* test(tui): cover registry negotiation and Go state races

* fix(tui): negotiate workspace registry capability

* test(tui): cover registry selection and tree close races

* fix(tui): resync registry selection deltas

* test(tui): cover concurrent empty workspace tabs

* fix(tui): serialize empty workspace tab creation

* test(tui): cover attach probe and browser races

* fix(tui): tolerate attach probes and browser races

* test(tui): cover sized browser attach ordering

* fix(tui): synchronize sized browser attach state

* test(tui): type legacy workspace deltas

* fix(tui): align SDK release compatibility

* test(tui): cover coarse resync and selector types

* fix(tui): scope resyncs and require selectors

* test(tui): cover joined browser attach resize

* fix(tui): join pending browser attach resize

* test(tui): cover larger browser attach viewer

* fix(tui): size browser attach to effective grid

* test(tui): cover CAS lookup and staged clients

* fix(tui): commit registry and attach state atomically

* fix(tui): suppress stale empty registry events

* fix(tui): validate workspace selectors in SDKs

* fix(tui): close attach and selection lifecycle gaps

* fix(tui): resync tab selection after creation

* fix(tui): keep rejected size rollback consistent

* fix(tui): reset input viewport on browser resize

* fix(tui): serialize attach stream activation

* fix(tui): complete failed attach size rollback

* fix(tui): resync close selection lifecycle

* fix(tui): release sizing locks before rollback wait

* Fix registry review and merge integration issues

* Fix workspace move types and migration docs

* Preserve Java identify constructor compatibility

* Bound the empty workspace registry

* Reserve workspace lifecycle during targeted creation

* Scope workspace lifecycle reservations per workspace

* test(tui): cover unrelated attach during rollback repair

* Scope rollback invalidation to the affected surface

* test(tui): cover key close replacement race

* Serialize replacement closes and pending rollbacks

* test(tui): cover apply layout close race

* Bound rollback repair and reserve layout targets

* Drop timed-out rollback waiters

* test(tui): cover fragmented capability response

* Preserve fragmented identify responses

---------

Co-authored-by: cmux-lawrence <[email protected]>
2026-07-19 07:56:14 -07:00
Abdulaziz Albahar 288f1e1908 Merge pull request #8484 from manaflow-ai/feat-iroh-final-integration
Finish the production Iroh transport rollout
2026-07-19 09:11:14 -05:00
Abdulaziz Albahar 0470a973a2 Merge pull request #8482 from manaflow-ai/feat-iroh-custom-relay-e2e
test(iroh): verify custom relay round trips
2026-07-19 08:37:19 -05:00
cmux reload-cloud b63de3891c test(iroh): gate custom relay round trips 2026-07-19 06:21:41 -07:00
Abdulaziz Albahar 54a5dbad3a Merge pull request #8480 from manaflow-ai/feat-iroh-direct-ports-web
feat(iroh): publish signed direct UDP ports
2026-07-19 08:13:41 -05:00
cmux reload-cloud 89b9dd918a fix(iroh): scrub revoked direct ports 2026-07-19 06:04:02 -07:00
cmux reload-cloud 76c18a11ef test(iroh): scrub revoked direct ports 2026-07-19 06:01:28 -07:00
cmux reload-cloud 95a97198e5 Merge branch 'feat-iroh-final-integration' into feat-iroh-direct-ports-web 2026-07-19 05:54:22 -07:00
Abdulaziz Albahar 61c5958735 feat(ios): add live Iroh debug transport modes (#8479)
Verified package-native iOS runtime and settings tests. Merges into the Iroh integration branch.
2026-07-19 07:53:18 -05:00
cmux reload-cloud 9f8ea070b7 fix(iroh): publish authoritative private path ports 2026-07-19 05:51:50 -07:00
cmux reload-cloud e5c66cf938 feat(iroh): publish signed direct UDP ports 2026-07-19 05:45:43 -07:00
cmux reload-cloud 76623144ad test(iroh): require signed direct UDP ports 2026-07-19 05:42:08 -07:00
cmux reload-cloud a12b2684f8 test(iroh): cover divergent private path ports 2026-07-19 05:38:13 -07:00
cmux reload-cloud bdb9fa4681 Merge branch 'feat-iroh-final-integration' into feat-iroh-direct-ports-web 2026-07-19 05:36:51 -07:00
cmux reload-cloud 654b3f0a09 feat(ios): add live Iroh debug transport modes 2026-07-19 05:34:25 -07:00
cmux reload-cloud 84adb5e66b test(ios): cover live Iroh debug transport modes 2026-07-19 05:17:49 -07:00
cmux reload-cloud e9ecb61e8b Merge remote-tracking branch 'origin/feat-iroh-final-integration' into feat-ios-iroh-debug-modes 2026-07-19 05:11:26 -07:00
cmux reload-cloud 55227956d5 fix(iroh): pin deferred bootstrap path core 2026-07-19 05:07:11 -07:00
Lawrence Chenandcmux-lawrence 41756f7285 feat(tui): support browser terminal backends (#8294)
* fix(tui): restore cursor after VT replay

* docs: record manual mirror Ghostty candidate

* feat(tui): expose packaged build commit

* feat(tui): stamp Ghostty build revision

* feat(tui): report Ghostty artifact revision

* test(tui): cover multi-client resize arbitration

* build: advance Ghostty for browser backend

* fix(tui): preserve frontend terminal palettes

* fix(tui): use reviewed Ghostty main pin

* fix(tui): bound palette change frames

* fix(tui): expose artifact revisions in SDKs

* fix(tui): ignore empty build stamps

* fix(tui): type palette attach metadata

* fix(tui): track authored palette overrides

* fix(tui): preserve authored palette in browser frontend

* fix(tui): reconcile authoritative palette state

* fix(tui): preserve resize and kitty palette metadata

* fix(tui): preserve v6 replay palette compatibility

* fix(tui): preserve protocol and build compatibility

* fix(tui): commit palette state at OSC exit

* fix(tui): align OSC tracking and Rust compatibility

* fix(tui): preserve Go identify compatibility

* perf(tui): make palette tracking bounded and lazy

* fix(tui): query attach colors without consuming damage

* fix(tui): mirror control aborts and Kitty limits

* fix(tui): commit palette on OSC cancellation

* fix(tui): stream authoritative Kitty color changes

* fix(tui): preserve replay cursor authority

* fix(tui): snapshot cursor from owning render state

* perf(tui): keep palette streaming lightweight

* fix(tui): align snapshots with canonical protocol

* fix(tui): preserve palette overrides across RIS

* fix(tui): reapply palette after terminal reset

* test(tui): satisfy current clippy

* build(tui): stamp packaged Ghostty source exactly

* fix(tui): match Ghostty palette index grammar

* fix(tui): coalesce live palette snapshots

* fix(tui): preserve palette reapply after reset

* test(tui): isolate cursor control-string exits

* fix(tui): match Ghostty C1 palette parsing

* fix(tui): preserve xterm color restore defaults

* fix(tui): bound palette background synchronization

---------

Co-authored-by: cmux-lawrence <[email protected]>
2026-07-19 04:58:23 -07:00
cmux reload-cloud cb3e56330c fix(iroh): bind relay credentials to active endpoints 2026-07-19 04:57:23 -07:00
cmux reload-cloud 38f00ec688 test(iroh): require an active binding for relay credentials 2026-07-19 04:55:14 -07:00
cmux reload-cloud 1e009f1998 Merge remote-tracking branch 'origin/main' into feat-iroh-final-integration 2026-07-19 04:20:18 -07:00
cmux reload-cloud d035808a99 fix: support fork resume gate on Xcode 16.2 2026-07-19 04:18:53 -07:00
cmux reload-cloud 587ac8c585 ci: allow compatibility matrix to finish 2026-07-19 04:18:53 -07:00
cmux reload-cloud b8034a558c ci: disable indexing in compatibility smoke builds 2026-07-19 04:18:53 -07:00
cmux reload-cloud eb97e6da27 fix: finish Xcode 16.2 nominal compatibility 2026-07-19 04:18:53 -07:00
cmux reload-cloud 98b6217718 fix: keep macOS sources compatible with Xcode 16.2 2026-07-19 04:18:53 -07:00
cmux reload-cloud 0a4e17211e fix(iroh): make release gate single flight 2026-07-19 04:16:03 -07:00
cmux reload-cloud 2f232930c2 test(iroh): cover release gate single flight 2026-07-19 04:16:03 -07:00
Lawrence Chenandcmux-lawrence ba406073f3 Match cmux-tui Alt+N pane distribution to Zellij (#8297)
* feat(cmux-tui): add stable split ids

* test(cmux-tui): gate protocol 7 split e2e

* perf(cmux-tui): reuse split containment checks

* test(tui): capture Zellij Alt-N layout

* fix(tui): match Zellij Alt-N pane layout

* fix(tui): match Zellij stacked pane overflow

* fix(tui): model Zellij stacks explicitly

* fix(tui): complete protocol 8 stack support

* fix(tui): preserve stack focus across clients

* fix(tui): make active pane own stack expansion

* fix(tui): close Alt-N lifecycle gaps

* fix(tui): close stack review gaps

* fix(tui): preserve manual split ratios

* fix(tui): complete remote pane routing

* fix(tui): keep nearby stack headers visible

* test(tui): require protocol boundary for split ids

* fix(tui): version stable split ids as protocol 8

* fix(tui): version stack layouts as protocol 9

* test(tui): expect protocol 8 from cli

* test(tui): accept protocol 8 in Go e2e

* test(tui): accept protocol 9 in Go e2e

* docs(tui): describe v8 split id boundary

* test(tui): require exact web split resizing

* fix(tui): resize web dividers by split id

* test(tui): reject v8 commands on protocol 7

* fix(tui): guard split resize on protocol 8

* test(tui): require protocol 8 web split targeting

* fix(tui): target web split updates by protocol ID

* docs(tui): align web frontend with protocol 8

* docs(tui): align web frontend with protocol 9

* test(tui): cover protocol 9 new-pane guards

* test(tui): require pane delta for new-pane

* fix(tui): emit pane delta for new-pane

* test(tui): expect protocol 8 in smoke test

* fix(tui): bind new-pane screen before delta build

* docs(tui): align protocol 9 examples

* test: cover collapsed browser graphics

* fix: hide collapsed browser graphics

* fix(tui): restack protocol 9 on indexed mux

* test(tui): expect protocol 9 in web mismatch copy

* test(tui): cover canonical stacked layout access

* fix(tui): preserve stacked pane access

* test(tui): align Alt+N stacked geometry

* test(tui): cover leading pane stack behavior

* test(tui): preserve stack expansion outside focus

* fix(tui): retain expanded stack member

* test(tui): cover stack swap and focus invalidation

* fix(tui): keep stack focus updates coherent

* test(tui-web): reserve stack terminal viewport

* fix(tui-web): keep expanded stack pane visible

* test(tui): keep stack neighbors and state isolated

* fix(tui): preserve constrained stack navigation

* test(tui-web): transfer stack header focus

* fix(tui-web): hand stack focus to terminal input

* test(tui): cover stack apply and collapsed split

* fix(tui): make stack layouts fully reusable

* test(tui): cover move-tab events and stack indexing

* fix(tui): bound stack refresh work

* test(tui): reject invisible auto-layout pane

* fix(tui): refuse invisible auto-layout panes

* test(tui): preserve unrelated singleton stacks

* fix(tui): preserve unrelated singleton stacks

---------

Co-authored-by: cmux-lawrence <[email protected]>
2026-07-19 03:25:48 -07:00
cmux reload-cloud 417187f67c chore(iroh): record release-gate path class 2026-07-19 03:23:47 -07:00
cmux reload-cloud af21ec02be fix(iroh): wait for encrypted release-gate route 2026-07-19 03:17:27 -07:00
cmux reload-cloud 6c55ab3991 test(iroh): allow bounded release-gate route startup 2026-07-19 03:16:47 -07:00
cmux reload-cloud b9f19ed32a chore(iroh): mark release-gate probe stages 2026-07-19 03:16:13 -07:00
Austin WangandClaude Fable 5 14e3400b95 Bump version to 0.64.20 (#8473)
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-19 03:06:55 -07:00
cmux reload-cloud 97fd372ffe test(iroh): correlate remote session closure 2026-07-19 02:58:55 -07:00
cmux reload-cloud f95a235dad fix(iroh): decode release-gate chat wire dates 2026-07-19 02:58:48 -07:00
cmux reload-cloud d63457edaf test(iroh): cover chat wire dates in release gate 2026-07-19 02:58:13 -07:00
cmux reload-cloud 9cad228bd6 Merge Iroh session flap diagnostics 2026-07-19 02:35:44 -07:00
cmux reload-cloud d0fe1321d4 Merge remote-tracking branch 'origin/feat-iroh-final-integration' into feat-iroh-session-flap-diagnostics 2026-07-19 02:28:08 -07:00
cmux reload-cloud bb84397d2f feat(iroh): identify session pool closures 2026-07-19 02:28:01 -07:00
cmux reload-cloud 8f3e07ec90 Clean stale tagged release gate sockets 2026-07-19 02:21:12 -07:00
cmux reload-cloud 520dfa3275 Expand Iroh release gate RPC coverage 2026-07-19 02:21:12 -07:00
cmux reload-cloud 6f7e3a10e6 test(iroh): gate Tailscale version skew 2026-07-19 02:16:40 -07:00
cmux reload-cloud 303ecb8f07 test(iroh): expose session path flap owner 2026-07-19 02:15:50 -07:00
Austin Wang 84519d1ad6 Fix AppKit sidebar agent status labels (#8450)
* test: cover sidebar agent status presentation

* fix: share sidebar status text presentation

* chore: clarify sidebar presentation API access

* test: import sidebar status model
2026-07-19 02:05:48 -07:00
cmux reload-cloud 694da34ceb Compile isolated iOS release gate support 2026-07-19 02:05:21 -07:00
cmux reload-cloud 5e4b9efbbc Merge Iroh release-gate readiness hardening 2026-07-19 01:50:26 -07:00
cmux reload-cloud 978e385e43 Harden Iroh release gate readiness 2026-07-19 01:49:42 -07:00
cmux reload-cloud c9dc3e42f1 fix(iroh): redact unexpected relay policy errors 2026-07-19 01:46:15 -07:00
cmux reload-cloud 32a2181cca test(iroh): prove relay error log redaction 2026-07-19 01:45:28 -07:00
cmux reload-cloud 91047ed50b Merge causal Iroh deadline verification 2026-07-19 01:42:38 -07:00
cmux reload-cloud d5146748df test(iroh): synchronize admission deadline cancellation 2026-07-19 01:41:39 -07:00
Austin Wang 1328104466 CLI: allow cmux ssh to run an initial remote command (#8439)
* Add initial command to cmux ssh

* Test initial command with fallback shells

* Run initial command portably for fallback shells

* Test private remote command staging

* Stage remote initial command privately

* Test initial command generations sharing a relay port

* Key initial command state by launch generation

* Test concurrent initial command staging

* Make initial command staging race-safe

* Test fallback command shell state

* Preserve fallback shell command state

* Test unknown remote shell fallback

* Retain unknown remote shell fallback

* Test exec initial command cleanup

* Run initial command without decoded files

* Test unknown shell interactive command state

* Run fallback commands in interactive shells

* Test Nushell initial command startup

* Run Nushell initial commands interactively

* Verify Nushell enters its initial REPL

* Document Nushell execute contract

* Make mobile liveness tests deterministic

* Test remote initial command failure recovery

* Retry remote initial commands safely

* Test private remote command retry staging

* Stage remote initial commands privately

* Update SSH bootstrap metadata assertion

* Fix Swift Testing diagnostic comments

* Test zsh initial command login ordering

* Run zsh initial command after login setup
2026-07-19 01:41:24 -07:00
Austin Wang 3c83a107ac Fix sidebar GitHub polling lifecycle regressions (#8226)
* Refactor sidebar git metadata activity state

* Test sidebar polling regression boundaries

* Fix sidebar polling lifecycle regressions

* Test process-wide sidebar GitHub coordination

* Test hidden pull request metadata retention

* Preserve passive pull request metadata when hidden

* Test hidden pull request deferral gating

* Gate pull request deferrals by visibility

* Clarify sidebar polling ownership

* Split passive sidebar metadata tests

* Test coalesced GitHub waiter cancellation

* Return canceled GitHub waiters promptly
2026-07-19 01:41:14 -07:00
cmux reload-cloud 9de7e75385 Merge secure custom Iroh relay verification 2026-07-19 01:39:20 -07:00
cmux reload-cloud c441770935 Merge latest main into Iroh integration 2026-07-19 01:38:25 -07:00
cmux reload-cloud 3728577ff1 fix(iroh): harden custom relay resolution 2026-07-19 01:37:59 -07:00
cmux reload-cloud 42e9c4e1e2 Merge deterministic Iroh reconnect verification 2026-07-19 01:35:34 -07:00
cmux reload-cloud f9e6858ab3 test(iroh): await superseded reconnect closure 2026-07-19 01:34:43 -07:00
cmux reload-cloud 74444c03bb test(iroh): prove custom relay safety regressions 2026-07-19 01:30:59 -07:00
cmux reload-cloud e788688c83 docs(iroh): record production relay safeguards 2026-07-19 01:28:54 -07:00
cmux reload-cloud d5f72bb01f Merge Iroh compatibility indexer mitigation 2026-07-19 01:20:12 -07:00
cmux reload-cloud a7f861b276 ci: disable indexing in compatibility tests 2026-07-19 01:19:18 -07:00
cmux reload-cloud 0d356335d6 Merge remote-tracking branch 'origin/main' into feat-iroh-release-gate 2026-07-19 01:16:26 -07:00
cmux reload-cloud d189a09c65 Merge deterministic Iroh capacity verification 2026-07-19 01:06:00 -07:00
cmux reload-cloud e4b8d21542 test(iroh): await admission before capacity assertions 2026-07-19 01:05:03 -07:00
cmux reload-cloud fe6af50907 Merge Iroh discovery diagnostics 2026-07-19 00:56:09 -07:00
cmux reload-cloud d884ef3ca6 fix(iroh): classify live discovery refresh failures 2026-07-19 00:45:14 -07:00
Lawrence Chenandcmux-lawrence af987b6a07 Add stable cmux-tui split IDs (#8023)
* feat(cmux-tui): add stable split ids

* test(cmux-tui): gate protocol 7 split e2e

* perf(cmux-tui): reuse split containment checks

* test(tui): require protocol boundary for split ids

* fix(tui): version stable split ids as protocol 8

* test(tui): expect protocol 8 from cli

* test(tui): accept protocol 8 in Go e2e

* docs(tui): describe v8 split id boundary

* test(tui): reject v8 commands on protocol 7

* fix(tui): guard split resize on protocol 8

* test(tui): require protocol 8 web split targeting

* fix(tui): target web split updates by protocol ID

* docs(tui): align web frontend with protocol 8

* test(tui): expect protocol 8 in smoke test

* docs: align protocol examples with v8

* test: cover additive protocol compatibility

* fix: preserve protocol-v8 client compatibility

* test: cover protocol and split failure regressions

* fix: report protocol and split failures accurately

* test: cover split event and keyboard semantics

* fix: expose one accessible split update path

* test: cover split ownership and future attach

* fix: index splits and preserve forward compatibility

* fix: update split ownership atomically

* chore: align SDK versions at 0.2.0

* test: cover repeated split keyboard resizing

* fix: queue repeated split keyboard resizing

* fix: coalesce split keyboard resizing

* fix: cancel stale split keyboard resizing

* test: cover typed layout change events

* fix: type layout change events in SDKs

* fix: retire settled split keyboard state

* test: cover split resize reconciliation costs

* fix: scope split resize reconciliation work

* test: cover bounded split hot paths

* fix: bound split mutation hot paths

* fix: debounce split keyboard commits

---------

Co-authored-by: cmux-lawrence <[email protected]>
2026-07-19 00:44:47 -07:00
cmux reload-cloud fca06a74c3 Merge remote-tracking branch 'origin/feat-iroh-release-gate' into feat-iroh-final-integration 2026-07-19 00:33:25 -07:00
cmux reload-cloud 5c0566b72b Merge remote-tracking branch 'origin/feat-iroh-device-id-canonical' into feat-iroh-final-integration 2026-07-19 00:33:01 -07:00
cmux reload-cloud 17232c7b4c Merge remote-tracking branch 'origin/feat-iroh-tailscale-skew' into feat-iroh-final-integration 2026-07-19 00:32:32 -07:00
cmux reload-cloud 763766876c Merge remote-tracking branch 'origin/feat-iroh-diag-owner' into feat-iroh-final-integration 2026-07-19 00:32:30 -07:00
cmux reload-cloud cbeef82434 Merge remote-tracking branch 'origin/feat-iroh-server-hardening' into feat-iroh-final-integration 2026-07-19 00:32:30 -07:00
cmux reload-cloud b17b77939c Merge remote-tracking branch 'origin/feat-iroh-session-owner' into feat-iroh-final-integration 2026-07-19 00:32:27 -07:00
cmux reload-cloud e8fc4241d9 test(iroh): expose discovery failure classification gap 2026-07-19 00:25:21 -07:00
cmux reload-cloud 0ae89fd7fe Add isolated Iroh release gate 2026-07-19 00:22:49 -07:00
cmux reload-cloud 0575f04392 fix: canonicalize paired Mac UUID device IDs 2026-07-19 00:14:28 -07:00
cmux reload-cloud 2be2f03925 docs(ios): state production diagnostic ownership 2026-07-19 00:02:35 -07:00
cmux reload-cloud a05a0ccb32 test(mobile): make Tailscale flavor coverage deterministic 2026-07-18 23:59:57 -07:00
cmux reload-cloud 6f9cf8f7b3 test(mobile): pin legacy nightly disable 2026-07-18 23:57:25 -07:00
cmux reload-cloud 5316ef9c79 fix(iroh): publish only established selected paths 2026-07-18 23:54:07 -07:00
cmux reload-cloud b8f1b33ab5 test(iroh): reject premature path publication 2026-07-18 23:52:28 -07:00
cmux reload-cloud 93c88c81af fix(iroh): close relay hardening review gaps 2026-07-18 23:50:08 -07:00
cmux reload-cloud 77b53f45e1 test(iroh): pin relay hardening review gaps 2026-07-18 23:48:16 -07:00
cmux reload-cloud d66c3e3e4f test: cover paired Mac UUID canonicalization 2026-07-18 23:40:26 -07:00
Lawrence Chen ecebdbb64b cmux-tui: TUI dead-band dimming for foreign-sized surfaces; move CmuxLite out of tree (#8305)
* 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
2026-07-18 23:29:47 -07:00
cmux reload-cloud 3d56654b24 fix: preserve nightly Tailscale compatibility ingress 2026-07-18 23:21:21 -07:00
cmux reload-cloud 9a688df060 test: cover nightly Tailscale listener migration 2026-07-18 23:18:46 -07:00
cmux reload-cloud 0275fe5602 fix(iroh): harden relay server activation 2026-07-18 23:07:12 -07:00
cmux reload-cloud 1dce7aaa6f test(iroh): cover relay server hardening gaps 2026-07-18 22:57:49 -07:00
cmux reload-cloud e0333db04d fix(iroh): anchor production connection diagnostics 2026-07-18 22:46:32 -07:00
cmux reload-cloud 02dfe7946c refactor(iroh): own admitted connection lifetime 2026-07-18 22:44:18 -07:00
cmux reload-cloud 79c9eaf2de test(iroh): characterize admitted connection lifetime 2026-07-18 22:43:12 -07:00
cmux reload-cloud 3a40c7eebb test(iroh): pin foreground path diagnostics 2026-07-18 22:38:09 -07:00
Lawrence Chen f0d3c74e54 Match nested branches in Vercel deploy filter (#8419)
* Test Vercel ephemeral branch filtering

* Match nested branches in Vercel deploy filter

* Harden Vercel deployment rule coverage

* Assert resolved Vercel branch rules
2026-07-18 21:35:44 -07:00
3c19e2623d Regenerate stale webviews diffSurface chunk (#8416)
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]>
2026-07-18 21:29:57 -07:00
Austin Wang e35b74407a Default AppKit sidebar on and enforce remote flag precedence (#8433)
* test: cover remote feature flag precedence

* test: reject overrides for remote-controlled flags

* feat: enforce remote feature flag precedence

* refactor: split feature flag source type
2026-07-18 19:50:53 -07:00
Austin Wangandcmux reload-cloud 6849b9351c Fix AppKit sidebar settings fidelity (#8432)
* test: cover shared sidebar settings fidelity

* fix: share sidebar row settings derivation

* test: require legacy layout to stack details

* fix: honor legacy stacked sidebar layout

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-18 15:01:26 -07:00
Abdulaziz Albaharandcmux reload-cloud 533e27d50b Fix iOS workspace resets during transient Iroh recovery (#8424)
* test(ios): reproduce reconnect navigation and liveness churn

* fix(ios): preserve workspaces through transient reconnects

* fix(ios): report liveness probe count accurately

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-18 11:54:42 -05:00
d795e2ccf0 Lawrence Sidebar round 3: colleague dogfood fixes (#8415)
* 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]>
2026-07-18 06:10:56 -05:00
Austin Wang 2c079676f9 Clear SSH auth marker after successful startup (#8410)
* test: cover SSH auth marker cleanup

* fix: clear SSH auth marker after success

* test: cover restored SSH auth marker cleanup

* fix: share SSH auth marker cleanup

* test: drain SSH auth test stderr safely
2026-07-18 01:11:28 -07:00
Austin Wangandcmux reload-cloud f28fbde2f4 Fix new-surface targeting remote tmux panes (#8403)
* Test remote pane surface creation routing

* Route new surfaces to remote tmux windows

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-17 23:39:53 -07:00
Austin Wang c1eb681888 Share native SSH connections per host (#8308)
* test: native SSH workspaces share a host control path (#8300)

* fix: share native SSH connections per host (#8300)

* fix: harden native SSH sharing lifecycle (#8300)

* fix: require resolved SSH master ownership keys (#8300)

* fix: coordinate SSH auth and master cleanup (#8300)

* refactor: align SSH broker tests with Swift policy (#8300)

* test: wire merged workspace todo regression coverage

* fix: harden native SSH broker lifecycle

* test: cover burst SSH background priming

* fix: keep SSH background priming stable through bursts

* test: cover deferred SSH master cleanup

* fix: retry deferred SSH master cleanup

* test: fix browser design mode test compilation

* test: cover multiplexed SSH foreground auth followers

* fix: signal multiplexed SSH auth followers locally
2026-07-17 23:39:45 -07:00
Lawrence Chen 7c6d5edffe Use 12-vCPU runner for Nightly app builds (#8408)
* test: require faster Nightly build capacity

* ci: use 12-vCPU runner for Nightly app builds
2026-07-17 23:10:57 -07:00
Lawrence Chen 404b71bc5b ci: apply proven Nightly speedups (#8401)
* test: require proven Nightly speedups

* ci: apply proven Nightly speedups

* test: execute Nightly notarization behavior

* ci: test Nightly notarization behavior

* test: reproduce transient busy DMG detach

* ci: retry transient busy DMG detach

* test: follow Nightly notarization helper

* test: assert delivered DMG metadata checks

* ci: route IndexNow through configured runner

* ci: repair portal test after signature change

* ci: make nightly detach cleanup deterministic

* test: remove meaningless duration assertions
2026-07-17 22:40:29 -07:00
Lawrence Chen 38d342f47d Exclude production crons from docs channels (#8407)
* test: guard docs deployments from production crons

* fix: exclude production crons from docs channels

* test: require docs config at Vercel project root

* fix: install docs config at Vercel project root

* test: prevent docs Vercel config drift
2026-07-17 22:37:21 -07:00
Austin Wang 48746e24b8 Fix remaining ssh-tmux GA lifecycle and control blockers (#8405)
* test: cover explicit ssh-tmux detach window lifecycle

* fix: close dedicated ssh-tmux window on detach

* test: cover background ssh-tmux split focus contract

* fix: preserve ssh-tmux focus for background splits

* test: cover advertised ssh-tmux surface reorder

* fix: resolve advertised ssh-tmux surfaces for reorder

* test: cover explicit detach with keep-open intent

* fix: make explicit ssh-tmux detach authoritative

* test: reject recoverable route after ssh-tmux detach

* fix: retire closed ssh-tmux window route

* refactor: give ssh-tmux lifecycle contracts dedicated files

* test: assert closed ssh-tmux window lifecycle

* test: preserve window after remote tmux session end

* fix: preserve window after remote tmux session end
2026-07-17 22:23:41 -07:00
Austin Wangandcmux reload-cloud a0b9c95834 Fix rename-tab resolution for multi-pane tmux mirrors (#8404)
* Add failing multi-pane mirror rename regression

* Resolve mirrored pane surfaces to their tmux window tab

* Preserve mirrored pane identity in tab actions

* Fail closed on unresolved mirrored tab targets

* Honor routed panes in mirrored tab actions

* Scope mirrored tab resolution to title actions

* Keep mirrored tab resolution rename-only

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-17 22:23:00 -07:00
Austin Wangandcmux reload-cloud b162e8b3cc Refine design mode annotations and context pill removal (#8393)
* test(browser): cover design annotation follow-ups

* feat(browser): refine design mode annotations

* test(browser): cover stalled annotation card refresh

* fix(browser): reconcile captured annotation card immediately

* test(browser): cover stale annotation lifecycle events

* fix(browser): restore design-mode region targeting

* test(browser): cover synchronous copy overlay restoration

* fix(browser): prevent design-mode copy flicker

* test(browser): keep design mode runtime ES-compatible

* test(browser): cover bounded annotation and mixed deletion

* fix(browser): bound annotation cards and mixed deletion

* test(browser): cover live annotation screenshot retention

* fix(browser): retain live annotation screenshots

* test(browser): cover annotation mode-switch invalidation

* fix(browser): invalidate capture when leaving draw mode

* test(browser): preserve mode when runtime switch fails

* fix(browser): commit mode after runtime acknowledgement

* test(browser): cover constant-time token hit testing

* fix(browser): hit-test hovered token directly

* test(browser): prune screenshots when live context releases

* fix(browser): prune released annotation screenshots

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-17 22:03:11 -07:00
Austin Wangandcmux reload-cloud 82a38532fb Make update pill installs causal and fail visibly (#8375)
* Add failing updater lifecycle regressions (#8368)

* Make updater install lifecycle causal (#8368)

* Handle aborted manual update cycles (#8368)

* Split updater lifecycle types by ownership

* Address updater lifecycle review findings

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-17 22:02:54 -07:00
e978baebcc Lawrence Sidebar round 2: reactivity, resize settle, selection coalescing, behavior tests (#8390)
* 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]>
2026-07-17 23:48:36 -05:00
Austin Wang 1c153a9704 Fix ssh-tmux new-window no-focus routing (#8402)
* test: preserve ssh-tmux no-focus identify context

* fix: preserve socket routing across background windows
2026-07-17 21:43:47 -07:00
Abdulaziz Albaharandcmux reload-cloud a5e5deb2e6 feat(iroh): add privacy-safe connection diagnostics (#8398)
* feat(iroh): add privacy-safe connection diagnostics

* fix(iroh): harden diagnostic lifecycle ordering

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-17 23:21:03 -05:00
Lawrence Chen 3112484c43 cmux-tui: preserve smallest sizing through web reattach (#8302)
* test(web): retain sizing across attach recovery

* fix(web): preserve smallest sizing across recovery

* style(tui): satisfy detach resize lint

* fix(web): align byte fallback terminal font

* test(tui): keep input independent from sizing

* fix(tui): remove sizing from input path

* test(web): reject sizing while render stream is detached

* fix(web): gate sizing on active render stream

* test(tui): wait for builtin sidebar reload

* test(tui): wait for plugin removal before sidebar input

* fix(tui): preserve builtin sidebar focus

* test(tui): cover viewer lifecycle sizing

* fix(tui): settle resize claims before recovery

* test(tui): require settled sidebar removal

* test(tui): reproduce concurrent viewer resize race

* fix(tui): serialize shared viewer sizing

* test(tui): reproduce early mutation settlement

* fix(tui): make worker settlement authoritative

* test(tui): scale PTY waits under valgrind

* Add connected client sizing controls

* Track client sizing by visible tab

* Test self-disconnect menu lifecycle

* Fix self-disconnect client lifecycle

* Stabilize sidebar plugin lifecycle test

* Allow sidebar lifecycle tests under load

* Keep TUI verification gates deterministic

* Allow web resize recovery test under load

* Update web client sizing fixtures

* Test tmux ignore-size fallback semantics

* Match tmux ignore-size fallback semantics

* Test remaining tmux sizing lifecycle edges

* Close tmux sizing lifecycle gaps

* Test tmux sizing bounds and fallback labels

* Clamp and label tmux shared sizes correctly

* Apply client sizing modes atomically

* Add per-pane client controls to web frontend

* Test unattached resize against shared minimum

* Route all resizes through shared client sizing

* Settle exclusive sizing through disconnect races

* Reject stale local sizing targets

* Separate explicit and viewer-derived size defaults

* Keep nested client menus inside viewport

* fix(tui): harden multi-client sizing lifecycle

* fix(tui): close multi-client review gaps

* fix(tui): harden nested menu recovery

* fix(tui): refresh clients after remote recovery

* fix(tui): coalesce remote subscription recovery

* fix(tui): linearize subscription recovery
2026-07-17 20:58:55 -07:00
Austin Wangandcmux reload-cloud 9687274586 Fix Files panel contrast across appearances (#8290)
* test: cover file explorer palette contrast

* fix: make file explorer palettes appearance-aware

* test: cover actual file explorer selection fills

* fix: preserve native Finder file icons

* fix: enforce Finder icon contrast

* test: clarify icon provider reuse checks

* test: cover icon tints across row states

* test: cover actual file row backgrounds

* fix: meet file contrast on material backgrounds

* test: cover final material row states

* fix: meet final file row contrast

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-07-17 19:45:51 -07:00
63ee64f1bd Recover timed-out Iroh lanes and stale iOS sessions (#8286)
* Test physical pairing rejects untrusted routes

* Fail closed on untrusted phone pairing tickets

* Rotate staging relay policy verification key

* Test local relay signer fallback

* Recover redacted local relay signer

* Test shared dev relay backend override

* Share trusted dev backend across Mac and iOS

* Test forty concurrent development bindings

* Scale and recycle development Iroh bindings

* test(ios): preserve tagged pairings on one Mac

* fix(ios): keep tagged pairings on one Mac

* test(ios): identify paired Mac app instances

* fix(ios): identify paired Mac app instances

* Test development Iroh challenge quota

* Scale development Iroh challenge quotas

* test(ios): reject physical reconnect to loopback

* fix(ios): reject loopback reconnect on physical devices

* test(ios): cover high-concurrency pairing routes

* test(presence): recycle inactive iOS build scopes

* fix(ios): enforce app-instance pairing identity

* test(iroh): cover lane timeout recovery

* test(iroh): preserve replacement after stale owner release

* fix(iroh): redial timed-out application lanes

* test(ios): cover stale Iroh shell redial

* fix(ios): reconnect stale Iroh shell sessions

* test(iroh): cover per-instance firewall partitions

* fix(iroh): partition registration firewall by app identity

* test(iroh): keep invalid identities account-scoped

* test(iroh): cover early direct address observation

* fix(iroh): replay early observed address changes

* test(ios): cover team-scope reconnect restart

* fix(ios): restart reconnect after team scope settles

* test(ios): revoke exact secondary instance in races

* test(iroh): isolate challenge quota by app instance

* test(ios): cover Iroh recovery ownership

* fix(iroh): scope challenge quota to app identity

* fix(ios): serialize Iroh connection recovery

* test(ios): cover stale connected manual reconnect

* fix(ios): redial stale connected clients

* test(iroh): cover expanded development binding quota

* test(ios): reproduce stalled RPC write connection loss

* fix(ios): recover stalled mobile RPC writes

* test(iroh): cover pairing preflight release blockers

* fix(iroh): close pairing deployment gates

* test(iroh): cover typed attach outcomes

* fix(iroh): distinguish permanent attach outcomes

* test(ios): reproduce duplicate startup Iroh owner

* fix(ios): serialize startup Iroh connection ownership

* test(ios): require same-account Iroh discovery

* feat(ios): connect same-account Macs over Iroh

* test(ios): reproduce half-installed RPC connection

* fix(ios): publish RPC connection state atomically

* test(ios): preserve legacy session across path changes

* fix(ios): preserve connection recovery invariants

* test(ios): make Iroh recovery checks deterministic

* test: cover Iroh discovery lifecycle races

* fix: close Iroh discovery lifecycle races

* test(ios): cover duplicate Iroh auth observation

* test(ios): reject pairing persistence failures

* test(ios): cover Iroh startup ownership races

* test(ios): measure only the recovery reconnect

* fix(ios): stabilize zero-touch Iroh startup

* test(ios): isolate paired Mac persistence hint

* test(iroh): reproduce pair-grant retry storm

* fix(iroh): honor pair-grant retry authority

* feat(core): expose retry-after error contract

* test(ios): reproduce zero-touch retry storm

* fix(ios): coalesce broker-directed reconnects

* test(iroh): cover empty routes and transient backoff

* fix(iroh): bound addressless reconnects

* test(iroh): stabilize runtime verification

* test(iroh): drive authenticated presence recovery

* test(iroh): expose sign-out recovery leak

* fix(iroh): cancel recovery on sign-out

* test(iroh): give session fixtures a public path

* test(iroh): expose sidecar-blocked host publication

* test(iroh): require signed-in host activation

* fix(iroh): publish host before optional sidecars

* test(iroh): reproduce dev route readiness races

* fix(iroh): wait for tagged endpoint publication

* test(iroh): reproduce stale compatibility QR

* fix(iroh): upgrade compatibility QR after publication

* test(ios): reject silent unpaired reload fallback

* fix(ios): fail closed when dev pairing setup fails

* test(ios): reproduce cross-lane QR fallback

* fix(ios): isolate tagged Iroh QR fallback

* test(ios): reproduce cross-agent Iroh discovery

* fix(ios): isolate zero-touch Iroh by dev tag

* test(iroh): reproduce tagged broker origin drift

* test(iroh): reject malformed dev broker origins

* fix(iroh): share trusted broker across dev lanes

* test(ios): enforce discovery build compatibility

* fix(ios): apply one build policy to Iroh discovery

* test(ios): require owned late transport cleanup

* fix(ios): own late transport cleanup lifecycle

* test(iroh): require lifecycle-owned readiness

* test(ios): import lifecycle test data types

* fix(iroh): signal lifecycle connection readiness

* test(iroh): require relay-ready host republication

* fix(iroh): republish routes after relay commit

* test(iroh): reject redundant relay republication

* fix(iroh): publish only changed relay routes

* test(iroh): reproduce foreground refresh teardown race

* fix(iroh): serialize foreground registration recovery

* test(ios): reproduce stale Iroh zero-touch ambiguity

* fix(ios): ignore unreachable stale Iroh bindings

* test(ios): reproduce zero-touch UUID case disconnect

* fix(ios): canonicalize zero-touch Mac identity checks

* test(ios): cover physical dev service origins

* fix(ios): use staging origins on physical dev builds

* test(ios): cover foreground relay credential recovery

* test(auth): require foreground validation callers to join

* test(iroh): reproduce same-peer control handoff race

* test(iroh): reproduce stale admission snapshot denial

* fix(iroh): refresh stale admission policy before denial

* fix(iroh): serialize same-peer control handoff

* fix(auth): join foreground session validation

* fix(ios): refresh relay credentials on foreground

* test(ios): reproduce duplicate Iroh endpoint thrash

* fix(ios): prevent duplicate Iroh endpoint ownership

* test(iroh): reproduce idle liveness lane renegotiation

* fix(iroh): keep idle liveness off optional lane setup

---------

Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: cmux-lawrence <[email protected]>
Co-authored-by: austinpower1258 <[email protected]>
2026-07-17 20:11:44 -05:00
Lawrence Chen c8138f4388 Harden docs deployment authentication (#8347)
* test: guard docs deployment authentication

* ci: harden Vercel deployment authentication

* ci: keep feature flag keys in the registry

* test: make fork probe teardown deterministic

* ci: keep Vercel tokens out of process arguments

* test: evaluate termination gate mutations before assertions

* test: satisfy Xcode 26.3 concurrency checks

* ci: clear Xcode 26.3 warning blockers

* ci: remove unused fork probe bindings

* test: reproduce Swift suite CI hang

* ci: bound and retry Swift Testing suites

* test: keep suite timeout guard deterministic
2026-07-17 17:47:03 -07:00
Lawrence Chen 5d7ffc649b Fix authenticated IndexNow deployment trigger (#8384)
* fix(ci): dispatch authenticated IndexNow submission

* chore(ci): harden IndexNow dispatch metadata

* fix(ci): pass dispatch ref through environment
2026-07-17 16:25:27 -07:00
Lawrence Chen 97c737e61f Expand search and answer-engine discovery (#8339)
* test(web): cover crawler discovery regressions

* feat(web): expand search and answer discovery

* fix(web): localize RSS discovery labels

* feat(web): localize blog discovery feeds

* fix(web): align root feed with default locale

* fix(web): harden IndexNow delivery

* fix(web): trigger IndexNow after production deploys

* fix(web): reuse server cron secret for IndexNow

* fix(web): bound IndexNow sitemap selection
2026-07-17 16:08:02 -07:00
Austin Wang 70d38ef30c Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p
# Conflicts:
#	.github/swift-file-length-budget.tsv
2026-07-04 14:28:30 -07:00
Austin WangandClaude Opus 4.8 4b697f1431 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p
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]>
2026-07-02 06:19:57 -07:00
Austin Wang e7ae720626 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p
# Conflicts:
#	.github/swift-file-length-budget.tsv
2026-07-02 03:49:28 -07:00
Austin WangandClaude Opus 4.8 2f56dacf93 Make chrome/panel background tests independent oracles; cover provisional/ephemeral
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]>
2026-07-02 03:29:39 -07:00
Austin WangandClaude Opus 4.8 a24c9ed213 Refresh Swift file length budget for prefetch revision capture
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]>
2026-07-02 02:14:26 -07:00
Austin WangandClaude Opus 4.8 8836294840 Capture content revision before prefetch debounce
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]>
2026-07-02 02:05:18 -07:00
Austin Wang d89f1be995 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p
# Conflicts:
#	.github/swift-file-length-budget.tsv
2026-07-02 01:54:12 -07:00
Austin Wang d3fdf04669 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p
# Conflicts:
#	.github/swift-file-length-budget.tsv
2026-07-01 18:37:12 -07:00
Austin WangandClaude Opus 4.8 89d9c0e0e7 Remove BrowserPanel test-only background seam
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]>
2026-06-29 18:03:45 -07:00
Austin WangandClaude Opus 4.8 0e3ca21ea6 Cover BrowserPanel ghostty-background subscription via injected center
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]>
2026-06-29 17:51:34 -07:00
Austin Wang 7176c4cfb2 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p
# Conflicts:
#	.github/swift-file-length-budget.tsv
#	cmuxTests/CLIGenericHookPersistenceTests.swift
#	cmuxTests/CLINotifyProcessTestSupport.swift
2026-06-29 17:28:43 -07:00
cmux e8a3ae5890 Stabilize CLI integration process capture 2026-06-29 01:14:12 -07:00
cmux 8571f47199 Update hook persistence expectations 2026-06-29 00:53:55 -07:00
cmux 7928b0fe41 Fix panel background test import boundary 2026-06-29 00:14:53 -07:00
cmux 08b736e378 Stabilize app host appearance tests 2026-06-29 00:06:42 -07:00
cmux 972e886ce4 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p 2026-06-28 23:57:45 -07:00
cmux 7f9d45f254 Use revision guard for file explorer loads 2026-06-28 23:11:10 -07:00
cmux 3b65799d31 Fix Claude stream accumulator delta accounting 2026-06-28 22:54:19 -07:00
cmux 2990e02824 Address file explorer cancellation cleanup 2026-06-28 22:36:07 -07:00
cmux 07781fe289 Stabilize app host regression tests 2026-06-28 22:29:14 -07:00
cmux 974de6b518 Address notification settings review feedback 2026-06-28 21:38:36 -07:00
cmux 71c057d2c5 Retrigger Vercel checks 2026-06-28 21:27:18 -07:00
cmux 8b5c288a07 Merge remote-tracking branch 'origin/main' into issue-5919-settings-desktop-notifications-row-stuck-on-p
# Conflicts:
#	Resources/Localizable.xcstrings
2026-06-28 20:21:32 -07:00
cmux 306d4eff95 Fix desktop notification permission settings row 2026-06-27 00:58:13 -07:00
cmux 3777d83c5b Add failing desktop notification settings test 2026-06-26 16:24:02 -07:00
Myk Melez b0458702e7 Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation
# Conflicts:
#	Sources/TabManager.swift
#	cmux.xcodeproj/project.pbxproj
2026-06-15 08:51:56 -07:00
Myk Melez e080bd826a Fix goto split cycle shortcut routing 2026-06-01 10:23:52 -07:00
Myk Melez a0555ef1d5 Merge branch 'main' into fix/goto-split-cycle-navigation 2026-06-01 09:41:17 -07:00
Myk Melez 5459d03e58 Merge remote-tracking branch 'manaflow/main' into fix/goto-split-cycle-navigation 2026-04-22 08:57:42 -07:00
Myk MelezandClaude Opus 4.6 e8553d7692 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]>
2026-04-12 20:52:24 -07:00
Myk Melez 49dc45084e Merge branch 'main' into fix/goto-split-cycle-navigation 2026-04-12 19:24:18 -07:00
Myk MelezandClaude Opus 4.6 a420ad9e54 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]>
2026-04-12 19:21:05 -07:00
Myk MelezandClaude Opus 4.6 a3cd333783 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]>
2026-04-06 19:01:57 -07:00
Myk MelezandClaude Opus 4.6 5a93244b14 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]>
2026-04-06 19:01:46 -07:00
Myk MelezandClaude Opus 4.6 cdaa58b80d 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]>
2026-04-06 11:44:58 -07:00
Myk MelezandClaude Opus 4.6 fe30a4c6a0 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]>
2026-04-06 11:44:58 -07:00
5839 changed files with 1134568 additions and 72501 deletions
+7 -21
View File
@@ -1,33 +1,19 @@
# Cleanup Dev Builds
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.
+2 -8
View File
@@ -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.
+21 -95
View File
@@ -1,108 +1,34 @@
# Release Local
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`
- **Filter for end-user visible changes only** — ignore developer tooling, CI, docs, tests
- Categorize changes into: Added, Changed, Fixed, Removed
- If there are no user-facing changes, ask the user if they still want to release
- **Collect contributors:** For each PR referenced in the commits, get the author:
```bash
gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Also check for linked issue reporters (the person who filed the bug):
```bash
gh issue view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- 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
4. **Verify and land the homebrew cask.**
- Stage: `CHANGELOG.md`, `cmux.xcodeproj/project.pbxproj`
- Commit message: `Bump version to X.Y.Z`
- Run: `./scripts/release-pretag-guard.sh`
- If it fails, run `./scripts/bump-version.sh`, commit the build-number bump, and rerun the guard
- Create tag: `git tag vX.Y.Z`
- Push: `git push origin main && git push origin vX.Y.Z`
### 6. Build, sign, notarize, and upload
```bash
./scripts/build-sign-upload.sh vX.Y.Z
```
This script handles: GhosttyKit build, xcodebuild, Sparkle key injection, codesigning, notarization (app + DMG), appcast generation, GitHub release upload, homebrew cask update, and cleanup.
If the script fails, run `say "cmux release failed"`.
### 7. Verify homebrew cask
- Run `bash tests/test_homebrew_sha.sh` to confirm the cask SHA matches the release DMG
- Update the homebrew-cmux submodule pointer: `git add homebrew-cmux && git commit -m "Update homebrew-cmux submodule to latest" && git push origin main`
## Changelog Guidelines
**Include only end-user visible changes:**
- New features users can see or interact with
- Bug fixes users would notice (crashes, UI glitches, incorrect behavior)
- Performance improvements users would feel
- UI/UX changes
- Breaking changes or removed features
**Exclude internal/developer changes:**
- Setup scripts, build scripts, reload scripts
- CI/workflow changes
- Documentation updates (README, CONTRIBUTING, CLAUDE.md)
- Test additions or fixes
- 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
```
+5 -95
View File
@@ -1,107 +1,17 @@
# Release Nightly
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`
- **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests
- Categorize changes into: Added, Changed, Fixed, Removed
- **Collect contributors:** For each PR referenced in the commits, get the author:
```bash
gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Also check for linked issue reporters (the person who filed the bug):
```bash
gh issue view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Build a deduplicated list of all contributor `@handle`s for the release
4. **Update the changelog**
- 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
- If there are no user-facing changes, ask the user if they still want to release
5. **Bump the version**
- Run `./scripts/bump-version.sh` (bumps minor by default)
6. **Commit and push the release branch**
- Stage: `CHANGELOG.md`, `cmux.xcodeproj/project.pbxproj`
- Commit message: `Bump version to X.Y.Z`
- Push: `git push -u origin release/vX.Y.Z`
7. **Create PR and wait for CI**
- `gh pr create --title "Release vX.Y.Z" --body "...changelog..."`
- `gh pr checks --watch`
8. **Merge PR**
- `gh pr merge --squash --delete-branch`
- `git checkout main && git pull`
9. **Create and push the tag**
- `git tag vX.Y.Z && git push origin vX.Y.Z`
### Phase 2: Local build, sign, notarize, upload
10. **Run the build script**
Replace steps 9 through 11 of `/release` with:
```bash
./scripts/build-sign-upload.sh vX.Y.Z
```
This script handles: GhosttyKit build, xcodebuild, Sparkle key injection, codesigning, notarization (app + DMG), appcast generation, GitHub release upload, and cleanup.
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"`.
## Changelog Guidelines
**Include only end-user visible changes:**
- New features users can see or interact with
- Bug fixes users would notice (crashes, UI glitches, incorrect behavior)
- Performance improvements users would feel
- UI/UX changes
- Breaking changes or removed features
**Exclude internal/developer changes:**
- Setup scripts, build scripts, reload scripts
- CI/workflow changes
- Documentation updates (README, CONTRIBUTING, CLAUDE.md)
- Test additions or fixes
- 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.
+41 -95
View File
@@ -1,129 +1,75 @@
# Release
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`
- **Filter for end-user visible changes only** - ignore developer tooling, CI, docs, tests
- Categorize changes into: Added, Changed, Fixed, Removed
- **Collect contributors:** For each PR referenced in the commits, get the author:
```bash
gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Also check for linked issue reporters (the person who filed the bug):
```bash
gh issue view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
- Build a deduplicated list of all contributor `@handle`s for the release
2. **Gather changes and contributors since the last tag.**
4. **Update the changelog**
- 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** - things users will see, feel, or interact with
- Write clear, user-facing descriptions (not raw commit messages)
- **Credit contributors inline** (see Contributor Credits below)
- Also update `docs-site/content/docs/changelog.mdx` with the same content
- If there are no user-facing changes, ask the user if they still want to release
```bash
git describe --tags --abbrev=0
git log --oneline <last-tag>..HEAD --no-merges
gh pr view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
gh issue view <N> --repo manaflow-ai/cmux --json author --jq '.author.login'
```
5. **Bump the version in Xcode project**
- 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.
6. **Commit and push the release branch**
- Stage: `CHANGELOG.md`, `docs-site/content/docs/changelog.mdx`, `cmux.xcodeproj/project.pbxproj`
- Commit message: `Bump version to X.Y.Z`
- Push: `git push -u origin release/vX.Y.Z`
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.
7. **Create a pull request**
- Create PR: `gh pr create --title "Release vX.Y.Z" --body "...changelog summary..."`
- Include the changelog entries in the PR body
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`
- Verify: `cd homebrew-cmux && git pull && grep version Casks/cmux.rb`
- 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.
**Include only end-user visible changes:**
- New features users can see or interact with
- Bug fixes users would notice (crashes, UI glitches, incorrect behavior)
- Performance improvements users would feel
- UI/UX changes
- Breaking changes or removed features
```bash
gh run list --workflow=update-homebrew.yml --limit=1
gh run watch --repo manaflow-ai/cmux <run-id>
cd homebrew-cmux && git pull && grep version Casks/cmux.rb
bash tests/test_homebrew_sha.sh
```
**Exclude internal/developer changes:**
- Setup scripts, build scripts, reload scripts
- CI/workflow changes
- Documentation updates (README, CONTRIBUTING, CLAUDE.md)
- Test additions or fixes
- 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
- [@contributor](https://github.com/contributor)
- [@fixer](https://github.com/fixer)
- [@lawrencechen](https://github.com/lawrencechen)
- [@lawrencecchen](https://github.com/lawrencecchen)
- [@reporter](https://github.com/reporter)
```
+5 -25
View File
@@ -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).
+3 -2
View File
@@ -1,8 +1,9 @@
self-hosted-runner:
labels:
# Active default lives in the MACOS_RUNNER_15 / MACOS_RUNNER_26 /
# MACOS_RUNNER_IOS / LINUX_RUNNER repo variables; these are the literal
# labels referenced as fallbacks or as manual workflow_dispatch choices.
# MACOS_RUNNER_IOS / LINUX_RUNNER / LINUX_ARM64_RUNNER repo variables;
# these are the literal labels referenced as fallbacks or as manual
# workflow_dispatch choices.
# See docs/ci-runners.md.
- blacksmith-6vcpu-macos-15
- blacksmith-6vcpu-macos-26
+4 -20
View File
@@ -2,23 +2,7 @@
# Format: relpath<TAB>rule<TAB>short reason
# A finding whose (path, rule) appears here is suppressed.
# Remove a line once the underlying test is determinized.
Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/HostBrowserSignInFlowTests.swift sleep-then-assert grandfathered
Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Omnibar/BrowserOmnibarPageFocusRepositoryTests.swift sleep-then-assert grandfathered
Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/SocketTransportIOTests.swift assert-on-duration grandfathered
Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Process/CommandRunnerTests.swift assert-on-duration grandfathered
Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift sleep-then-assert grandfathered
cmuxTests/CMUXOpenCommandTests.swift assert-on-duration grandfathered
cmuxTests/FileExplorerStoreTests.swift sleep-then-assert grandfathered
cmuxTests/MobileHostAuthorizationTests.swift sleep-then-assert grandfathered
cmuxTests/NotificationAndMenuBarTests.swift assert-on-duration grandfathered
cmuxTests/OmnibarAndToolsTests.swift assert-on-duration grandfathered
cmuxTests/RovoDevSessionIndexTests.swift sleep-then-assert grandfathered
cmuxTests/TabManagerSessionSnapshotTests.swift assert-on-duration grandfathered
cmuxUITests/FeedSidebarUITests.swift sleep-then-assert grandfathered
tests/test_multi_workspace_focus.py sleep-then-assert grandfathered
tests_v2/test_browser_api_extended_families.py sleep-then-assert grandfathered
tests_v2/test_pane_break_swap_preserve_focus.py sleep-then-assert grandfathered
tests_v2/test_surface_list_custom_titles.py sleep-then-assert grandfathered
tests_v2/test_tmux_compat_geometry.py sleep-then-assert grandfathered
tests_v2/test_tmux_compat_matrix.py sleep-then-assert grandfathered
tests_v2/test_v1_panel_creation_preserves_focus.py sleep-then-assert grandfathered
Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTransportDrainTests.swift sleep-then-assert temporary outage debt owned by #8931; remove when determinized
Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/OnboardingMacDiscoveryKeepAliveTests.swift sleep-then-assert temporary outage debt owned by #9163; remove when determinized
cmuxTests/WorkspaceForkConversationContextMenuTests.swift assert-on-duration grandfathered
cmuxTests/WorkspaceForkConversationContextMenuTests.swift sleep-then-assert grandfathered
+14 -7
View File
@@ -10,10 +10,10 @@ concurrency:
jobs:
build-ghosttykit:
runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 20
timeout-minutes: 35
env:
GHOSTTYKIT_CRASH_REPORT_SUBDIR: cmux/crash
GHOSTTYKIT_BUILD_FLAVOR: crashsubdir-cmux-crash-v1
GHOSTTYKIT_BUILD_FLAVOR: crashsubdir-cmux-crash-sentry-off-v1
steps:
- name: Clear stale git locks (self-hosted reused workspace)
shell: bash
@@ -54,7 +54,6 @@ jobs:
fi
- name: Select Xcode
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
@@ -78,7 +77,6 @@ jobs:
xcodebuild -version
- name: Cache Zig packages
if: steps.check-release.outputs.exists == 'false'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/zig
@@ -86,16 +84,25 @@ jobs:
restore-keys: zig-packages-
- name: Install zig
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
./scripts/install-zig-ci.sh
- name: Test Ghostty OS opener stderr reader
run: |
set -euo pipefail
cd ghostty
zig build test \
-Dapp-runtime=none \
-Demit-macos-app=false \
-Dsentry=false \
-Dtest-filter="open stderr reader exits"
- name: Build GhosttyKit.xcframework
if: steps.check-release.outputs.exists == 'false'
run: |
set -euo pipefail
cd ghostty && zig build -Dcrash-report-subdir="$GHOSTTYKIT_CRASH_REPORT_SUBDIR" -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast
cd ghostty && zig build -Dcrash-report-subdir="$GHOSTTYKIT_CRASH_REPORT_SUBDIR" -Dsentry=false -Demit-xcframework=true -Demit-macos-app=false -Dxcframework-target=universal -Doptimize=ReleaseFast
- name: Package xcframework
if: steps.check-release.outputs.exists == 'false'
@@ -121,6 +128,6 @@ jobs:
--repo manaflow-ai/ghostty \
--target "${{ steps.ghostty-sha.outputs.sha }}" \
--title "GhosttyKit xcframework (${{ steps.ghostty-sha.outputs.sha }}, ${GHOSTTYKIT_BUILD_FLAVOR})" \
--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" \
GhosttyKit.xcframework.tar.gz
echo "Published release $TAG"
+13 -4
View File
@@ -18,7 +18,7 @@ jobs:
expected_arch: x86_64
expected_os_major: "14"
- os: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout: 30
timeout: 60
run_unit_tests: true
startup_smoke: true
virtual_display: true
@@ -26,7 +26,7 @@ jobs:
expected_arch: ""
expected_os_major: ""
- os: ${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }}
timeout: 30
timeout: 60
run_unit_tests: true
startup_smoke: true
virtual_display: false
@@ -95,7 +95,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
@@ -153,6 +153,12 @@ jobs:
./scripts/ci/run-swift-testing-suites.sh Packages/Shared/CMUXMobileCore
./scripts/ci/run-swift-testing-suites.sh Packages/Shared/CmuxIrohTransport
- name: Run mobile transport tests in x86_64 iOS Simulator on Intel Sonoma
if: matrix.expected_arch == 'x86_64'
env:
CMUX_EXPECTED_SIMULATOR_ARCH: ${{ matrix.expected_arch }}
run: ./scripts/ci/run-iroh-ios-simulator-package-tests.sh
- name: Run unit tests
if: matrix.run_unit_tests
env:
@@ -166,6 +172,7 @@ jobs:
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
COMPILER_INDEX_STORE_ENABLE=NO \
test 2>&1
}
@@ -225,7 +232,9 @@ jobs:
xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" build
-destination "platform=macOS" \
COMPILER_INDEX_STORE_ENABLE=NO \
build
- name: Smoke test
if: matrix.startup_smoke
+145 -10
View File
@@ -95,6 +95,9 @@ jobs:
- name: Validate release SDK build lane
run: ./tests/test_ci_release_sdk_lane.sh
- name: Validate docs deployment authentication guard
run: python3 tests/test_docs_deploy_auth_guard.py
- name: Validate Python test harness syntax
run: git ls-files 'tests/*.py' 'tests_v2/*.py' 'scripts/*.py' | xargs python3 -m py_compile
@@ -118,6 +121,9 @@ jobs:
with:
python-version: "3.9"
- name: Install workflow guard Python dependencies
run: python3 -m pip install --disable-pip-version-check --no-input PyYAML==6.0.3 bashlex==0.18
- name: Validate nightly prune Python compatibility
run: PYTHON_BIN=python3.9 bash ./tests/test_ci_nightly_prune_python_compat.sh
@@ -133,6 +139,9 @@ jobs:
- name: Validate unit-test SwiftPM retry guard
run: ./tests/test_ci_unit_test_spm_retry.sh
- name: Validate Swift Testing suite timeout guard
run: python3 tests/test_swift_testing_suite_timeout.py
- name: Validate xcodebuild noninteractive crash prompt guard
run: python3 tests/test_ci_xcodebuild_noninteractive_helper.py
@@ -151,15 +160,24 @@ jobs:
- name: Validate TestFlight notes generator
run: python3 tests/test_ios_testflight_notes.py
- name: Validate CMUX INTERNAL main-push path filter
run: python3 tests/test_ios_testflight_main_push_filter.py
- name: Validate external TestFlight group assignment helper
run: python3 tests/test_ios_testflight_external_distribution.py
- name: Validate Pro TestFlight distribution workflow
run: python3 tests/test_ios_testflight_pro_distribution.py
- name: Validate iOS App Store lane identity
run: python3 tests/test_ios_appstore_lane_identity.py
- name: Validate cmux scheme test configuration
run: ./tests/test_ci_scheme_testaction_debug.sh
- name: Validate selected iOS test execution guard
run: python3 tests/test_ios_selected_test_execution.py
- name: Validate cmuxTests sharding
run: |
python3 scripts/ci/cmux_unit_test_shard.py --validate
@@ -171,6 +189,12 @@ jobs:
- name: Validate Zig install without sudo
run: ./tests/test_install_zig_ci_no_sudo.sh
- name: Initialize Ghostty for Zig version guard
run: git submodule update --init --depth 1 ghostty
- name: Validate Ghostty Zig version synchronization
run: ./tests/test_ghostty_zig_version_sync.sh
- name: Validate virtual display lock
run: ./tests/test_ci_virtual_display_lock.sh
@@ -186,6 +210,9 @@ jobs:
- name: Validate universal nightly workflow
run: bash ./tests/test_nightly_universal_build.sh
- name: Validate nightly notarization behavior
run: ./tests/test_notarize_nightly_dmg.sh
- name: Validate release asset guard
run: node scripts/release_asset_guard.test.js
@@ -219,6 +246,11 @@ jobs:
- name: Validate pbxproj test-wiring lint
run: ./tests/test_ci_pbxproj_test_wiring.sh
- name: Validate stored DispatchWorkItem ownership
run: |
python3 tests/test_lint_stored_dispatch_work_items.py
python3 scripts/lint-stored-dispatch-work-items.py
- name: Validate pbxproj objectVersion pin and normalization
run: ./scripts/check-pbxproj.sh
@@ -226,7 +258,9 @@ jobs:
run: python3 scripts/check-workspace-package-groups.py --check
- name: Validate SwiftPM lockfile policy
run: python3 scripts/check-package-resolved-policy.py
run: |
python3 tests/test_check_package_resolved_policy.py
python3 scripts/check-package-resolved-policy.py
- name: Validate bash shell integration job control
run: python3 tests/test_bash_integration_no_done_notifications.py
@@ -277,6 +311,8 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Install dependencies
run: bun install --frozen-lockfile
@@ -288,11 +324,7 @@ jobs:
run: bun run typecheck
- name: Web tests
# Explicit sorted file list: bun discovers test files in filesystem
# readdir order, which differs between Linux runners and local macOS,
# so an unpinned run exercises a file order no developer can
# reproduce. Sorted order makes CI failures replayable locally.
run: bun test $(ls tests/*.test.ts tests/*.test.tsx | sort)
run: bun run test
# Checks for in-app React webviews (currently the diff viewer; more cmux React
# surfaces will live alongside it).
@@ -483,7 +515,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
@@ -570,6 +602,31 @@ jobs:
echo "::warning::Passwordless sudo unavailable; XCTest will use its default automation-mode setup"
fi
- name: Run agent chat transcript lifecycle regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Swift Testing assertion failures are tolerated in the full sharded
# app-host suite. Keep hook-driven transcript resolution on a
# non-tolerant focused invocation so Feed ingress cannot regain an
# unbounded recursive filesystem scan.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
for suite in \
AgentChatSessionRegistryLifecycleReviewRegressionTests \
AgentChatFallbackTranscriptResolutionCoordinatorTests
do
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:"cmuxTests/$suite" \
test
done
- name: Run browser runtime viewport regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
@@ -590,6 +647,26 @@ jobs:
-only-testing:cmuxTests/BrowserViewportRuntimeTests \
test
- name: Run five-tab renderer memory regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Use a dedicated app-host process so task_vm_info deltas only compare
# one versus five real Ghostty renderers in this workload. The focused
# invocation also makes footprint assertion failures non-tolerant.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
CMUX_RENDERER_MEMORY_REGRESSION=1 \
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/GhosttySurfaceOverlayTests/testFiveTabRendererFootprintReturnsToOneRendererTargetAcrossHideRevealCycles \
test
- name: Run notification routing regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
@@ -607,6 +684,7 @@ jobs:
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/AgentNotificationRegressionTests \
-only-testing:cmuxTests/DockNotificationAttentionTests \
-only-testing:cmuxTests/ClaudeHookLifecycleCleanupTests \
-only-testing:cmuxTests/ClaudeHookLiveDeliveryTargetTests \
-only-testing:cmuxTests/ClaudeHookPIDAuthenticationTests \
@@ -614,6 +692,34 @@ jobs:
-only-testing:cmuxTests/PhonePushPresenceGateTests \
test
- name: Run Pi Feed ownership regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# Swift Testing assertion failures are tolerated in the full sharded
# app-host suite. Keep Feed ingestion and ownership on a non-tolerant
# focused invocation so valid Pi Feed targets cannot silently regress.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
# Each suite mutates process-global app/store state. Give every suite
# a fresh app-host process so Swift Testing cannot interleave them.
for suite in \
FeedCoordinatorTests \
FeedCoordinatorIngressTests \
PiFeedOwnershipTests \
PiFeedDockOwnershipTests
do
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:"cmuxTests/$suite" \
test
done
- name: Run remote tmux mirror detach and placement regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
@@ -910,6 +1016,12 @@ jobs:
with:
node-version: "20"
- name: Set up Bun for Pi extension dispatch regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.6"
- name: Run CLI no-socket regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
@@ -930,27 +1042,50 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_contract_help.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_layout_focus_contract.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_operation_deadline.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_simulator_contract.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_browser_profile_cli.py
python3 tests/test_stress_cli_socket_api.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_openagent_plugin_migration.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py
python3 tests/test_codex_wrapper_resume_hooks.py
python3 tests/test_claude_wrapper_hooks.py
python3 tests/test_claude_wrapper_mutual_shim_loop.py
python3 tests/test_claude_wrapper_shim_root_survives_tmpdir_change.py
python3 tests/test_claude_wrapper_user_binary_resolution.py
python3 tests/test_claude_teams_test_utils.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_codex_teams_informational.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_env.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_existing_shim.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_main_vertical.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_moved_surface.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_tmux_sequence.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_claude_teams_trust_optin.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omo_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omx_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_omc_fallback_path.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_issue_8743_path_directory_shadowing.py
python3 tests/test_issue_2448_shell_claude_wrapper_dispatch.py
python3 tests/test_issue_8093_ghostty_ssh_binary_path.py
python3 tests/test_issue_6714_zsh_shim_noclobber.py
python3 tests/test_issue_9356_bash_shim_noclobber.py
python3 tests/test_issue_8953_zsh_prompt_wrap_guard.py
python3 tests/test_shell_git_branch_stale_cwd.py
python3 tests/test_shell_git_config_remote_url_parsing.py
if ! command -v fish >/dev/null 2>&1; then
HOMEBREW_NO_AUTO_UPDATE=1 brew install fish
fi
command -v fish >/dev/null 2>&1 || {
echo "fish is required for the issue #9075 shell socket regression" >&2
exit 1
}
python3 tests/test_issue_9075_shell_socket_reports.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_stop_last_assistant.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_clear_running_status.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_push_notification.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_dispatch.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_compacted_feed.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_omp_extension_install.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_campfire_extension_install.py
@@ -1118,7 +1253,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Validate cached GhosttyKit.xcframework
id: validate-ghosttykit-package-tests
@@ -1430,7 +1565,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit-lag.outputs.cache-hit != 'true'
@@ -1751,7 +1886,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ steps.ghostty-revision.outputs.sha }}
key: ghosttykit-sentry-off-v1-${{ steps.ghostty-revision.outputs.sha }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit-release.outputs.cache-hit != 'true'
+34
View File
@@ -0,0 +1,34 @@
name: cmux-browser
on:
pull_request:
paths:
- "cmux-browser/**"
- ".github/workflows/cmux-browser.yml"
push:
branches: [main]
paths:
- "cmux-browser/**"
- ".github/workflows/cmux-browser.yml"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
host-tests:
# Browser pull requests are untrusted code and must not run on a
# configurable self-hosted runner with private network access.
runs-on: ubuntu-24.04 # github-hosted-required: browser PRs run untrusted code
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run Browser policy and host tests
run: ./cmux-browser/scripts/run-host-tests.sh
+52 -21
View File
@@ -1,16 +1,21 @@
name: cmux-tui artifacts
# Publishes raw cmux-tui (the Rust TUI multiplexer) binaries to the
# cmux-binaries R2 bucket (public at https://files.cmux.com/cmux-tui/...) so cloud
# VM snapshot builders and install scripts can curl a binary directly, without
# npm or PyPI. Binary building is shared with the npm/uvx distribution lane
# (cmux-tui-build-package.yml); this workflow only adds the R2 raw-binary publish.
# Publishes raw cmux-tui (the Rust TUI multiplexer) and cmux-relay binaries to the
# cmux-binaries R2 bucket (public under https://files.cmux.com/cmux-tui/... and
# https://files.cmux.com/cmux-relay/...) so cloud VM snapshot builders and install
# scripts can curl a binary directly, without npm or PyPI. Binary building is
# shared with the npm/uvx distribution lane (cmux-tui-build-package.yml); this
# workflow only adds the R2 raw-binary publish.
#
# Layout in R2:
# cmux-tui/<commit-sha>/cmux-tui-<rust-target> immutable, commit-addressed
# cmux-tui/<commit-sha>/manifest.json
# cmux-tui/latest/cmux-tui-<rust-target> rolling, manual publishes only
# cmux-tui/latest/manifest.json
# cmux-relay/<commit-sha>/cmux-relay-<rust-target> immutable, commit-addressed
# cmux-relay/<commit-sha>/manifest.json
# cmux-relay/latest/cmux-relay-<rust-target> rolling, manual publishes only
# cmux-relay/latest/manifest.json
on:
# Temporarily manual-only beginning 2026-07-13 to pause automatic CI/CD.
@@ -28,10 +33,12 @@ jobs:
contents: read
uses: ./.github/workflows/cmux-tui-build-package.yml
with:
# Binaries only; the version input is unused when packaging is off.
version: "0.0.0"
# Raw R2 artifacts are not npm releases and must never claim that an
# older published package can reproduce their source contents.
version: 0.0.0-r2.${{ github.sha }}
package_npm: false
package_pypi: false
include_windows: true
publish:
name: publish to R2
@@ -48,21 +55,37 @@ jobs:
with:
persist-credentials: false
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
- name: Download cmux-tui binaries
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: cmux-tui-*
path: assets
path: assets/cmux-tui
merge-multiple: true
- name: Download cmux-relay binaries
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: cmux-relay-*
path: assets/cmux-relay
merge-multiple: true
- name: Build manifest and checksums
run: |
cd assets
chmod 0755 cmux-tui-*
sha256sum cmux-tui-* > cmux-tui-checksums.txt
python3 - "$GITHUB_SHA" <<'PY'
build_manifest() {
local directory="$1"
local binary_prefix="$2"
local checksums="$3"
(
cd "$directory"
chmod 0755 "${binary_prefix}"*
sha256sum "${binary_prefix}"* > "$checksums"
python3 - "$GITHUB_SHA" "$binary_prefix" <<'PY'
import hashlib, json, os, sys
from datetime import datetime, timezone
files = sorted(f for f in os.listdir(".") if f.startswith("cmux-tui-") and not f.endswith(".txt"))
files = sorted(
f for f in os.listdir(".")
if f.startswith(sys.argv[2]) and not f.endswith(".txt")
)
manifest = {
"commit": sys.argv[1],
"builtAt": datetime.now(timezone.utc).isoformat(),
@@ -74,6 +97,10 @@ jobs:
json.dump(manifest, out, indent=2)
print(json.dumps(manifest, indent=2))
PY
)
}
build_manifest assets/cmux-tui cmux-tui- cmux-tui-checksums.txt
build_manifest assets/cmux-relay cmux-relay- cmux-relay-checksums.txt
- name: Upload to R2
env:
@@ -84,9 +111,10 @@ jobs:
run: |
set -euo pipefail
publish_prefix() {
local prefix="$1"
local cache="$2"
for file in assets/cmux-tui-* assets/manifest.json; do
local directory="$1"
local prefix="$2"
local cache="$3"
for file in "$directory"/*; do
python3 scripts/ci/upload-r2-object.py \
--file "$file" \
--endpoint-url "$R2_ENDPOINT" \
@@ -95,11 +123,14 @@ jobs:
--cache-control "$cache"
done
}
publish_prefix "cmux-tui/$GITHUB_SHA" "public, max-age=31536000, immutable"
publish_prefix assets/cmux-tui "cmux-tui/$GITHUB_SHA" "public, max-age=31536000, immutable"
publish_prefix assets/cmux-relay "cmux-relay/$GITHUB_SHA" "public, max-age=31536000, immutable"
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
publish_prefix "cmux-tui/latest" "no-cache, no-store, must-revalidate"
publish_prefix assets/cmux-tui "cmux-tui/latest" "no-cache, no-store, must-revalidate"
publish_prefix assets/cmux-relay "cmux-relay/latest" "no-cache, no-store, must-revalidate"
fi
echo "Published: https://files.cmux.com/cmux-tui/$GITHUB_SHA/manifest.json"
echo "Published: https://files.cmux.com/cmux-relay/$GITHUB_SHA/manifest.json"
# Transitional double-publish under the pre-rename prefix and binary
# names: out-of-repo consumers (cmux-cloud bootstrap/install-mux.sh
@@ -108,10 +139,10 @@ jobs:
# (https://github.com/manaflow-ai/cmux-cloud/pull/2).
legacy_assets="assets-legacy"
mkdir -p "$legacy_assets"
for file in assets/cmux-tui-*; do
for file in assets/cmux-tui/cmux-tui-*; do
cp "$file" "$legacy_assets/$(basename "$file" | sed 's/^cmux-tui-/cmux-mux-/')"
done
cp assets/manifest.json "$legacy_assets/manifest.json"
cp assets/cmux-tui/manifest.json "$legacy_assets/manifest.json"
publish_legacy_prefix() {
local prefix="$1"
local cache="$2"
+317 -27
View File
@@ -35,6 +35,9 @@ on:
permissions: {}
env:
RUST_TOOLCHAIN: "1.95.0"
jobs:
build:
name: build ${{ matrix.target }}
@@ -47,21 +50,29 @@ jobs:
matrix:
include:
- target: aarch64-apple-darwin
build_target: aarch64-apple-darwin
runner: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
cross: false
ext: ""
compatibility_target: ""
- target: x86_64-apple-darwin
build_target: x86_64-apple-darwin
runner: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
cross: true
ext: ""
- target: x86_64-unknown-linux-gnu
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
cross: false
ext: ""
- target: aarch64-unknown-linux-gnu
compatibility_target: ""
- target: x86_64-unknown-linux-musl
build_target: x86_64-unknown-linux-musl
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
cross: true
ext: ""
compatibility_target: x86_64-unknown-linux-gnu
- target: aarch64-unknown-linux-musl
build_target: aarch64-unknown-linux-musl
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
cross: true
ext: ""
compatibility_target: aarch64-unknown-linux-gnu
steps:
- name: Checkout caller ref
if: inputs.checkout_ref == ''
@@ -83,12 +94,26 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y clang libclang-dev pkg-config
sudo apt-get install -y binutils clang libclang-dev pkg-config
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
rustc --version
- name: Install Rust target
shell: bash
@@ -101,40 +126,172 @@ jobs:
- name: Build cmux-tui (native)
if: matrix.cross == false
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
working-directory: cmux-tui
shell: bash
env:
CMUX_TUI_BUILD_COMMIT: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }}
run: cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.target }}
run: |
# Package artifacts must build and stamp the checked-out submodule,
# independent of any self-hosted runner source override.
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (cross)
if: matrix.cross == true
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
working-directory: cmux-tui
shell: bash
env:
CMUX_TUI_BUILD_COMMIT: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }}
run: cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.target }}
run: |
# Package artifacts must build and stamp the checked-out submodule,
# independent of any self-hosted runner source override.
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git -C .. rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ../ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Stage binary
shell: bash
run: |
mkdir -p dist
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-tui${{ matrix.ext }}" "dist/cmux-tui-${{ matrix.target }}${{ matrix.ext }}"
binary="dist/cmux-tui-${{ matrix.target }}${{ matrix.ext }}"
relay_binary="dist/cmux-relay-${{ matrix.target }}${{ matrix.ext }}"
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-tui${{ matrix.ext }}" "$binary"
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-relay${{ matrix.ext }}" "$relay_binary"
if [[ -n "${{ matrix.compatibility_target }}" ]]; then
cp "$binary" "dist/cmux-tui-${{ matrix.compatibility_target }}${{ matrix.ext }}"
cp "$relay_binary" "dist/cmux-relay-${{ matrix.compatibility_target }}${{ matrix.ext }}"
fi
ls -la dist
- name: Verify non-npm binaries disable SSH auto-install
if: runner.os == 'Linux' && matrix.target == 'x86_64-unknown-linux-musl' && inputs.package_npm == false
shell: bash
run: |
CMUX_TUI_PROBE="$(dist/cmux-tui-${{ matrix.target }} remote-probe --json)"
CMUX_TUI_EXPECTED_BUILD_IDENTITY="$(git rev-parse HEAD)"
export CMUX_TUI_PROBE CMUX_TUI_EXPECTED_BUILD_IDENTITY
python3 - <<'PY'
import json
import os
probe = json.loads(os.environ["CMUX_TUI_PROBE"])
expected_identity = os.environ["CMUX_TUI_EXPECTED_BUILD_IDENTITY"]
if probe.get("build_identity") != expected_identity:
raise SystemExit(
f"binary build identity {probe.get('build_identity')!r} "
f"!= {expected_identity!r}"
)
if probe.get("npm_bootstrap_version") is not None:
raise SystemExit(
"non-npm binary unexpectedly advertises npm bootstrap version "
f"{probe.get('npm_bootstrap_version')!r}"
)
PY
- name: Smoke-test release remote sessions
if: matrix.target == 'x86_64-unknown-linux-musl'
shell: bash
run: cmux-tui/scripts/smoke-remote-release.sh "dist/cmux-tui-${{ matrix.target }}"
- name: Test release remote sequence rollback
if: matrix.target == 'x86_64-unknown-linux-musl'
working-directory: cmux-tui
shell: bash
run: >-
cargo test --release --locked --target ${{ matrix.target }}
-p cmux-remote queue_admission_failure_rolls_back_sequence_and_replay
- name: Upload binary artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cmux-tui-${{ matrix.target }}
path: dist/cmux-tui-${{ matrix.target }}${{ matrix.ext }}
path: dist/cmux-tui-*${{ matrix.ext }}
if-no-files-found: error
- name: Upload relay binary artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cmux-relay-${{ matrix.target }}
path: dist/cmux-relay-*${{ matrix.ext }}
if-no-files-found: error
cloudflare-relay:
name: Cloudflare Durable Object relay
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 30
env:
RUSTUP_TOOLCHAIN: "1.91.0"
permissions:
contents: read
defaults:
run:
working-directory: cmux-tui/relays/cloudflare-do
steps:
- name: Checkout caller ref
if: inputs.checkout_ref == ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Checkout requested ref
if: inputs.checkout_ref != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ inputs.checkout_ref }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
cache: npm
cache-dependency-path: cmux-tui/relays/cloudflare-do/package-lock.json
- name: Install pinned Rust toolchain and Worker builder
run: |
rustup toolchain install "$RUSTUP_TOOLCHAIN" --profile minimal --component clippy --target wasm32-unknown-unknown
cargo install --locked [email protected]
- name: Install pinned npm dependencies
run: npm ci --no-audit --no-fund
- name: Test and lint relay
run: |
python3 tests/validate_wrangler_config.py
cargo test --locked
cargo clippy --locked --all-targets -- -D warnings
- name: Audit npm dependencies
run: npm audit --audit-level=high
- name: Build Worker
run: npm run build
- name: Validate Wrangler deployment bundle
run: npx --no-install wrangler deploy --dry-run --outdir "$RUNNER_TEMP/cmux-cloudflare-relay"
build-windows:
name: build x86_64-pc-windows-gnu
if: inputs.include_windows
runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }}
timeout-minutes: 60
continue-on-error: true
permissions:
contents: read
steps:
@@ -154,10 +311,24 @@ jobs:
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
rustc --version
- name: Install Rust target
shell: bash
@@ -166,15 +337,37 @@ jobs:
printf '%s\n' 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH"
- name: Build libghostty-vt + cmux-tui (Windows GNU)
shell: bash
env:
CMUX_TUI_BUILD_COMMIT: ${{ inputs.checkout_ref != '' && inputs.checkout_ref || github.sha }}
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
PACKAGE_NPM: ${{ inputs.package_npm }}
shell: bash
run: |
# Keep the manual Zig build and Cargo's build script on the same
# checked-out Ghostty source whose revision is stamped below.
unset CMUX_GHOSTTY_SRC
CMUX_TUI_BUILD_COMMIT="$(git rev-parse HEAD)"
CMUX_TUI_GHOSTTY_COMMIT="$(git -C ghostty rev-parse HEAD)"
export CMUX_TUI_BUILD_COMMIT CMUX_TUI_GHOSTTY_COMMIT CMUX_TUI_DISTRIBUTION_VERSION
if [[ "$PACKAGE_NPM" == "true" ]]; then
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cd ghostty
zig build -Demit-lib-vt=true -Demit-xcframework=false -Doptimize=ReleaseFast -Dtarget=x86_64-windows-gnu --prefix "$RUNNER_TEMP/ghostty-vt-win-gnu"
cd ../cmux-tui
cargo build -p cmux-tui --bin cmux-tui --release --locked --target x86_64-pc-windows-gnu
- name: Verify remote commands fail clearly on Windows
shell: bash
run: |
set +e
output="$(cmux-tui/target/x86_64-pc-windows-gnu/release/cmux-tui.exe remote-probe --json 2>&1)"
status=$?
set -e
printf '%s\n' "$output"
test "$status" -eq 1
grep -F "remote daemon commands require Unix sockets" <<<"$output"
- name: Stage binary
shell: bash
run: |
@@ -229,13 +422,13 @@ jobs:
- name: Download linux x64 binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-tui-x86_64-unknown-linux-gnu
name: cmux-tui-x86_64-unknown-linux-musl
path: dist/binaries
- name: Download linux arm64 binary
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-tui-aarch64-unknown-linux-gnu
name: cmux-tui-aarch64-unknown-linux-musl
path: dist/binaries
- name: Build npm package directories
@@ -288,9 +481,33 @@ jobs:
if not binary.stat().st_mode & stat.S_IXUSR:
raise SystemExit(f"{binary} is not executable")
PY
chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-gnu
dist/binaries/cmux-tui-x86_64-unknown-linux-gnu --version >/tmp/cmux-tui-version.txt 2>&1 || \
dist/binaries/cmux-tui-x86_64-unknown-linux-gnu --help >/tmp/cmux-tui-version.txt 2>&1
chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-musl
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --version >/tmp/cmux-tui-version.txt 2>&1 || \
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --help >/tmp/cmux-tui-version.txt 2>&1
CMUX_TUI_PROBE="$(dist/binaries/cmux-tui-x86_64-unknown-linux-musl remote-probe --json)"
CMUX_TUI_EXPECTED_BUILD_IDENTITY="$(git rev-parse HEAD)"
export CMUX_TUI_PROBE CMUX_TUI_EXPECTED_BUILD_IDENTITY
python3 - <<'PY'
import json
import os
probe = json.loads(os.environ["CMUX_TUI_PROBE"])
expected = os.environ["NPM_VERSION"]
expected_identity = os.environ["CMUX_TUI_EXPECTED_BUILD_IDENTITY"]
if probe.get("build_identity") != expected_identity:
raise SystemExit(
f"binary build identity {probe.get('build_identity')!r} "
f"!= {expected_identity!r}"
)
if probe.get("distribution_version") != expected:
raise SystemExit(
f"binary distribution version {probe.get('distribution_version')!r} != {expected!r}"
)
if probe.get("npm_bootstrap_version") != expected:
raise SystemExit(
f"binary npm bootstrap version {probe.get('npm_bootstrap_version')!r} != {expected!r}"
)
PY
- name: Archive npm package directories with executable modes
if: inputs.package_npm
@@ -306,9 +523,9 @@ jobs:
for wheel in dist/pypi-wheels/*.whl; do
python3 -m zipfile -l "$wheel" >"/tmp/$(basename "$wheel").list"
done
chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-gnu
dist/binaries/cmux-tui-x86_64-unknown-linux-gnu --version >/tmp/cmux-tui-version.txt 2>&1 || \
dist/binaries/cmux-tui-x86_64-unknown-linux-gnu --help >/tmp/cmux-tui-version.txt 2>&1
chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-musl
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --version >/tmp/cmux-tui-version.txt 2>&1 || \
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --help >/tmp/cmux-tui-version.txt 2>&1
python3 -m venv /tmp/cmux-tui-wheel-smoke
/tmp/cmux-tui-wheel-smoke/bin/python -m pip install --no-index --find-links dist/pypi-wheels cmux=="$PYPI_VERSION"
/tmp/cmux-tui-wheel-smoke/bin/cmux --help >/tmp/cmux-help.txt
@@ -329,3 +546,76 @@ jobs:
name: pypi-wheels
path: dist/pypi-wheels/*.whl
if-no-files-found: error
verify-linux-packages:
name: verify Linux package entrypoints (${{ matrix.architecture }})
needs: package
if: ${{ inputs.package_npm || inputs.package_pypi }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- architecture: x64
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# Run ARM64 containers on native hardware. QEMU registration is not
# persistent on every third-party x64 runner and can disappear
# between setup and the package smoke test.
- architecture: arm64
runner: ${{ vars.LINUX_ARM64_RUNNER || 'ubuntu-24.04-arm' }} # github-hosted-required: package smoke tests need native ARM64 execution
env:
PYPI_VERSION: ${{ inputs.pypi_version != '' && inputs.pypi_version || inputs.version }}
steps:
- name: Checkout caller ref
if: inputs.checkout_ref == ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Checkout requested ref
if: inputs.checkout_ref != ''
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ inputs.checkout_ref }}
- name: Download npm package archive
if: inputs.package_npm
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: npm-packages
path: dist
- name: Restore npm package directories
if: inputs.package_npm
run: |
python3 cmux-tui/dist/scripts/package_npm_artifact.py extract \
--archive dist/npm-packages.tar.gz \
--out dist
- name: Download PyPI wheels
if: inputs.package_pypi
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: pypi-wheels
path: dist/pypi-wheels
- name: Test Linux distributions
env:
PACKAGE_NPM: ${{ inputs.package_npm }}
PACKAGE_PYPI: ${{ inputs.package_pypi }}
run: |
set -euo pipefail
args=(--version "$PYPI_VERSION")
if [[ "$PACKAGE_NPM" == "true" ]]; then
args+=(--npm-packages dist/npm-packages)
fi
if [[ "$PACKAGE_PYPI" == "true" ]]; then
args+=(--pypi-wheels dist/pypi-wheels)
fi
python3 cmux-tui/dist/scripts/test_linux_packages.py \
--architecture "${{ matrix.architecture }}" \
"${args[@]}"
+14 -8
View File
@@ -151,19 +151,25 @@ jobs:
- name: Dispatch release workflows
# The tag push above was made with the default GITHUB_TOKEN, which
# never triggers other workflows' tag-push events. Dispatch the
# release workflows explicitly against the new tag. This keeps registry
# OIDC identities on their top-level workflow files and binds provenance
# to the exact immutable release commit. Failed runs retain that ref.
# build/package workflow explicitly against the new tag. That workflow
# builds and verifies every package once, then dispatches the top-level
# registry workflows with its artifact run ID. The registry OIDC
# identities therefore stay on their existing workflow files without
# rebuilding the same binaries for each registry.
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
gh workflow run cmux-tui-release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f version="$VERSION"
gh workflow run tui-publish-pypi.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f version="$VERSION"
gh workflow run tui-publish-npm.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" -f version="$VERSION" -f confirm_tui_cmux=true
gh workflow run cmux-tui-release.yml \
--repo "$GITHUB_REPOSITORY" \
--ref "$TAG" \
-f version="$VERSION" \
-f publish_npm=true \
-f publish_pypi=true \
-f confirm_tui_cmux=true
{
echo "- Dispatched cmux-tui-release.yml on $TAG"
echo "- Dispatched npm and PyPI publishers on $TAG"
echo "- Dispatched one build/package run on $TAG"
echo "- npm and PyPI will reuse that run's verified artifacts"
} >> "$GITHUB_STEP_SUMMARY"
+79 -15
View File
@@ -1,8 +1,8 @@
name: cmux-tui release binaries
# Builds the cmux-tui TUI binary for every distribution target (npm/PyPI `cmux`).
# Manual dispatch or cmux-tui tag builds upload one artifact per target so the
# npm/PyPI wrapper-packaging jobs can bundle them.
# Builds and verifies the cmux-tui distribution artifacts once. A coordinated
# stable release can then dispatch the npm and PyPI publishers with this run's
# artifact ID, avoiding identical rebuilds in each registry workflow.
on:
workflow_dispatch:
inputs:
@@ -10,6 +10,21 @@ on:
description: "TUI package version to build, for example 0.1.0"
required: true
type: string
publish_npm:
description: "Publish the verified npm artifacts after the build"
required: true
default: false
type: boolean
publish_pypi:
description: "Publish the verified PyPI artifacts after the build"
required: true
default: false
type: boolean
confirm_tui_cmux:
description: "Set true only for the coordinated npm cmux TUI publish"
required: true
default: false
type: boolean
push:
tags:
- "cmux-tui-v*"
@@ -33,20 +48,27 @@ jobs:
id: version
env:
DISPATCH_VERSION: ${{ inputs.version }}
PUBLISH_NPM: ${{ inputs.publish_npm }}
CONFIRM_TUI_CMUX: ${{ inputs.confirm_tui_cmux }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^cmux-tui-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-tui-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-tui-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
if [[ "${GITHUB_REF_TYPE:-}" != "tag" ]]; then
echo "Stable artifacts require a cmux-tui-vX.Y.Z tag ref." >&2
exit 1
fi
[[ "$GITHUB_REF_NAME" =~ ^cmux-tui-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-tui-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-tui-v}"
if [[ -n "$DISPATCH_VERSION" && "$DISPATCH_VERSION" != "$version" ]]; then
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
fi
if [[ "$PUBLISH_NPM" == "true" && "$CONFIRM_TUI_CMUX" != "true" ]]; then
echo "npm publishing requires confirm_tui_cmux=true." >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
@@ -60,3 +82,45 @@ jobs:
package_npm: true
package_pypi: true
include_windows: true
dispatch-publishers:
name: dispatch verified artifacts to registries
if: ${{ inputs.publish_npm || inputs.publish_pypi }}
needs: build-package
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
actions: write
contents: read
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
VERSION: ${{ inputs.version }}
ARTIFACT_RUN_ID: ${{ github.run_id }}
PUBLISH_NPM: ${{ inputs.publish_npm }}
PUBLISH_PYPI: ${{ inputs.publish_pypi }}
steps:
- name: Dispatch registry publishers
run: |
set -euo pipefail
if [[ "$PUBLISH_NPM" == "true" ]]; then
gh workflow run tui-publish-npm.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" \
-f version="$VERSION" \
-f artifact_run_id="$ARTIFACT_RUN_ID" \
-f confirm_tui_cmux=true
fi
if [[ "$PUBLISH_PYPI" == "true" ]]; then
gh workflow run tui-publish-pypi.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" \
-f version="$VERSION" \
-f artifact_run_id="$ARTIFACT_RUN_ID"
fi
{
echo "### Registry publishing"
echo
echo "- Verified artifact run: $ARTIFACT_RUN_ID"
if [[ "$PUBLISH_NPM" == "true" ]]; then
echo "- Dispatched npm publisher"
fi
if [[ "$PUBLISH_PYPI" == "true" ]]; then
echo "- Dispatched PyPI publisher"
fi
} >> "$GITHUB_STEP_SUMMARY"
+541
View File
@@ -0,0 +1,541 @@
name: cmux-tui SDKs
on:
push:
branches:
- main
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-nightly.yml"
- ".github/workflows/cmux-tui-release-cut.yml"
- ".github/workflows/cmux-tui-release.yml"
- ".github/workflows/cmux-tui-sdks.yml"
- ".github/workflows/cmux-tui-spec.yml"
- ".github/workflows/sdk-bootstrap-crates.yml"
- ".github/workflows/sdk-bootstrap-npm.yml"
- ".github/workflows/sdk-bootstrap-pypi.yml"
- ".github/workflows/sdk-publish-crates.yml"
- ".github/workflows/sdk-publish-go.yml"
- ".github/workflows/sdk-publish-java.yml"
- ".github/workflows/sdk-publish-npm.yml"
- ".github/workflows/sdk-publish-python.yml"
- ".github/workflows/sdk-release-cut.yml"
- ".github/workflows/tui-publish-npm.yml"
- ".github/workflows/tui-publish-pypi.yml"
- "tests/test_tui_publish_workflow_security.py"
pull_request:
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-nightly.yml"
- ".github/workflows/cmux-tui-release-cut.yml"
- ".github/workflows/cmux-tui-release.yml"
- ".github/workflows/cmux-tui-sdks.yml"
- ".github/workflows/cmux-tui-spec.yml"
- ".github/workflows/sdk-bootstrap-crates.yml"
- ".github/workflows/sdk-bootstrap-npm.yml"
- ".github/workflows/sdk-bootstrap-pypi.yml"
- ".github/workflows/sdk-publish-crates.yml"
- ".github/workflows/sdk-publish-go.yml"
- ".github/workflows/sdk-publish-java.yml"
- ".github/workflows/sdk-publish-npm.yml"
- ".github/workflows/sdk-publish-python.yml"
- ".github/workflows/sdk-release-cut.yml"
- ".github/workflows/tui-publish-npm.yml"
- ".github/workflows/tui-publish-pypi.yml"
- "tests/test_tui_publish_workflow_security.py"
workflow_dispatch:
concurrency:
group: cmux-tui-sdks-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
contract:
name: protocol contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 8
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Install workflow guard dependencies
run: |
python3 -m pip install \
--disable-pip-version-check \
"PyYAML==6.0.3"
- name: Test protocol inventory
run: python3 cmux-tui/scripts/test_check_spec_inventory.py
- name: Check protocol and TUI action inventory
run: python3 cmux-tui/scripts/check-spec-inventory.py
- name: Test SDK schema drift checker
run: python3 cmux-tui/scripts/test_check_sdk_schema.py
- name: Check SDK schema against runtime fields
run: python3 cmux-tui/scripts/check-sdk-schema.py
- name: Test public resource boundary checker
run: python3 cmux-tui/scripts/test_check_resource_api_boundary.py
- name: Check public resource API boundary
run: python3 cmux-tui/scripts/check-resource-api-boundary.py
- name: Test deterministic SDK generator
env:
PYTHONPATH: cmux-tui/bindings
run: python3 -m unittest discover -s cmux-tui/bindings/codegen/tests -v
- name: Check all generated SDK wire layers
run: python3 cmux-tui/bindings/codegen/generate.py --check
- name: Test package version guard
run: |
python3 -m unittest discover \
-s cmux-tui/bindings/tests \
-p 'test_*.py' \
-v
- name: Test SDK publishing workflow guards
run: python3 tests/test_tui_publish_workflow_security.py -v
- name: Check package versions
run: python3 cmux-tui/bindings/check-versions.py --published-only
- name: Test shared conformance runner
run: |
python3 -m unittest discover \
-s cmux-tui/bindings/conformance \
-p 'test_*.py' \
-v
packages:
name: ${{ matrix.language }} package
needs: contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
language:
- python
- typescript
- rust
- go
- java
- cpp
- zig
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python 3.9
if: matrix.language == 'python'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.9"
- name: Set up Node.js
if: matrix.language == 'typescript'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20.19.5"
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Set up Rust 1.88
if: matrix.language == 'rust'
run: |
rustup toolchain install 1.88.0 --profile minimal --component clippy,rustfmt
cargo +1.88.0 --version
rustc +1.88.0 --version
- name: Set up Go
if: matrix.language == 'go'
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.12"
cache-dependency-path: cmux-tui/bindings/go/go.mod
- name: Select JDK 17
if: matrix.language == 'java'
run: |
test -x "$JAVA_HOME_17_X64/bin/java"
echo "$JAVA_HOME_17_X64/bin" >> "$GITHUB_PATH"
echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV"
"$JAVA_HOME_17_X64/bin/java" -version
"$JAVA_HOME_17_X64/bin/javac" -version
- name: Select Clang C++20
if: matrix.language == 'cpp'
run: clang++ --version
- name: Set up Zig
if: matrix.language == 'zig'
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
- name: Test and install Python SDK
if: matrix.language == 'python'
env:
PYTHONPATH: cmux-tui/bindings/python
run: |
python3 -m pip install \
--disable-pip-version-check \
"setuptools==80.9.0"
python3 -m unittest discover -s cmux-tui/bindings/python/tests -v
python3 -m pip install \
--no-build-isolation \
--no-deps \
--target "$RUNNER_TEMP/cmux-python-package" \
./cmux-tui/bindings/python
CMUX_PYTHON_PACKAGE="$RUNNER_TEMP/cmux-python-package" python3 - <<'PY'
import importlib.metadata
import os
import pathlib
import sys
package = pathlib.Path(os.environ["CMUX_PYTHON_PACKAGE"]).resolve()
sys.path.insert(0, str(package))
import cmux
assert pathlib.Path(cmux.__file__).resolve().is_relative_to(package)
distribution = next(
item
for item in importlib.metadata.distributions(path=[str(package)])
if item.metadata["Name"] == "cmux-sdk"
)
assert not distribution.requires
PY
- name: Test packed TypeScript SDK
if: matrix.language == 'typescript'
working-directory: cmux-tui/bindings/typescript
run: |
npm ci --no-audit --no-fund
npm test
- name: Test and package Rust SDKs
if: matrix.language == 'rust'
working-directory: cmux-tui
run: |
cargo +1.88.0 fmt -p cmux-sdk -p cmux-sidebar -- --check
cargo +1.88.0 test \
-p cmux-sdk \
-p cmux-sidebar \
--all-targets \
--locked
cargo +1.88.0 test \
-p cmux-sdk \
-p cmux-sidebar \
--doc \
--locked
cargo +1.88.0 clippy \
-p cmux-sdk \
-p cmux-sidebar \
--all-targets \
--locked \
-- -D warnings
RUSTDOCFLAGS="-D warnings" \
cargo +1.88.0 doc \
-p cmux-sdk \
-p cmux-sidebar \
--locked \
--no-deps
cargo +1.88.0 package -p cmux-sdk --locked
# Full sidebar packaging resolves its versioned crates.io dependency.
# Publish cmux-sdk first; CI still verifies the exact sidebar file set.
cargo +1.88.0 package -p cmux-sidebar --locked --list
- name: Test Go SDK
if: matrix.language == 'go'
working-directory: cmux-tui/bindings/go
run: |
go test ./...
go test -race ./...
go vet ./...
- name: Test external Java jar consumer
if: matrix.language == 'java'
working-directory: cmux-tui/bindings/java
run: bash scripts/test.sh
- name: Test installed C++ package consumer
if: matrix.language == 'cpp'
env:
CC: clang
CXX: clang++
run: |
cmake \
-S cmux-tui/bindings/cpp \
-B "$RUNNER_TEMP/cmux-cpp-sdk" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS="-Wall -Wextra -Wpedantic -Werror"
cmake --build "$RUNNER_TEMP/cmux-cpp-sdk" --parallel
ctest --test-dir "$RUNNER_TEMP/cmux-cpp-sdk" --output-on-failure
- name: Test Zig SDK
if: matrix.language == 'zig'
working-directory: cmux-tui/bindings/zig
run: |
test "$(zig version)" = "0.15.2"
zig fmt --check build.zig src examples
zig build test
zig build
consumers:
name: ${{ matrix.language }} consumer
needs: contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
language:
- python
- typescript
- rust
- go
- java
- cpp
- zig
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python 3.9
if: matrix.language == 'python'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.9"
- name: Set up Node.js
if: matrix.language == 'typescript'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20.19.5"
cache: npm
cache-dependency-path: |
cmux-tui/bindings/typescript/package-lock.json
cmux-tui/bindings/examples/typescript-browser-controller/package-lock.json
- name: Set up Rust 1.88
if: matrix.language == 'rust'
run: |
rustup toolchain install 1.88.0 --profile minimal --component clippy,rustfmt
cargo +1.88.0 --version
rustc +1.88.0 --version
- name: Set up Go
if: matrix.language == 'go'
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.12"
cache-dependency-path: |
cmux-tui/bindings/go/go.mod
cmux-tui/bindings/examples/go-terminal-bot/go.mod
- name: Select JDK 17
if: matrix.language == 'java'
run: |
test -x "$JAVA_HOME_17_X64/bin/java"
echo "$JAVA_HOME_17_X64/bin" >> "$GITHUB_PATH"
echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV"
"$JAVA_HOME_17_X64/bin/java" -version
"$JAVA_HOME_17_X64/bin/javac" -version
- name: Select Clang C++20
if: matrix.language == 'cpp'
run: clang++ --version
- name: Set up Zig
if: matrix.language == 'zig'
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
- name: Test Python agent watchdog
if: matrix.language == 'python'
working-directory: cmux-tui/bindings/examples/python-agent-watchdog
env:
PYTHONPATH: ${{ github.workspace }}/cmux-tui/bindings/python
run: python3 -m unittest discover -s tests -v
- name: Test Python development orchestrator
if: matrix.language == 'python'
working-directory: cmux-tui/bindings/examples/python-dev-orchestrator
env:
PYTHONPATH: ${{ github.workspace }}/cmux-tui/bindings/python:${{ github.workspace }}/cmux-tui/bindings/examples/python-dev-orchestrator
run: python3 -m unittest discover -s tests -v
- name: Test TypeScript browser controller
if: matrix.language == 'typescript'
working-directory: cmux-tui/bindings/examples/typescript-browser-controller
run: |
npm ci --no-audit --no-fund
npm test
- name: Test Rust resource consumers
if: matrix.language == 'rust'
run: |
cargo +1.88.0 test \
--manifest-path cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.toml \
--locked
cargo +1.88.0 clippy \
--manifest-path cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.toml \
--locked \
--all-targets \
-- -D warnings
cargo +1.88.0 test \
--manifest-path cmux-tui/bindings/examples/rust-sidebar-monitor/Cargo.toml \
--locked \
--all-targets
cargo +1.88.0 clippy \
--manifest-path cmux-tui/bindings/examples/rust-sidebar-monitor/Cargo.toml \
--locked \
--all-targets \
-- -D warnings
- name: Test Go terminal bot
if: matrix.language == 'go'
working-directory: cmux-tui/bindings/examples/go-terminal-bot
run: |
go test ./...
go test -race ./...
go vet ./...
- name: Test Java CI orchestrator
if: matrix.language == 'java'
run: cmux-tui/bindings/examples/java-ci-orchestrator/scripts/test.sh
- name: Test installed C++ terminal frontend
if: matrix.language == 'cpp'
env:
CC: clang
CXX: clang++
run: |
cmake \
-S cmux-tui/bindings/cpp \
-B "$RUNNER_TEMP/cmux-cpp-install-build" \
-DCMAKE_BUILD_TYPE=Release \
-DCMUX_BUILD_TESTS=OFF
cmake --build "$RUNNER_TEMP/cmux-cpp-install-build" --parallel
cmake \
--install "$RUNNER_TEMP/cmux-cpp-install-build" \
--prefix "$RUNNER_TEMP/cmux-cpp-install"
cmake \
-S cmux-tui/bindings/examples/cpp-terminal-frontend \
-B "$RUNNER_TEMP/cmux-cpp-frontend" \
-DCMAKE_BUILD_TYPE=Release \
-DCMUX_CPP_SDK_DIR= \
-DCMAKE_PREFIX_PATH="$RUNNER_TEMP/cmux-cpp-install"
cmake --build "$RUNNER_TEMP/cmux-cpp-frontend" --parallel
ctest --test-dir "$RUNNER_TEMP/cmux-cpp-frontend" --output-on-failure
- name: Test Zig session supervisor
if: matrix.language == 'zig'
working-directory: cmux-tui/bindings/examples/zig-session-supervisor
run: |
test "$(zig version)" = "0.15.2"
zig fmt --check build.zig src tests
zig build test -Doptimize=Debug
zig build test -Doptimize=ReleaseSafe
zig build -Doptimize=Debug
zig build -Doptimize=ReleaseSafe
conformance:
name: seven-language live conformance
needs: contract
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Initialize Ghostty protocol submodule
run: git submodule update --init --depth 1 ghostty
- name: Install Linux build dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang libclang-dev pkg-config
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "20.19.5"
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Set up Rust 1.95
run: |
rustup toolchain install 1.95.0 --profile minimal
cargo +1.95.0 --version
rustc +1.95.0 --version
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.12"
cache-dependency-path: cmux-tui/bindings/go/go.mod
- name: Select JDK 17
run: |
test -x "$JAVA_HOME_17_X64/bin/java"
echo "$JAVA_HOME_17_X64/bin" >> "$GITHUB_PATH"
echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV"
"$JAVA_HOME_17_X64/bin/java" -version
"$JAVA_HOME_17_X64/bin/javac" -version
- name: Set up Zig for Ghostty
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.16.0
- name: Install TypeScript adapter build tools
working-directory: cmux-tui/bindings/typescript
run: npm ci --no-audit --no-fund
- name: Build exact headless cmux-tui
working-directory: cmux-tui
run: |
test "$(zig version)" = "0.16.0"
cargo +1.95.0 build -p cmux-tui --bin cmux-tui --locked
- name: Set up Zig for SDK conformance
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
- name: Run shared fake and live protocol contract
env:
CC: clang
CXX: clang++
CMUX_ZIG: zig
NODE_OPTIONS: --experimental-websocket
RUSTUP_TOOLCHAIN: 1.95.0
run: |
test "$(zig version)" = "0.15.2"
test "$(node -p 'typeof WebSocket')" = "function"
python3 cmux-tui/bindings/conformance/runner.py \
--require python,typescript,rust,go,java,cpp,zig \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui"
+56
View File
@@ -0,0 +1,56 @@
name: cmux-tui spec inventory
on:
push:
branches:
- main
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-spec.yml"
pull_request:
paths:
- "cmux-tui/**"
- ".github/workflows/cmux-tui-spec.yml"
workflow_dispatch:
concurrency:
group: cmux-tui-spec-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
inventory:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Test inventory checker
run: python3 cmux-tui/scripts/test_check_spec_inventory.py
- name: Check protocol and TUI action inventory
run: python3 cmux-tui/scripts/check-spec-inventory.py
- name: Test deterministic SDK generator
env:
PYTHONPATH: cmux-tui/bindings
run: python3 -m unittest discover -s cmux-tui/bindings/codegen/tests -v
- name: Test SDK schema checker
run: python3 cmux-tui/scripts/test_check_sdk_schema.py
- name: Check SDK schema against runtime inventory
run: python3 cmux-tui/scripts/check-sdk-schema.py
- name: Test public resource boundary checker
run: python3 cmux-tui/scripts/test_check_resource_api_boundary.py
- name: Check public resource API boundary
run: python3 cmux-tui/scripts/check-resource-api-boundary.py
- name: Check generated SDK wire layers
run: python3 cmux-tui/bindings/codegen/generate.py --check
+15 -1
View File
@@ -111,6 +111,9 @@ jobs:
# otherwise-correct build; the guarded regression (events serialized
# behind a 100ms read poll) inflates far past this bound anyway.
CMUX_TEST_WS_LATENCY_BUDGET_MS: "2000"
# Process-exit and PTY-reader tests also use bounded polling. Keep
# their normal deadlines strict while allowing for instrumentation.
CMUX_TEST_TIMEOUT_SCALE: "4"
run: |
while IFS= read -r bin; do
[ -n "$bin" ] || continue
@@ -170,6 +173,10 @@ jobs:
working-directory: cmux-tui
run: cargo test --workspace --locked
- name: crossterm parser tests
working-directory: cmux-tui
run: cargo test --manifest-path vendor/crossterm/Cargo.toml --lib
- name: TUI smoke test (scripted pty)
working-directory: cmux-tui
run: |
@@ -236,10 +243,17 @@ jobs:
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: 0.15.2
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust GNU target
shell: bash
+5 -1
View File
@@ -24,12 +24,16 @@ jobs:
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- run: bun install --frozen-lockfile
working-directory: web
- run: |
mkdir -p .vercel
printf '{"orgId":"%s","projectId":"%s"}' "$VERCEL_ORG_ID" "$VERCEL_PROJECT_ID" > .vercel/project.json
bunx vercel deploy --prod --yes --token "$VERCEL_TOKEN" \
# Vercel reloads config from the linked project's web root.
cp web/vercel.docs-channel.json web/vercel.json
bunx [email protected] deploy --prod --yes \
--build-env "CMUX_DOCS_CHANNEL=$DOCS_CHANNEL" \
--env "CMUX_DOCS_CHANNEL=$DOCS_CHANNEL"
env:
+52
View File
@@ -0,0 +1,52 @@
name: Notify IndexNow
on:
deployment_status:
workflow_dispatch:
permissions:
contents: read
jobs:
dispatch:
name: Dispatch notification run
if: >-
github.event_name == 'deployment_status' &&
github.event.deployment_status.state == 'success' &&
github.event.deployment.environment == 'Production cmux'
permissions:
# Required to start the secret-bearing workflow_dispatch run.
actions: write
contents: read
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 2
steps:
- name: Dispatch authenticated submission
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
GH_TOKEN: ${{ github.token }}
run: >-
gh workflow run indexnow.yml
--repo "$GITHUB_REPOSITORY"
--ref "$DEFAULT_BRANCH"
notify:
name: Notify IndexNow
if: github.event_name == 'workflow_dispatch'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 2
steps:
- name: Submit changed URLs
env:
INDEXNOW_TRIGGER_SECRET: ${{ secrets.INDEXNOW_TRIGGER_SECRET }}
run: |
test -n "$INDEXNOW_TRIGGER_SECRET"
curl --request POST \
--fail-with-body \
--silent \
--show-error \
--max-time 30 \
--retry 2 \
--retry-all-errors \
--header "Authorization: Bearer $INDEXNOW_TRIGGER_SECRET" \
https://cmux.com/api/cron/indexnow
+345 -330
View File
@@ -1,17 +1,14 @@
name: iOS TestFlight (beta)
name: iOS TestFlight (CMUX INTERNAL)
on:
# No push trigger. A TestFlight upload is a release action: you only ever want
# the LATEST main state in beta, exactly once per change, never one upload per
# commit. Triggering on every iOS-affecting push forced a concurrency group to
# dedup concurrent uploads, and GitHub cancels the superseded *pending* runs in
# that group during merge bursts; those cancelled runs surface as red checks on
# the intermediate main commits, making main look like CI is failing. The
# schedule below already SHA-compares HEAD to the last uploaded commit (the
# `decide` job), which is the correct primitive for a beta lane: it uploads the
# current main only when it has actually advanced, and skips (green) otherwise.
# For an immediate beta, use workflow_dispatch; intentional cuts go through the
# release flow. See nightly.yml for the rolling dogfood lane.
# Poll main every 20 minutes and batch merges into one upload. The decide job
# skips scheduled runs when main is unchanged or the changes do not affect iOS.
# Manual dispatch remains available for intentional rebuilds.
schedule:
# Check current main for a cmux INTERNAL upload every 20 minutes.
- cron: "7,27,47 * * * *"
# Twice-daily (every 12 hours) cmux DEMO upload of current main.
- cron: "37 5,17 * * *"
workflow_dispatch:
inputs:
build_number:
@@ -22,40 +19,23 @@ on:
description: Optional one-time MARKETING_VERSION override (for example 1.0.1)
required: false
default: ""
force:
# Manual (workflow_dispatch) runs always upload, so this only documents
# intent. It exists so the no-new-commits skip can be bypassed if the
# 24h commit-window check is ever extended to dispatch runs.
description: Force an upload (manual runs already always upload)
variant:
description: >-
Upload variant. internal (default) ships dev.cmux.app.internal /
"cmux INTERNAL"; demo ships dev.cmux.app.demo / "cmux DEMO" with the
DEMO-badged app icon and assigns to the cmux DEMO TestFlight group.
required: false
default: false
type: boolean
schedule:
# Every ~2h (at :17 to stay off the top-of-hour rush). The decide job skips a
# run when the current main HEAD was already uploaded by a prior successful
# run (SHA compare, not a wall-clock window), and also when main advanced but
# the diff since the last uploaded beta touches no iOS-affecting path (every
# upload notifies every TestFlight tester, so web-only / macOS-only merges
# must not ship a new beta). An iOS-affecting change still reaches the
# TestFlight beta lane within ~2h of landing on main, and a failed or missed
# run retries the not-yet-uploaded commit instead of permanently stranding
# it. These uploads are external-eligible too, and reuse
# CMUX_IOS_BETA_MARKETING_VERSION so external testers receive new builds under
# the already-approved beta version until that version is intentionally bumped.
#
# Why ~2h and not a push trigger / per-commit: a per-push lane needs either a
# shared concurrency group (which cancels superseded pending runs into red
# checks) or per-SHA concurrency (which removes the serialization that keeps
# parallel archives from racing on the timestamp build number). ~2h spacing
# keeps runs from overlapping (an archive+upload takes ~30-60m), so this
# single per-ref lane stays serialized and uploads never collide. If faster
# turnaround is ever needed, tighten the interval (still > one archive's
# duration) rather than adding a push trigger.
- cron: "17 */2 * * *"
default: internal
type: choice
options:
- internal
- demo
concurrency:
group: ios-testflight-${{ github.ref_name }}
# Queue concurrent runs instead of canceling them so no upload is lost.
# Every run keys by run_id: scheduled runs must not cancel each other (the
# decide job already serializes uploads), and an operator may intentionally
# rebuild via dispatch.
group: ios-testflight-${{ github.run_id }}
cancel-in-progress: false
permissions:
@@ -65,46 +45,127 @@ permissions:
jobs:
decide:
name: Decide whether a TestFlight upload is needed
name: Order main uploads and resolve prior build
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
timeout-minutes: 360
outputs:
should_build: ${{ steps.decide.outputs.should_build }}
should_assign_only: ${{ steps.decide.outputs.should_assign_only }}
last_uploaded_sha: ${{ steps.decide.outputs.last_uploaded_sha }}
last_uploaded_run_id: ${{ steps.decide.outputs.last_uploaded_run_id }}
variant: ${{ steps.decide.outputs.variant }}
steps:
- name: Decide whether a TestFlight upload is needed
id: decide
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
FORCE_BUILD: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.force == 'true' && 'true' || 'false' }}
with:
script: |
const forceBuild = process.env.FORCE_BUILD === 'true';
const { owner, repo } = context.repo;
// The head_sha of the most recent CANONICAL run on main whose *upload
// job* succeeded is the last uploaded beta commit. We intentionally do
// NOT key this off whole-workflow success: a later post-upload job (for
// example external-group assignment) may fail after the IPA has already
// been uploaded, and re-uploading the same SHA on the next schedule
// would create duplicate TestFlight builds for one commit. We always
// resolve this SHA so the schedule SHA-compare can skip an already-
// uploaded HEAD, AND the upload job can use it as the base of the
// "What to Test" commit range. branch:'main' is required: the upload
// job is gated on github.ref == 'refs/heads/main', so a
// workflow_dispatch run on a feature branch can succeed without
// uploading anything. Without this filter its branch SHA would become
// last_uploaded_sha and poison the next real beta's notes base (the
// generator also fails closed to a fallback line when the base is not
// an ancestor of HEAD).
// Resolve the upload variant once. Scheduled runs select the variant
// by which cron fired; dispatch runs use the variant input. Every
// downstream job reads needs.decide.outputs.variant instead of
// re-deriving it from event fields.
const internalCron = '7,27,47 * * * *';
const demoCron = '37 5,17 * * *';
const requestedVariant = context.payload?.inputs?.variant;
const schedule = context.payload?.schedule;
let variant;
if (context.eventName === 'schedule' && schedule === internalCron) {
variant = 'internal';
} else if (context.eventName === 'schedule' && schedule === demoCron) {
variant = 'demo';
} else if (
context.eventName === 'workflow_dispatch' &&
['internal', 'demo'].includes(requestedVariant)
) {
variant = requestedVariant;
} else {
core.setFailed('unsupported TestFlight event, schedule, or variant');
return;
}
const canonicalArtifactName = variant === 'demo'
? 'ios-testflight-build-metadata-demo'
: 'ios-testflight-build-metadata';
// Per-SHA workflow concurrency preserves every push, but it also lets
// several runs reach App Store Connect at once. Build numbers must be
// uploaded monotonically, so wait on a cheap Linux runner until every
// earlier main-push run has finished its upload job. Assignment can
// continue independently after the upload completes.
if (context.ref === 'refs/heads/main') {
const currentRunId = Number(context.runId);
const maxWaits = 300;
for (let wait = 0; wait < maxWaits; wait += 1) {
const blockingRuns = [];
try {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'ios-testflight.yml',
branch: 'main',
per_page: 100,
});
const earlierActiveRuns = runs.data.workflow_runs.filter(
(run) =>
Number(run.id) < currentRunId &&
run.status !== 'completed' &&
['push', 'schedule', 'workflow_dispatch'].includes(run.event)
);
for (const run of earlierActiveRuns) {
const jobs = await github.rest.actions.listJobsForWorkflowRun({
owner,
repo,
run_id: run.id,
per_page: 100,
});
const uploadJob = jobs.data.jobs.find(
(job) => job.name === 'Upload to TestFlight'
);
if (!uploadJob || uploadJob.status !== 'completed') {
blockingRuns.push(run.id);
}
}
} catch (error) {
if (wait === maxWaits - 1) {
core.setFailed(
`timed out ordering main TestFlight uploads after GitHub API errors: ${error.message}`
);
return;
}
if (wait % 5 === 0) {
core.warning(
`could not inspect earlier TestFlight runs; retrying: ${error.message}`
);
}
await new Promise((resolve) => setTimeout(resolve, 60_000));
continue;
}
if (blockingRuns.length === 0) break;
if (wait === maxWaits - 1) {
core.setFailed(
`timed out waiting for earlier main TestFlight upload run(s): ${blockingRuns.join(', ')}`
);
return;
}
if (wait % 5 === 0) {
core.info(
`waiting for earlier main TestFlight upload run(s): ${blockingRuns.join(', ')}`
);
}
await new Promise((resolve) => setTimeout(resolve, 60_000));
}
}
// Resolve the most recent successful canonical upload as the base for
// this build's "What to Test" commit range. Metadata artifacts are
// written only after the upload succeeds, and the repository artifact
// response includes the originating run's branch and head SHA. Looking
// them up directly keeps skipped schedule runs out of this history scan.
//
// Manual marketing-version-override uploads are deliberately excluded
// from this canonical lane. They ship the current main SHA under an
// operator-selected beta marketing version as a one-off escape hatch, but
// they must NOT suppress the next scheduled canonical beta for the
// same commit. Override runs upload a dedicated
// they must not become the notes base for the next canonical main
// upload. Override runs upload a dedicated
// ios-testflight-build-metadata-override artifact instead of the
// canonical ios-testflight-build-metadata artifact, and we skip only
// those runs here.
@@ -115,206 +176,144 @@ jobs:
// is necessarily a normal immediate beta cut and SHOULD count as the
// last canonical upload.
let lastUploadedSha = null;
let lastUploadedRunId = null;
let lastAssignmentSucceeded = false;
let lastAssignmentRetrySupported = false;
let lookupFailed = false;
let uploadHistoryKnown = true;
try {
for (let page = 1; page <= 20 && !lastUploadedSha; page += 1) {
const runs = await github.rest.actions.listWorkflowRuns({
const perPage = 100;
const firstPage = await github.rest.actions.listArtifactsForRepo({
owner,
repo,
name: canonicalArtifactName,
per_page: perPage,
page: 1,
});
const totalCount = Number(firstPage.data.total_count);
if (!Number.isSafeInteger(totalCount) || totalCount < 0) {
throw new Error('invalid artifact count');
}
const pageCount = Math.max(1, Math.ceil(totalCount / perPage));
let latestArtifact = null;
const considerArtifacts = (artifacts) => {
for (const artifact of artifacts) {
const run = artifact.workflow_run;
if (
artifact.name !== canonicalArtifactName ||
run?.head_branch !== 'main' ||
!run.head_sha ||
Number(run.id) === Number(context.runId)
) {
continue;
}
const createdAt = Date.parse(artifact.created_at);
if (!Number.isFinite(createdAt)) {
throw new Error('invalid artifact creation time');
}
if (
!latestArtifact ||
createdAt > latestArtifact.createdAt ||
(createdAt === latestArtifact.createdAt &&
Number(artifact.id) > Number(latestArtifact.id))
) {
latestArtifact = {
id: artifact.id,
createdAt,
headSha: run.head_sha,
};
}
}
};
considerArtifacts(firstPage.data.artifacts || []);
for (let page = 2; page <= pageCount; page += 1) {
const response = await github.rest.actions.listArtifactsForRepo({
owner,
repo,
workflow_id: 'ios-testflight.yml',
branch: 'main',
per_page: 100,
name: canonicalArtifactName,
per_page: perPage,
page,
});
for (const run of runs.data.workflow_runs) {
if (run.id === context.runId || run.status !== 'completed') continue;
const jobs = await github.rest.actions.listJobsForWorkflowRun({
considerArtifacts(response.data.artifacts || []);
}
lastUploadedSha = latestArtifact?.headSha || null;
} catch {
uploadHistoryKnown = false;
core.warning('could not resolve last uploaded sha; skipping scheduled upload');
}
// Manual runs are intentional rebuilds and always build. Scheduled
// runs batch merges instead of shipping each one: skip when this
// variant already shipped the current head, or when the delta since
// the last upload touches no iOS-relevant paths, so idle hours never
// spend App Store Connect upload quota.
const iosRelevantPaths = [
'ios/',
'Packages/iOS/',
'Packages/Shared/',
'Sources/Mobile/',
'vendor/stack-auth-swift-sdk-prerelease/',
'ghostty',
'ghostty.h',
'scripts/ensure-ghosttykit.sh',
'scripts/ghosttykit-checksums.txt',
'scripts/install-zig-ci.sh',
'scripts/ghostty-zig-version.sh',
'scripts/validate-xcframework-archive.py',
'.github/workflows/ios-testflight.yml',
];
const touchesIOS = (filename) =>
iosRelevantPaths.some((p) =>
p.endsWith('/') ? filename.startsWith(p) : filename === p
);
let shouldBuild = true;
let reason = 'manual dispatch';
if (context.eventName === 'schedule') {
if (!uploadHistoryKnown) {
shouldBuild = false;
reason = 'upload history unavailable';
} else if (!lastUploadedSha) {
reason = 'no prior upload found for this variant';
} else if (lastUploadedSha === context.sha) {
shouldBuild = false;
reason = 'main unchanged since last upload';
} else {
// Fail open: when the compare is unavailable or truncated, build
// rather than silently skipping a real iOS change.
reason = 'new commits since last upload';
try {
const compare = await github.rest.repos.compareCommits({
owner,
repo,
run_id: run.id,
per_page: 100,
base: lastUploadedSha,
head: context.sha,
});
const uploadJob = jobs.data.jobs.find((job) => job.name === 'Upload to TestFlight');
if (uploadJob?.conclusion === 'success') {
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner,
repo,
run_id: run.id,
per_page: 100,
});
const artifactNames = new Set(
(artifacts.data.artifacts || []).map((artifact) => artifact.name)
);
if (
artifactNames.has('ios-testflight-build-metadata-override') &&
!artifactNames.has('ios-testflight-build-metadata')
) {
continue;
}
lastUploadedSha = run.head_sha;
lastUploadedRunId = String(run.id);
const assignJob = jobs.data.jobs.find(
(job) =>
job.name === 'Assign build to internal TestFlight group' ||
job.name === 'Assign build to external TestFlight group'
);
// Older successful upload runs predate the external-assignment
// job and metadata artifact entirely. Those runs uploaded the
// current main SHA, but they are NOT safe to treat as
// assign-only retry candidates because there is no artifact to
// download and the build may not even be external-eligible.
// Only the post-migration workflow shape can enter the
// assignment-only path.
lastAssignmentRetrySupported = !!assignJob;
// A same-version sibling already in Beta App Review is a
// legitimate "pending" state outside CI's control, so the
// assign job returns success but uploads a dedicated pending
// artifact. That lets the schedule retry assignment-only
// later without turning the current main commit red.
//
// Fail closed for RECENT pre-migration success runs that
// have NO assignment-state artifact at all. The old helper
// could report success both when the build was truly
// complete and when it was merely pending behind a sibling
// review, so a fresh missing-state run should be retried; a
// genuinely-complete build will short-circuit quickly on
// the recheck.
//
// Do NOT fail closed forever: artifacts expire after 30
// days, and an idle main branch must not fall into
// permanent red assign-only retries just because historical
// metadata aged out. Once the run is past the retention
// horizon, treat missing state as effectively complete.
const assignmentComplete =
artifactNames.has('ios-testflight-assignment-state-complete');
const assignmentPending =
artifactNames.has('ios-testflight-assignment-state-pending');
const runAgeMs = Date.now() - Date.parse(run.created_at);
const assignmentArtifactRetentionMs = 30 * 24 * 60 * 60 * 1000;
const assignmentStateExpired = runAgeMs > assignmentArtifactRetentionMs;
lastAssignmentSucceeded =
assignJob?.conclusion === 'success' &&
(assignmentComplete || (!assignmentPending && assignmentStateExpired));
break;
const files = compare.data.files || [];
const truncated = files.length >= 300;
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
// an iOS change out of beta.
const iosPathPattern = /^(ios\/|Packages\/Shared\/|Packages\/iOS\/|Sources\/Mobile\/|vendor\/stack-auth-swift-sdk-prerelease\/|ghostty$|scripts\/ensure-ghosttykit\.sh$|scripts\/ghosttykit-checksums\.txt$|scripts\/install-zig-ci\.sh$|\.github\/workflows\/ios-testflight\.yml$)/;
let iosPathsChanged = 'not-evaluated';
if (!forceBuild && context.eventName === 'schedule' && needsBuild && lastUploadedSha) {
try {
const compare = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${lastUploadedSha}...${context.sha}`,
});
const files = compare.data.files || [];
if (compare.data.status !== 'ahead') {
core.warning(`compare status is '${compare.data.status}', not 'ahead'; assuming iOS paths changed`);
iosPathsChanged = 'assumed (non-linear history)';
} else if (files.length >= 300) {
// The compare API caps the files list at 300 entries.
core.warning('diff since last upload has >=300 files (API truncates); assuming iOS paths changed');
iosPathsChanged = 'assumed (diff too large)';
} else {
const changed = files.some(
(f) =>
iosPathPattern.test(f.filename) ||
(f.previous_filename && iosPathPattern.test(f.previous_filename))
);
iosPathsChanged = String(changed);
if (!changed) {
needsBuild = false;
core.notice(`skipping TestFlight upload: ${files.length} changed file(s) since ${lastUploadedSha} touch no iOS-affecting path`);
}
}
} catch (e) {
core.warning(`could not diff ${lastUploadedSha}...${context.sha}; assuming iOS paths changed: ${e.message}`);
iosPathsChanged = 'assumed (compare failed)';
}
}
// Retry a not-yet-complete external-group assignment whenever this
// run is NOT building: both when HEAD was already uploaded and when
// the path gate skipped a non-iOS range (the previously uploaded
// build is still the current beta and its assignment must not be
// stranded until the next iOS change lands).
const shouldAssignOnly =
!forceBuild &&
context.eventName === 'schedule' &&
!needsBuild &&
lastAssignmentRetrySupported &&
!lastAssignmentSucceeded;
const shouldBuild =
forceBuild || context.eventName === 'workflow_dispatch' || needsBuild;
core.setOutput('should_build', shouldBuild ? 'true' : 'false');
core.setOutput('should_assign_only', shouldAssignOnly ? 'true' : 'false');
core.setOutput('last_uploaded_sha', lastUploadedSha || '');
core.setOutput('last_uploaded_run_id', lastUploadedRunId || '');
core.setOutput('variant', variant);
core.summary
.addHeading('iOS TestFlight upload decision')
.addTable([
[{ data: 'event', header: true }, context.eventName],
[{ data: 'force', header: true }, String(forceBuild)],
[{ data: 'variant', header: true }, variant],
[{ data: 'head sha', header: true }, context.sha],
[{ data: 'last uploaded sha (schedule only)', header: true }, String(lastUploadedSha)],
[{ data: 'last uploaded run id', header: true }, String(lastUploadedRunId)],
[{ data: 'ios paths changed since last upload', header: true }, iosPathsChanged],
[{ data: 'last external assignment succeeded', header: true }, String(lastAssignmentSucceeded)],
[{ data: 'last uploaded sha (notes base)', header: true }, String(lastUploadedSha)],
[{ data: 'should build', header: true }, String(shouldBuild)],
[{ data: 'should assign only', header: true }, String(shouldAssignOnly)],
[{ data: 'reason', header: true }, reason],
])
.write();
upload:
name: Upload to TestFlight
needs: decide
# Only ever publish from main. push/schedule always run on main; this also
# Only ever publish from main. Push runs always use main; this also
# blocks publishing arbitrary code by dispatching the workflow against a
# feature branch (the ASC secrets are only meant to ship reviewed main).
if: needs.decide.outputs.should_build == 'true' && github.ref == 'refs/heads/main'
@@ -322,16 +321,15 @@ jobs:
timeout-minutes: 60
outputs:
final_build_number: ${{ steps.upload.outputs.final_build_number }}
bundle_id: ${{ steps.distribution.outputs.bundle_id }}
assign_internal_group: ${{ steps.distribution.outputs.assign_internal_group }}
env:
ASC_API_KEY_ID: ${{ secrets.ASC_API_KEY_ID }}
ASC_API_ISSUER_ID: ${{ secrets.ASC_API_ISSUER_ID }}
ASC_API_KEY_P8_BASE64: ${{ secrets.ASC_API_KEY_P8_BASE64 }}
CMUX_TESTFLIGHT_EXTERNAL_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_EXTERNAL_GROUP_ID }}
CMUX_TESTFLIGHT_EXTERNAL_GROUP_NAME: ${{ vars.IOS_TESTFLIGHT_EXTERNAL_GROUP_NAME }}
CMUX_TESTFLIGHT_ASSIGN_EXTERNAL_GROUP: "0"
# Internal builds use separate bundle ID and display name (set here so provisioning profile step can use it)
IOS_BETA_BUNDLE_ID: dev.cmux.app.internal
IOS_BETA_DISPLAY_NAME: cmux INTERNAL
CMUX_TESTFLIGHT_PRO_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_PRO_GROUP_ID }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -343,6 +341,18 @@ jobs:
fetch-depth: 0
fetch-tags: true
- name: Resolve TestFlight distribution
id: distribution
env:
INPUT_VARIANT: ${{ needs.decide.outputs.variant }}
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
run: |
python3 ./ios/scripts/resolve_testflight_distribution.py \
--variant "$INPUT_VARIANT" \
--marketing-version-override "$INPUT_MARKETING_VERSION_OVERRIDE" \
--github-env "$GITHUB_ENV" \
--github-output "$GITHUB_OUTPUT"
- name: Select Xcode
run: |
set -euo pipefail
@@ -436,54 +446,69 @@ jobs:
IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64: ${{ secrets.IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64 }}
run: |
set -euo pipefail
# Determine which profile to use based on bundle ID
if [ "${IOS_BETA_BUNDLE_ID:-dev.cmux.app.beta}" = "dev.cmux.app.internal" ]; then
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64}"
EXPECTED_APP_ID="7WLXT3NR37.dev.cmux.app.internal"
PROFILE_TYPE="internal"
SKIP_APS_ENVIRONMENT_CHECK=false
else
if [ "$IOS_BETA_PROFILE_TYPE" = "beta" ]; then
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_BASE64}"
EXPECTED_APP_ID="7WLXT3NR37.dev.cmux.app.beta"
PROFILE_TYPE="beta"
SKIP_APS_ENVIRONMENT_CHECK=false
fi
if [ -z "${PROFILE_BASE64:-}" ]; then
echo "Missing provisioning profile secret for $PROFILE_TYPE" >&2
elif [ "$IOS_BETA_PROFILE_TYPE" = "demo" ]; then
# The demo profile is fetched from the ASC API by name instead of a
# repository secret, so regenerating it in the developer portal
# needs no secret rotation. Same credentials the upload uses.
PROFILE_BASE64="$(python3 ./ios/scripts/asc_download_profile.py --name "cmux Demo Distribution")"
elif [ "$IOS_BETA_PROFILE_TYPE" = "internal" ]; then
PROFILE_BASE64="${IOS_BETA_PROVISIONING_PROFILE_INTERNAL_BASE64}"
else
echo "Unsupported TestFlight profile type: $IOS_BETA_PROFILE_TYPE" >&2
exit 1
fi
TMP_PROFILE="$RUNNER_TEMP/cmux-${PROFILE_TYPE}.mobileprovision"
TMP_PLIST="$RUNNER_TEMP/cmux-${PROFILE_TYPE}-profile.plist"
if [ -z "${PROFILE_BASE64:-}" ]; then
echo "Missing provisioning profile secret for $IOS_BETA_PROFILE_TYPE" >&2
exit 1
fi
TMP_PROFILE="$RUNNER_TEMP/cmux-${IOS_BETA_PROFILE_TYPE}.mobileprovision"
TMP_PLIST="$RUNNER_TEMP/cmux-${IOS_BETA_PROFILE_TYPE}-profile.plist"
printf '%s' "$PROFILE_BASE64" | base64 --decode > "$TMP_PROFILE"
security cms -D -i "$TMP_PROFILE" > "$TMP_PLIST"
APP_ID="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:application-identifier" "$TMP_PLIST")"
if [ "$APP_ID" != "$EXPECTED_APP_ID" ]; then
echo "$PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $EXPECTED_APP_ID)" >&2
if [ "$APP_ID" != "$IOS_BETA_EXPECTED_APP_ID" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile targets unexpected app ID: $APP_ID (expected $IOS_BETA_EXPECTED_APP_ID)" >&2
exit 1
fi
# Check aps-environment for every TestFlight provisioning profile.
if [ "${SKIP_APS_ENVIRONMENT_CHECK:-false}" != "true" ]; then
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
echo "$PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
exit 1
fi
APS_ENVIRONMENT="$(/usr/libexec/PlistBuddy -c "Print :Entitlements:aps-environment" "$TMP_PLIST" 2>/dev/null || echo "")"
if [ -z "$APS_ENVIRONMENT" ] || [ "$APS_ENVIRONMENT" != "production" ]; then
echo "$IOS_BETA_PROFILE_TYPE provisioning profile aps-environment is '$APS_ENVIRONMENT', expected 'production'" >&2
exit 1
fi
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c "Print :Name" "$TMP_PLIST")"
PROFILE_UUID="$(/usr/libexec/PlistBuddy -c "Print :UUID" "$TMP_PLIST")"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$TMP_PROFILE" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision"
echo "IOS_BETA_PROVISIONING_PROFILE_NAME=$PROFILE_NAME" >> "$GITHUB_ENV"
echo "Installed $PROFILE_TYPE provisioning profile: $PROFILE_NAME ($PROFILE_UUID)"
echo "Installed $IOS_BETA_PROFILE_TYPE provisioning profile: $PROFILE_NAME ($PROFILE_UUID)"
- name: Use DEMO-badged app icon
if: needs.decide.outputs.variant == 'demo'
run: |
set -euo pipefail
# Swap the AppIcon PNGs in the CI checkout instead of overriding
# ASSETCATALOG_COMPILER_APPICON_NAME on the xcodebuild command line:
# command-line build settings apply to every target in the workspace,
# and SwiftPM resource-bundle targets with their own asset catalogs
# would fail actool with a missing "AppIcon-Demo" icon set.
SRC="ios/cmux/Assets.xcassets/AppIcon-Demo.appiconset"
DST="ios/cmux/Assets.xcassets/AppIcon.appiconset"
for f in AppIcon.png AppIconDark.png AppIconTinted.png; do
cp "$SRC/$f" "$DST/$f"
done
echo "Installed DEMO-badged icon variants into AppIcon.appiconset"
- name: Archive, export, and upload to TestFlight
id: upload
env:
IOS_BETA_BUNDLE_ID: ${{ steps.distribution.outputs.bundle_id }}
# Only set for manual workflow_dispatch with an explicit build number.
# For push/schedule this is empty and the script generates a monotonic
# For push this is empty and the script generates a monotonic
# 14-digit UTC timestamp itself (single source of the numbering scheme,
# so the workflow can't drift from it the way it did before). An empty
# value here means "let the script decide", which also keeps the guard's
@@ -493,6 +518,10 @@ jobs:
# The script writes the CFBundleVersion that actually shipped here (the
# monotonic guard may bump it), so the summary reports the real value.
CMUX_BUILD_NUMBER_OUT_FILE: ${{ runner.temp }}/cmux-final-build-number.txt
# Pin the archive/export workspace: the default is a /tmp dir keyed by
# the build number, which this workflow cannot compute, and the
# post-upload dSYM artifact step must find the archive's dSYM bundle.
CMUX_IOS_UPLOAD_DIR: ${{ runner.temp }}/cmux-ios-upload
# The previous beta's commit (the last successful run's head_sha): base
# of the per-build "What to Test" commit range. Empty on the very first
# run / a missing history, where the generator falls back gracefully.
@@ -500,14 +529,9 @@ jobs:
# Optional manual one-off override that reuses an older approved beta
# marketing version so external testers can install it immediately.
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
# Display name for scheduled internal builds (auto-synced to internal group).
IOS_BETA_DISPLAY_NAME: cmux INTERNAL
# Internal builds use separate bundle ID (dev.cmux.app.internal) so internal
# and external can coexist on same device.
IOS_BETA_BUNDLE_ID: dev.cmux.app.internal
run: |
set -euo pipefail
if [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then
if [ "$IOS_TESTFLIGHT_UPLOAD_MODE" = "marketing_version_override" ]; then
# One-time operator escape hatch: upload latest main as another build
# of an already-approved beta marketing version, which avoids starting a
# fresh Beta App Review for that version. This path intentionally
@@ -518,15 +542,13 @@ jobs:
echo "build_number is not supported together with marketing_version_override in the cloud override path" >&2
exit 1
fi
unset IOS_BETA_DISPLAY_NAME
unset IOS_BETA_BUNDLE_ID
./ios/scripts/cloud-testflight.sh \
--external \
--marketing-version "$INPUT_MARKETING_VERSION_OVERRIDE" \
--skip-notes
else
# Reuse the checked-in CMUX_IOS_BETA_MARKETING_VERSION for scheduled betas.
# Scheduled builds use:
# Reuse the checked-in CMUX_IOS_BETA_MARKETING_VERSION for automatic betas.
# Main-push builds use:
# - "cmux INTERNAL" display name
# - dev.cmux.app.internal bundle ID (separate app, can coexist with external)
# - assigned to internal TestFlight group for dogfooding within the team
@@ -555,33 +577,33 @@ jobs:
env:
BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number || github.event.inputs.build_number || 'unknown' }}
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
UPLOAD_BUNDLE_ID: ${{ steps.distribution.outputs.bundle_id }}
UPLOAD_DISPLAY_NAME: ${{ steps.distribution.outputs.display_name }}
UPLOAD_AUDIENCE: ${{ steps.distribution.outputs.audience }}
UPLOAD_REVIEW_NOTE: ${{ steps.distribution.outputs.review_note }}
run: |
{
echo "### iOS TestFlight upload"
echo
echo "- lane: \`beta\` (bundle id \`dev.cmux.app.internal\`, internal TestFlight)"
echo "- lane: \`beta\` (bundle id \`${UPLOAD_BUNDLE_ID}\`, ${UPLOAD_AUDIENCE})"
echo "- signing: manual (CI-imported iOS distribution cert + beta profile)"
if [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then
echo "- marketing version override: \`${INPUT_MARKETING_VERSION_OVERRIDE}\`"
echo "- external groups: Founder's Edition and cmux Pro"
else
echo "- marketing version: checked-in beta marketing version"
fi
echo "- build number (CFBundleVersion): \`${BUILD_NUMBER}\`"
echo "- audience: internal TestFlight group (cmux INTERNAL) on the dev.cmux.app.internal app; no beta review needed"
echo "- audience: ${UPLOAD_AUDIENCE} (${UPLOAD_DISPLAY_NAME}) on the ${UPLOAD_BUNDLE_ID} app; ${UPLOAD_REVIEW_NOTE}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Persist uploaded build metadata
if: success()
env:
FINAL_BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number }}
INPUT_MARKETING_VERSION_OVERRIDE: ${{ github.event.inputs.marketing_version_override }}
UPLOAD_MODE: ${{ steps.distribution.outputs.upload_mode }}
run: |
set -euo pipefail
if [ -n "${INPUT_MARKETING_VERSION_OVERRIDE:-}" ]; then
UPLOAD_MODE="marketing_version_override"
else
UPLOAD_MODE="checked_in_version"
fi
cat > "$RUNNER_TEMP/ios-testflight-build.json" <<EOF
{"head_sha":"${GITHUB_SHA}","build_number":"${FINAL_BUILD_NUMBER}","upload_mode":"${UPLOAD_MODE}"}
EOF
@@ -590,23 +612,45 @@ jobs:
if: success()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ github.event.inputs.marketing_version_override != '' && 'ios-testflight-build-metadata-override' || 'ios-testflight-build-metadata' }}
# Variant-specific names keep the decide job's last-upload lookup (and
# therefore skip logic + notes ranges) independent per app.
name: ${{ steps.distribution.outputs.metadata_artifact }}
path: ${{ runner.temp }}/ios-testflight-build.json
retention-days: 30
- name: Persist dSYM bundle as run artifact
# The runner is ephemeral, so the archive's dSYMs are the only durable
# copy of this build's symbols besides the Symbols/ files uploaded
# inside the IPA; without this artifact, a TestFlight crash whose
# symbols ASC cannot serve is permanently unsymbolicatable (build
# 20260730090940). Keyed by variant + CFBundleVersion so a crash
# report's build number finds the right bundle. The override path is
# excluded like the canonical metadata artifact: it archives on a fleet
# Mac via cloud-testflight.sh, not under CMUX_IOS_UPLOAD_DIR.
# Gated on the UPLOAD step's outcome, not whole-job success(): once the
# IPA reached TestFlight its symbols must be persisted even if a later
# step (summary/metadata artifact) failed, or a shipped build becomes
# unsymbolicatable again.
if: ${{ !cancelled() && steps.upload.outcome == 'success' && steps.distribution.outputs.assign_internal_group == '1' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ios-dsyms-${{ needs.decide.outputs.variant }}-${{ steps.upload.outputs.final_build_number }}
path: ${{ runner.temp }}/cmux-ios-upload/cmux.xcarchive/dSYMs
if-no-files-found: error
retention-days: 30
- name: Cleanup keychain
if: always()
run: |
security delete-keychain ios-testflight.keychain >/dev/null 2>&1 || true
# NOTE: this lane uploads to dev.cmux.app.internal only, so there is no
# external-group assignment job here. The old assign-external-group job
# polled dev.cmux.app.beta for a build that now never arrives there and hung
# for its full 40-minute timeout on every run.
# Normal and demo runs assign their internal group after upload. A manual
# external marketing-version override assigns Founder's Edition and Pro
# inline in cloud-testflight.sh, so it must skip this internal-app lookup.
assign-internal-group:
name: Assign build to internal TestFlight group
needs: [decide, upload]
if: (needs.decide.outputs.should_build == 'true' || needs.decide.outputs.should_assign_only == 'true') && github.ref == 'refs/heads/main' && (needs.upload.result == 'success' || needs.decide.outputs.should_assign_only == 'true')
if: github.ref == 'refs/heads/main' && needs.upload.result == 'success' && needs.upload.outputs.assign_internal_group == '1'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 40
env:
@@ -618,51 +662,22 @@ jobs:
# defaults and hard-errors when both are set ("set only one of --group-id
# or --group-name"), which failed every internal assignment while both
# repo variables were wired in.
CMUX_TESTFLIGHT_INTERNAL_GROUP_ID: ${{ vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }}
GH_TOKEN: ${{ github.token }}
SHOULD_BUILD: ${{ needs.decide.outputs.should_build }}
SHOULD_ASSIGN_ONLY: ${{ needs.decide.outputs.should_assign_only }}
LAST_UPLOADED_RUN_ID: ${{ needs.decide.outputs.last_uploaded_run_id }}
# The demo variant assigns to the "cmux DEMO" internal group on the
# dev.cmux.app.demo app record instead.
CMUX_TESTFLIGHT_INTERNAL_GROUP_ID: ${{ needs.decide.outputs.variant == 'demo' && 'dd5c5cde-05a6-44e5-bd71-c2ec08a3ebfe' || vars.IOS_TESTFLIGHT_INTERNAL_GROUP_ID }}
ASSIGN_BUNDLE_ID: ${{ needs.upload.outputs.bundle_id }}
BUILD_NUMBER: ${{ needs.upload.outputs.final_build_number }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore previous uploaded build metadata
if: env.SHOULD_ASSIGN_ONLY == 'true'
run: |
set -euo pipefail
gh run download "$LAST_UPLOADED_RUN_ID" --repo manaflow-ai/cmux \
-n ios-testflight-build-metadata \
-D "$RUNNER_TEMP/ios-testflight-build"
BUILD_NUMBER="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["build_number"])' "$RUNNER_TEMP/ios-testflight-build/ios-testflight-build.json")"
echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_ENV"
- name: Assign uploaded build to the internal beta group
id: assign
run: |
set -euo pipefail
export CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE="$RUNNER_TEMP/ios-testflight-assign-state.txt"
if [ -z "${BUILD_NUMBER:-}" ] || [ "$BUILD_NUMBER" = "unknown" ]; then
echo "missing uploaded build number for internal TestFlight assignment" >&2
exit 1
fi
python3 ./ios/scripts/asc_assign_internal_testflight_group.py \
--bundle-id dev.cmux.app.internal \
--bundle-id "$ASSIGN_BUNDLE_ID" \
--build-number "$BUILD_NUMBER"
ASSIGNMENT_STATE="unknown"
if [ -f "$CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE" ]; then
ASSIGNMENT_STATE="$(cat "$CMUX_TESTFLIGHT_ASSIGN_STATE_OUT_FILE")"
fi
echo "assignment_state=$ASSIGNMENT_STATE" >> "$GITHUB_OUTPUT"
- name: Upload assignment-state artifact
if: success()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# The decide job's assign-only retry logic looks for this exact
# artifact name (internal groups have no beta-review "pending" state,
# so a successful run is always complete).
name: ios-testflight-assignment-state-complete
path: ${{ runner.temp }}/ios-testflight-assign-state.txt
retention-days: 30
+125
View File
@@ -0,0 +1,125 @@
name: Iroh release gate
on:
workflow_dispatch:
inputs:
ref:
description: Branch or SHA to verify
required: false
default: ""
mode:
description: Iroh transport mode
required: true
default: all
type: choice
options:
- all
- automatic
- relay-only
- relay-expiry
- direct-only
- private-path
permissions:
contents: read
jobs:
tailscale-version-skew:
name: Tailscale version-skew compatibility
runs-on: ${{ vars.MACOS_RUNNER_15 || 'warp-macos-15-arm64-6x' }}
timeout-minutes: 75
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
CMUX_SKIP_ZIG_BUILD: "1"
SWIFT_BACKTRACE: "interactive=no,timeout=0s,symbolicate=off,color=no"
CMUX_XCODEBUILD_NONINTERACTIVE_POST_TEST_TIMEOUT_SECONDS: "45"
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
submodules: recursive
- name: Select Xcode
run: ./scripts/select-ci-xcode.sh
- name: Download pre-built GhosttyKit
run: ./scripts/download-prebuilt-ghosttykit.sh
- name: Install Rust
run: ./scripts/install-rust-ci.sh
- name: Run deterministic version-skew gate
run: ./scripts/ci/run-iroh-tailscale-compatibility-gate.sh
simulator-e2e:
needs: tailscale-version-skew
strategy:
fail-fast: false
matrix:
mode: ${{ fromJSON(inputs.mode == 'all' && '["automatic","relay-only","relay-expiry","direct-only","private-path"]' || format('["{0}"]', inputs.mode)) }}
runs-on: ${{ vars.MACOS_RUNNER_STREAMED_VALIDATION || 'warp-macos-15-arm64-6x' }}
timeout-minutes: 120
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
submodules: recursive
- name: Select Xcode
run: ./scripts/select-ci-xcode.sh
- name: Ensure iOS Simulator runtime
if: ${{ matrix.mode != 'private-path' }}
run: |
xcrun simctl list runtimes available | grep -Eq '\biOS\b' || xcodebuild -downloadPlatform iOS
- name: Install app build dependencies
if: ${{ matrix.mode == 'automatic' || matrix.mode == 'relay-only' || matrix.mode == 'relay-expiry' }}
run: |
./scripts/install-zig-ci.sh
./scripts/download-prebuilt-ghosttykit.sh || ./scripts/ensure-ghosttykit.sh
- name: Materialize isolated staging account
if: ${{ matrix.mode == 'automatic' || matrix.mode == 'relay-only' || matrix.mode == 'relay-expiry' }}
env:
CMUX_DOGFOOD_STACK_EMAIL: ${{ secrets.CMUX_DOGFOOD_STACK_EMAIL }}
CMUX_DOGFOOD_STACK_PASSWORD: ${{ secrets.CMUX_DOGFOOD_STACK_PASSWORD }}
run: |
set -euo pipefail
[[ -n "${CMUX_DOGFOOD_STACK_EMAIL:-}" ]] || { echo "::error::missing staging email"; exit 1; }
[[ -n "${CMUX_DOGFOOD_STACK_PASSWORD:-}" ]] || { echo "::error::missing staging password"; exit 1; }
mkdir -p "$HOME/.secrets"
{
printf 'CMUX_DOGFOOD_STACK_EMAIL=%s\n' "$CMUX_DOGFOOD_STACK_EMAIL"
printf 'CMUX_DOGFOOD_STACK_PASSWORD=%s\n' "$CMUX_DOGFOOD_STACK_PASSWORD"
} > "$HOME/.secrets/cmuxterm-dev.env"
chmod 600 "$HOME/.secrets/cmuxterm-dev.env"
- name: Run staging Iroh gate
run: |
set -euo pipefail
case "${{ matrix.mode }}" in
automatic) TAG=irgaut ;;
relay-only) TAG=irgrel ;;
relay-expiry) TAG=irgexp ;;
direct-only) TAG=irgdir ;;
private-path) TAG=irgprv ;;
esac
./scripts/run-iroh-release-gate.sh \
--mode "${{ matrix.mode }}" \
--tag "$TAG" \
--report-output "$RUNNER_TEMP/iroh-release-gate-${{ matrix.mode }}.json"
- name: Upload redacted verdict
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: iroh-release-gate-${{ matrix.mode }}
path: ${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}.json
if-no-files-found: warn
retention-days: 7
+9 -66
View File
@@ -254,7 +254,7 @@ jobs:
if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *')
# Match the cache warmer and stable release app build exactly. Keeping the
# runner and pinned Xcode identical makes their compilation caches reusable.
runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }}
runs-on: ${{ vars.MACOS_RUNNER_26_NIGHTLY_BUILD || 'blacksmith-12vcpu-macos-26' }}
timeout-minutes: 45
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }}
@@ -338,6 +338,12 @@ jobs:
rm -rf "$cache_path"
fi
- name: Strip unsigned nightly app before transfer
run: |
set -euo pipefail
products="build-universal/Build/Products/Release"
./scripts/strip-release-bundle.sh "$products/cmux.app"
- name: Archive unsigned nightly app
run: |
set -euo pipefail
@@ -682,9 +688,6 @@ jobs:
cp "$TMP_PROFILE" "$PROFILE_PATH"
- name: Strip nightly release binaries
run: ./scripts/strip-release-bundle.sh "build-universal/Build/Products/Release/cmux NIGHTLY.app"
- name: Codesign apps
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
@@ -702,74 +705,14 @@ jobs:
"$APPLE_SIGNING_IDENTITY"
done
- name: Notarize apps and dmgs
- name: Notarize app ticket through final DMG
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
if [ -z "$APPLE_ID" ] || [ -z "$APPLE_APP_SPECIFIC_PASSWORD" ] || [ -z "$APPLE_TEAM_ID" ]; then
echo "Missing notarization secrets (APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID)" >&2
exit 1
fi
notarize_and_package() {
local app_path="$1"
local dmg_release="$2"
local dmg_immutable="$3"
local zip_submit="${dmg_release%.dmg}-notary.zip"
local dmg_tmp_dir
local created_dmg
ditto -c -k --sequesterRsrc --keepParent "$app_path" "$zip_submit"
APP_SUBMIT_JSON="$(xcrun notarytool submit "$zip_submit" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
APP_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$APP_SUBMIT_JSON")"
APP_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$APP_SUBMIT_JSON")"
if [ "$APP_STATUS" != "Accepted" ]; then
echo "App notarization failed for $app_path with status: $APP_STATUS" >&2
xcrun notarytool log "$APP_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
exit 1
fi
xcrun stapler staple "$app_path"
xcrun stapler validate "$app_path"
spctl -a -vv --type execute "$app_path"
CMUX_SMOKE_ALLOW_UNSUPPORTED_GUI=1 CMUX_SMOKE_DEBUG_LOGS=1 ./scripts/smoke-launch-macos-app.sh "$app_path"
CMUX_SMOKE_DIRECT_EXEC=1 CMUX_SMOKE_DEBUG_LOGS=1 ./scripts/smoke-launch-macos-app.sh "$app_path"
./scripts/verify-app-bundle-channel-metadata.sh "$app_path" nightly
./scripts/verify-app-bundle-licenses.sh "$app_path"
rm -f "$zip_submit"
dmg_tmp_dir="$(mktemp -d)"
create-dmg \
--no-code-sign \
"$app_path" \
"$dmg_tmp_dir"
created_dmg="$(find "$dmg_tmp_dir" -maxdepth 1 -name '*.dmg' | head -n 1)"
if [ -z "$created_dmg" ]; then
echo "Failed to locate created DMG for $app_path" >&2
exit 1
fi
mv "$created_dmg" "$dmg_release"
rm -rf "$dmg_tmp_dir"
/usr/bin/codesign --force --timestamp --keychain build.keychain \
--sign "$APPLE_SIGNING_IDENTITY" \
"$dmg_release"
/usr/bin/codesign --verify --verbose=2 "$dmg_release"
DMG_SUBMIT_JSON="$(xcrun notarytool submit "$dmg_release" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --wait --output-format json)"
DMG_SUBMIT_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' <<<"$DMG_SUBMIT_JSON")"
DMG_STATUS="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["status"])' <<<"$DMG_SUBMIT_JSON")"
if [ "$DMG_STATUS" != "Accepted" ]; then
echo "DMG notarization failed for $dmg_release with status: $DMG_STATUS" >&2
xcrun notarytool log "$DMG_SUBMIT_ID" --apple-id "$APPLE_ID" --team-id "$APPLE_TEAM_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" || true
exit 1
fi
xcrun stapler staple "$dmg_release"
xcrun stapler validate "$dmg_release"
cp "$dmg_release" "$dmg_immutable"
}
notarize_and_package \
./scripts/ci/notarize-nightly-dmg.sh \
"build-universal/Build/Products/Release/cmux NIGHTLY.app" \
"cmux-nightly-macos.dmg" \
"$NIGHTLY_DMG_IMMUTABLE"
+26 -1
View File
@@ -4,6 +4,13 @@
# the Durable Object migrations declared in wrangler.toml atomically with the code
# upload, so the service's schema can never lag a deploy.
#
# The `target` input picks the worker: `prod` (default) deploys `cmux-presence`
# on presence.cmux.dev; `dev` deploys the shared integration baseline
# `cmux-presence-dev` from wrangler.dev.toml. Both use the repository's
# Cloudflare secrets, so nobody needs a personal Cloudflare account membership
# to keep the shared dev worker current. Per-developer isolated workers stay on
# `scripts/deploy-dev.sh` (they need per-instance Stack secrets at creation).
#
# Required repository secrets (deploy job):
# CLOUDFLARE_API_TOKEN API token with Workers Scripts:Edit on the account
# CLOUDFLARE_ACCOUNT_ID the Cloudflare account id
@@ -16,6 +23,14 @@ name: presence
on:
workflow_dispatch:
inputs:
target:
description: "Worker to deploy"
type: choice
options:
- prod
- dev
default: prod
permissions:
contents: read
@@ -100,7 +115,17 @@ jobs:
fi
- name: Deploy (applies DO migrations atomically)
run: bunx wrangler deploy
# The target reaches the shell via env, never template interpolation
# (an API dispatch is not limited to the UI's choice list), and any
# value other than the two known targets fails closed instead of
# silently deploying production.
run: |
case "$DEPLOY_TARGET" in
dev) bunx wrangler deploy --config wrangler.dev.toml ;;
prod) bunx wrangler deploy ;;
*) echo "::error::Unsupported deployment target '$DEPLOY_TARGET'"; exit 1 ;;
esac
env:
DEPLOY_TARGET: ${{ inputs.target }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+43 -2
View File
@@ -138,6 +138,11 @@ jobs:
slug="$(printf '%s' "$BUILD_TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//')"
bundle_id="dev.cmux.ios.$slug"
display_name="cmux DEV $BUILD_TAG"
# This archive is installed on a physical device. Its localhost is
# the phone, not the tagged Mac or runner, so Debug device builds use
# staging unless the dispatcher supplies an explicit reachable URL.
api_base_url="${CMUX_IOS_API_BASE_URL:-${CMUX_DEV_API_BASE_URL:-https://cmux-staging.vercel.app}}"
iroh_broker_base_url="${CMUX_IOS_IROH_BROKER_BASE_URL:-${CMUX_IROH_BROKER_BASE_URL:-https://cmux-staging.vercel.app}}"
# Register the iOS platform if the runner only has macOS provisioned.
ios_ready() { xcrun simctl runtime list 2>/dev/null | grep -qiE "iOS [0-9].*\(Ready\)"; }
@@ -163,13 +168,49 @@ jobs:
PRODUCT_DISPLAY_NAME="$display_name" \
CMUX_GIT_SHA="$(git rev-parse --short HEAD)" \
CMUX_DEV_TAG="$BUILD_TAG" \
CMUX_API_BASE_URL="$api_base_url" \
CMUX_IROH_BROKER_BASE_URL="$iroh_broker_base_url" \
EXCLUDED_SOURCE_FILE_NAMES=Info.plist \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY=""
CODE_SIGN_IDENTITY="" \
SWIFT_OPTIMIZATION_LEVEL=-O \
SWIFT_COMPILATION_MODE=wholemodule \
GCC_OPTIMIZATION_LEVEL=s
[ -d "$archive" ] || { echo "archive not produced: $archive" >&2; exit 1; }
# Keep Blacksmith device reloads equivalent to the fleet path: one
# build supplies both the unsigned phone archive and the exact same
# source revision for an isolated Simulator verification.
xcodebuild build \
-workspace ios/cmux.xcworkspace \
-scheme cmux-ios \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/cmux-ios-dd" \
PRODUCT_BUNDLE_IDENTIFIER="$bundle_id" \
PRODUCT_DISPLAY_NAME="$display_name" \
CMUX_GIT_SHA="$(git rev-parse --short HEAD)" \
CMUX_DEV_TAG="$BUILD_TAG" \
CMUX_API_BASE_URL="$api_base_url" \
CMUX_IROH_BROKER_BASE_URL="$iroh_broker_base_url" \
EXCLUDED_SOURCE_FILE_NAMES=Info.plist \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
SWIFT_OPTIMIZATION_LEVEL=-O \
SWIFT_COMPILATION_MODE=wholemodule \
GCC_OPTIMIZATION_LEVEL=s
sim_app="$RUNNER_TEMP/cmux-ios-dd/Build/Products/Debug-iphonesimulator/cmux.app"
[ -d "$sim_app" ] || { echo "simulator app not produced: $sim_app" >&2; exit 1; }
mkdir -p artifact
( cd "$out" && ditto -c -k --keepParent "$(basename "$archive")" "$GITHUB_WORKSPACE/artifact/archive.zip" )
pkg="$out/cmux-ios-$slug-pkg"
rm -rf "$pkg"
mkdir -p "$pkg/simulator"
mv "$archive" "$pkg/"
ditto "$sim_app" "$pkg/simulator/cmux.app"
ditto -c -k "$pkg" "$GITHUB_WORKSPACE/artifact/archive.zip"
- name: Write timings.json
if: ${{ always() }}
+626
View File
@@ -0,0 +1,626 @@
name: sdk bootstrap crates
on:
repository_dispatch:
types: [sdk-bootstrap-crates]
permissions: {}
concurrency:
group: sdk-bootstrap-crates
cancel-in-progress: false
env:
BOOTSTRAP_VERSION: "0.0.0-bootstrap.0"
RUST_TOOLCHAIN: "1.95.0"
jobs:
build:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
contents: read
outputs:
sdk_sha256: ${{ steps.package.outputs.sdk_sha256 }}
sidebar_sha256: ${{ steps.package.outputs.sidebar_sha256 }}
steps:
- name: Require explicit bootstrap confirmation
if: github.event.client_payload.confirm_bootstrap != true
run: |
echo "Refusing to reserve the Rust SDK crates without confirm_bootstrap=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require current protected main
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "Dispatch sdk-bootstrap-crates.yml from main, found $GITHUB_REF." >&2
exit 1
}
git fetch --force origin main
main_sha="$(git rev-parse origin/main)"
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
- name: Install pinned Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
cargo --version
rustc --version
- name: Build and test the ownership bootstrap
id: package
run: |
set -euo pipefail
for specification in \
"cmux-sdk:rust-sdk:sdk_sha256" \
"cmux-sidebar:rust-sidebar:sidebar_sha256"; do
IFS=: read -r package source output_name <<< "$specification"
source_dir="cmux-tui/bindings/bootstrap/$source"
bootstrap_dir="$RUNNER_TEMP/$package-bootstrap"
cp -R "$source_dir" "$bootstrap_dir"
manifest="$bootstrap_dir/Cargo.toml"
cargo test --manifest-path "$manifest" --locked
cargo package --manifest-path "$manifest" --locked --no-verify
artifact="$bootstrap_dir/target/package/$package-$BOOTSTRAP_VERSION.crate"
[[ -f "$artifact" ]] || {
echo "$package bootstrap crate was not created" >&2
exit 1
}
verify_dir="$RUNNER_TEMP/$package-bootstrap-verify"
mkdir -p "$verify_dir"
tar -xzf "$artifact" -C "$verify_dir"
cargo test \
--manifest-path \
"$verify_dir/$package-$BOOTSTRAP_VERSION/Cargo.toml" \
--locked
artifact_dir="$RUNNER_TEMP/$package-bootstrap-artifact"
mkdir -p "$artifact_dir"
cp "$artifact" "$artifact_dir/"
artifact_sha256="$(sha256sum "$artifact" | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
echo "$package bootstrap crate digest is malformed" >&2
exit 1
}
echo "$output_name=$artifact_sha256" >> "$GITHUB_OUTPUT"
done
- name: Upload the cmux-sdk bootstrap crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-sdk-bootstrap-crate
path: ${{ runner.temp }}/cmux-sdk-bootstrap-artifact
if-no-files-found: error
overwrite: true
- name: Upload the cmux-sidebar bootstrap crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-sidebar-bootstrap-crate
path: ${{ runner.temp }}/cmux-sidebar-bootstrap-artifact
if-no-files-found: error
overwrite: true
preflight:
needs: build
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- package: cmux-sdk
artifact: cmux-sdk-bootstrap-crate
decision: cmux-sdk-bootstrap-decision
- package: cmux-sidebar
artifact: cmux-sidebar-bootstrap-crate
decision: cmux-sidebar-bootstrap-decision
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: ${{ matrix.artifact }}
path: bootstrap-crate
- name: Inspect the crates.io bootstrap state
id: project
env:
PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
metadata="$RUNNER_TEMP/$PACKAGE-bootstrap-registry.json"
status="$(
curl \
--silent \
--show-error \
--location \
--retry 5 \
--retry-delay 1 \
--retry-all-errors \
--user-agent 'cmux-sdk-bootstrap/1 (https://github.com/manaflow-ai/cmux; contact: https://github.com/manaflow-ai/cmux/issues)' \
--output "$metadata" \
--write-out '%{http_code}' \
"https://crates.io/api/v1/crates/$PACKAGE"
)"
case "$status" in
404)
echo "$PACKAGE is unclaimed; bootstrap may continue."
project_status=missing
;;
200)
echo "$PACKAGE exists; bootstrap bytes must match."
project_status=exists
;;
*)
echo "crates.io returned HTTP $status; refusing to infer availability." >&2
exit 1
;;
esac
echo "status=$project_status" >> "$GITHUB_OUTPUT"
sleep 1
- name: Reconcile an existing crates.io ownership bootstrap
if: steps.project.outputs.status == 'exists'
env:
PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
shopt -s nullglob
artifacts=(bootstrap-crate/*.crate)
[[ "${#artifacts[@]}" == 1 ]] || {
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--require-match
sleep 1
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package "$PACKAGE" \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
--owner-login lawrencecchen \
--bootstrap-ownership-only
- name: Record the credential-job decision
env:
PACKAGE: ${{ matrix.package }}
PROJECT_STATUS: ${{ steps.project.outputs.status }}
run: |
set -euo pipefail
case "$PROJECT_STATUS" in
missing) decision=publish ;;
exists) decision=skip ;;
*)
echo "unexpected $PACKAGE project state: $PROJECT_STATUS" >&2
exit 1
;;
esac
decision_dir="$RUNNER_TEMP/$PACKAGE-bootstrap-decision"
mkdir -p "$decision_dir"
printf '%s\n' "$decision" > "$decision_dir/decision.txt"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ matrix.decision }}
path: ${{ runner.temp }}/${{ matrix.package }}-bootstrap-decision
if-no-files-found: error
overwrite: true
decisions:
needs:
- preflight
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
permissions:
actions: read
outputs:
sdk_need_publish: ${{ steps.read.outputs.sdk_need_publish }}
sidebar_need_publish: ${{ steps.read.outputs.sidebar_need_publish }}
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sdk-bootstrap-decision
path: sdk-decision
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sidebar-bootstrap-decision
path: sidebar-decision
- name: Export protected-environment decisions
id: read
run: |
set -euo pipefail
read_decision() {
local path="$1"
local output_name="$2"
local decision
decision="$(cat "$path")"
case "$decision" in
publish) need_publish=true ;;
skip) need_publish=false ;;
*)
echo "invalid bootstrap publication decision: $decision" >&2
exit 1
;;
esac
echo "$output_name=$need_publish" >> "$GITHUB_OUTPUT"
}
read_decision sdk-decision/decision.txt sdk_need_publish
read_decision sidebar-decision/decision.txt sidebar_need_publish
publish-sdk:
needs:
- build
- preflight
- decisions
if: needs.decisions.outputs.sdk_need_publish == 'true'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions: {}
environment:
name: crates-bootstrap
url: https://crates.io/crates/cmux-sdk
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sdk-bootstrap-crate
path: bootstrap-crate
- name: Install pinned Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
cargo --version
rustc --version
- name: Verify protected source and reproduce the tested crate
id: prepare
env:
EXPECTED_SHA256: ${{ needs.build.outputs.sdk_sha256 }}
PACKAGE: cmux-sdk
run: |
set -euo pipefail
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
echo "bootstrap repository must be manaflow-ai/cmux" >&2
exit 1
}
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "bootstrap credential job must run from main" >&2
exit 1
}
main_sha="$(
git ls-remote \
https://github.com/manaflow-ai/cmux.git \
refs/heads/main |
awk 'NR == 1 { print $1 }'
)"
[[ "$main_sha" == "$GITHUB_SHA" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
[[ "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
echo "validated crate digest is malformed" >&2
exit 1
}
shopt -s nullglob
artifacts=(bootstrap-crate/*.crate)
[[ "${#artifacts[@]}" == 1 ]] || {
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
BOOTSTRAP_ARTIFACT="${artifacts[0]}"
[[ "$(basename "$BOOTSTRAP_ARTIFACT")" == "$PACKAGE-$BOOTSTRAP_VERSION.crate" ]] || {
echo "unexpected bootstrap crate filename" >&2
exit 1
}
actual_sha256="$(sha256sum "$BOOTSTRAP_ARTIFACT" | cut -d ' ' -f 1)"
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
echo "downloaded crates.io bootstrap artifact digest mismatch" >&2
exit 1
}
package_prefix="$PACKAGE-$BOOTSTRAP_VERSION"
publish_root="$RUNNER_TEMP/$PACKAGE-publish"
mkdir -p "$publish_root"
python3 - \
"$BOOTSTRAP_ARTIFACT" \
"$publish_root" \
"$package_prefix" <<'PY'
import pathlib
import shutil
import sys
import tarfile
archive_path = pathlib.Path(sys.argv[1])
publish_root = pathlib.Path(sys.argv[2])
package_prefix = sys.argv[3]
expected = {
f"{package_prefix}/Cargo.lock",
f"{package_prefix}/Cargo.toml",
f"{package_prefix}/Cargo.toml.orig",
f"{package_prefix}/README.md",
f"{package_prefix}/src/lib.rs",
}
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
names = [member.name for member in members]
if len(names) != len(expected) or set(names) != expected:
raise SystemExit(
f"bootstrap crate paths differ from the allowlist: {names!r}"
)
for member in members:
if not member.isfile():
raise SystemExit(
f"bootstrap crate member is not a regular file: {member.name}"
)
source = archive.extractfile(member)
if source is None:
raise SystemExit(
f"bootstrap crate member cannot be read: {member.name}"
)
destination = publish_root / member.name
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
destination.chmod(member.mode & 0o777)
PY
package_root="$publish_root/$package_prefix"
cp "$package_root/Cargo.toml.orig" "$package_root/Cargo.toml"
cargo package \
--manifest-path "$package_root/Cargo.toml" \
--locked \
--no-verify
REPACKED_ARTIFACT="$package_root/target/package/$package_prefix.crate"
cmp "$BOOTSTRAP_ARTIFACT" "$REPACKED_ARTIFACT"
echo "manifest=$package_root/Cargo.toml" >> "$GITHUB_OUTPUT"
- name: Publish the exact tested ownership bootstrap
continue-on-error: true
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_BOOTSTRAP_TOKEN }}
PUBLISH_MANIFEST: ${{ steps.prepare.outputs.manifest }}
run: |
set -euo pipefail
[[ -n "$CARGO_REGISTRY_TOKEN" ]] || {
echo "crates-bootstrap environment secret CARGO_BOOTSTRAP_TOKEN is required." >&2
exit 1
}
cargo publish \
--manifest-path "$PUBLISH_MANIFEST" \
--locked \
--no-verify
publish-sidebar:
needs:
- build
- preflight
- decisions
- publish-sdk
if: >-
always() &&
!cancelled() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
needs.decisions.result == 'success' &&
needs.decisions.outputs.sidebar_need_publish == 'true'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions: {}
environment:
name: crates-bootstrap
url: https://crates.io/crates/cmux-sidebar
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-sidebar-bootstrap-crate
path: bootstrap-crate
- name: Install pinned Rust toolchain
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
cargo --version
rustc --version
- name: Verify protected source and reproduce the tested crate
id: prepare
env:
EXPECTED_SHA256: ${{ needs.build.outputs.sidebar_sha256 }}
PACKAGE: cmux-sidebar
run: |
set -euo pipefail
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
echo "bootstrap repository must be manaflow-ai/cmux" >&2
exit 1
}
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "bootstrap credential job must run from main" >&2
exit 1
}
main_sha="$(
git ls-remote \
https://github.com/manaflow-ai/cmux.git \
refs/heads/main |
awk 'NR == 1 { print $1 }'
)"
[[ "$main_sha" == "$GITHUB_SHA" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
[[ "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
echo "validated crate digest is malformed" >&2
exit 1
}
shopt -s nullglob
artifacts=(bootstrap-crate/*.crate)
[[ "${#artifacts[@]}" == 1 ]] || {
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
BOOTSTRAP_ARTIFACT="${artifacts[0]}"
[[ "$(basename "$BOOTSTRAP_ARTIFACT")" == "$PACKAGE-$BOOTSTRAP_VERSION.crate" ]] || {
echo "unexpected bootstrap crate filename" >&2
exit 1
}
actual_sha256="$(sha256sum "$BOOTSTRAP_ARTIFACT" | cut -d ' ' -f 1)"
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
echo "downloaded crates.io bootstrap artifact digest mismatch" >&2
exit 1
}
package_prefix="$PACKAGE-$BOOTSTRAP_VERSION"
publish_root="$RUNNER_TEMP/$PACKAGE-publish"
mkdir -p "$publish_root"
python3 - \
"$BOOTSTRAP_ARTIFACT" \
"$publish_root" \
"$package_prefix" <<'PY'
import pathlib
import shutil
import sys
import tarfile
archive_path = pathlib.Path(sys.argv[1])
publish_root = pathlib.Path(sys.argv[2])
package_prefix = sys.argv[3]
expected = {
f"{package_prefix}/Cargo.lock",
f"{package_prefix}/Cargo.toml",
f"{package_prefix}/Cargo.toml.orig",
f"{package_prefix}/README.md",
f"{package_prefix}/src/lib.rs",
}
with tarfile.open(archive_path, "r:gz") as archive:
members = archive.getmembers()
names = [member.name for member in members]
if len(names) != len(expected) or set(names) != expected:
raise SystemExit(
f"bootstrap crate paths differ from the allowlist: {names!r}"
)
for member in members:
if not member.isfile():
raise SystemExit(
f"bootstrap crate member is not a regular file: {member.name}"
)
source = archive.extractfile(member)
if source is None:
raise SystemExit(
f"bootstrap crate member cannot be read: {member.name}"
)
destination = publish_root / member.name
destination.parent.mkdir(parents=True, exist_ok=True)
with source, destination.open("wb") as output:
shutil.copyfileobj(source, output)
destination.chmod(member.mode & 0o777)
PY
package_root="$publish_root/$package_prefix"
cp "$package_root/Cargo.toml.orig" "$package_root/Cargo.toml"
cargo package \
--manifest-path "$package_root/Cargo.toml" \
--locked \
--no-verify
REPACKED_ARTIFACT="$package_root/target/package/$package_prefix.crate"
cmp "$BOOTSTRAP_ARTIFACT" "$REPACKED_ARTIFACT"
echo "manifest=$package_root/Cargo.toml" >> "$GITHUB_OUTPUT"
- name: Publish the exact tested ownership bootstrap
continue-on-error: true
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_BOOTSTRAP_TOKEN }}
PUBLISH_MANIFEST: ${{ steps.prepare.outputs.manifest }}
run: |
set -euo pipefail
[[ -n "$CARGO_REGISTRY_TOKEN" ]] || {
echo "crates-bootstrap environment secret CARGO_BOOTSTRAP_TOKEN is required." >&2
exit 1
}
cargo publish \
--manifest-path "$PUBLISH_MANIFEST" \
--locked \
--no-verify
verify:
needs:
- build
- preflight
- decisions
- publish-sdk
- publish-sidebar
if: >-
always() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
needs.decisions.result == 'success'
strategy:
fail-fast: false
max-parallel: 1
matrix:
include:
- package: cmux-sdk
artifact: cmux-sdk-bootstrap-crate
- package: cmux-sidebar
artifact: cmux-sidebar-bootstrap-crate
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: ${{ matrix.artifact }}
path: bootstrap-crate
- name: Reconcile the exact crates.io ownership bootstrap
env:
PACKAGE: ${{ matrix.package }}
run: |
set -euo pipefail
shopt -s nullglob
artifacts=(bootstrap-crate/*.crate)
[[ "${#artifacts[@]}" == 1 ]] || {
echo "expected one tested crate, found ${#artifacts[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry crates \
--package "$PACKAGE" \
--version "$BOOTSTRAP_VERSION" \
--artifact "${artifacts[0]}" \
--retry-missing-project \
--wait-seconds 300 \
--require-match
sleep 1
python3 cmux-tui/bindings/verify_crates_ownership.py \
--package "$PACKAGE" \
--repository https://github.com/manaflow-ai/cmux \
--owner-id 431397 \
--owner-login lawrencecchen \
--bootstrap-ownership-only
+340
View File
@@ -0,0 +1,340 @@
name: sdk bootstrap npm
on:
repository_dispatch:
types: [sdk-bootstrap-npm]
permissions: {}
concurrency:
group: sdk-bootstrap-npm
cancel-in-progress: false
env:
BOOTSTRAP_VERSION: "0.0.0-bootstrap.0"
jobs:
build:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
contents: read
outputs:
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- name: Require explicit bootstrap confirmation
if: github.event.client_payload.confirm_bootstrap != true
run: |
echo "Refusing to reserve cmux-sdk without confirm_bootstrap=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require current protected main
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "Dispatch sdk-bootstrap-npm.yml from main, found $GITHUB_REF." >&2
exit 1
}
git fetch --force origin main
main_sha="$(git rev-parse origin/main)"
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Build, test, and pack the bootstrap prerelease
id: package
working-directory: cmux-tui/bindings/typescript
run: |
set -euo pipefail
npm ci --no-audit --no-fund
npm version "$BOOTSTRAP_VERSION" --no-git-tag-version
npm test
mkdir -p "$RUNNER_TEMP/cmux-npm-bootstrap"
npm pack --pack-destination "$RUNNER_TEMP/cmux-npm-bootstrap"
shopt -s nullglob
packages=("$RUNNER_TEMP"/cmux-npm-bootstrap/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one bootstrap artifact, found ${#packages[@]}" >&2
exit 1
}
CMUX_NPM_PACKAGE="${packages[0]}" \
node scripts/verify-packaged-consumer.mjs
artifact_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || {
echo "bootstrap package digest is malformed" >&2
exit 1
}
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-npm-bootstrap-package
path: ${{ runner.temp }}/cmux-npm-bootstrap/*.tgz
if-no-files-found: error
overwrite: true
preflight:
needs: build
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
outputs:
need_publish: ${{ steps.decision.outputs.need_publish }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-npm-bootstrap-package
path: bootstrap-package
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Inspect the npm bootstrap state
id: project
run: |
set -euo pipefail
metadata="$RUNNER_TEMP/cmux-sdk-bootstrap-registry.json"
status="$(
curl \
--silent \
--show-error \
--location \
--retry 5 \
--retry-all-errors \
--output "$metadata" \
--write-out '%{http_code}' \
https://registry.npmjs.org/cmux-sdk
)"
case "$status" in
404)
echo "cmux-sdk is unclaimed; bootstrap may continue."
project_status=missing
;;
200)
echo "cmux-sdk exists; bootstrap bytes and provenance must match."
project_status=exists
;;
*)
echo "npm registry returned HTTP $status; refusing to infer availability." >&2
exit 1
;;
esac
echo "status=$project_status" >> "$GITHUB_OUTPUT"
- name: Reconcile an existing npm ownership bootstrap
if: steps.project.outputs.status == 'exists'
run: |
set -euo pipefail
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/verify_npm_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--repository-url git+https://github.com/manaflow-ai/cmux.git \
--repository-directory cmux-tui/bindings/typescript \
--owner lawrencechen \
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--publisher owner \
--artifact "${packages[0]}"
- name: Request publication for an unclaimed project
id: decision
if: steps.project.outputs.status == 'missing'
run: echo "need_publish=true" >> "$GITHUB_OUTPUT"
publish:
needs:
- build
- preflight
if: needs.preflight.outputs.need_publish == 'true'
runs-on: ubuntu-latest # github-hosted-required: npm provenance publishing
timeout-minutes: 10
permissions:
id-token: write
environment:
name: npm-bootstrap
url: https://www.npmjs.com/package/cmux-sdk
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-npm-bootstrap-package
path: bootstrap-package
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Verify protected source and the exact tested package
env:
EXPECTED_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
run: |
set -euo pipefail
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
echo "bootstrap repository must be manaflow-ai/cmux" >&2
exit 1
}
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "bootstrap credential job must run from main" >&2
exit 1
}
main_sha="$(
git ls-remote \
https://github.com/manaflow-ai/cmux.git \
refs/heads/main |
awk 'NR == 1 { print $1 }'
)"
[[ "$main_sha" == "$GITHUB_SHA" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
[[ "$EXPECTED_SHA256" =~ ^[0-9a-f]{64}$ ]] || {
echo "validated npm artifact digest is malformed" >&2
exit 1
}
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
actual_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
[[ "$actual_sha256" == "$EXPECTED_SHA256" ]] || {
echo "downloaded npm bootstrap artifact digest mismatch" >&2
exit 1
}
- name: Publish the exact tested prerelease artifact
continue-on-error: true
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_BOOTSTRAP_TOKEN }}
run: |
set -euo pipefail
[[ -n "$NODE_AUTH_TOKEN" ]] || {
echo "npm-bootstrap environment secret NPM_BOOTSTRAP_TOKEN is required." >&2
exit 1
}
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
echo "npm lifecycle scripts are disabled in the credentialed publisher"
npm publish "${packages[0]}" \
--ignore-scripts \
--tag bootstrap \
--provenance \
--access public
verify:
needs:
- build
- preflight
- publish
if: >-
always() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-npm-bootstrap-package
path: bootstrap-package
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
package-manager-cache: false
- name: Install pinned npm
run: npm install --global --ignore-scripts [email protected]
- name: Verify the prerelease did not claim latest
run: |
set -euo pipefail
tags="$RUNNER_TEMP/cmux-sdk-bootstrap-tags.json"
deadline=$((SECONDS + 300))
until npm view cmux-sdk dist-tags --json > "$tags"; do
(( SECONDS < deadline )) || {
echo "cmux-sdk bootstrap tags did not become visible within 300 seconds." >&2
exit 1
}
sleep 15
done
node - "$tags" "$BOOTSTRAP_VERSION" <<'NODE'
const fs = require("node:fs");
const [path, expected] = process.argv.slice(2);
const tags = JSON.parse(fs.readFileSync(path, "utf8"));
if (tags.bootstrap !== expected || Object.hasOwn(tags, "latest")) {
throw new Error(`unexpected cmux-sdk dist-tags: ${JSON.stringify(tags)}`);
}
NODE
- name: Verify the npm ownership bootstrap
run: |
set -euo pipefail
shopt -s nullglob
packages=(bootstrap-package/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one tested npm artifact, found ${#packages[@]}" >&2
exit 1
}
python3 cmux-tui/bindings/verify_npm_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--repository-url git+https://github.com/manaflow-ai/cmux.git \
--repository-directory cmux-tui/bindings/typescript \
--owner lawrencechen \
--workflow .github/workflows/sdk-bootstrap-npm.yml \
--workflow-ref refs/heads/main \
--dist-tag bootstrap \
--publisher owner \
--artifact "${packages[0]}"
+413
View File
@@ -0,0 +1,413 @@
name: sdk bootstrap pypi
on:
repository_dispatch:
types: [sdk-bootstrap-pypi]
permissions: {}
env:
BOOTSTRAP_VERSION: "0.0.0a0"
concurrency:
group: sdk-bootstrap-pypi
cancel-in-progress: false
jobs:
build:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- name: Require explicit bootstrap confirmation
if: github.event.client_payload.confirm_bootstrap != true
run: |
echo "Refusing to reserve cmux-sdk without confirm_bootstrap=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
fetch-depth: 0
- name: Require current protected main
run: |
set -euo pipefail
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "Dispatch sdk-bootstrap-pypi.yml from main." >&2
exit 1
}
git fetch --force origin main
main_sha="$(git rev-parse origin/main)"
[[ "$GITHUB_SHA" == "$main_sha" ]] || {
echo "workflow commit is not current main" >&2
exit 1
}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Install pinned packaging tools
run: |
python3 -m pip install \
--disable-pip-version-check \
"build==1.3.0" \
"setuptools==80.9.0" \
"wheel==0.45.1"
- name: Prepare the prerelease source tree
env:
CMUX_BOOTSTRAP_VERSION: ${{ env.BOOTSTRAP_VERSION }}
run: |
python3 - <<'PY'
import os
from pathlib import Path
import re
import shutil
source = Path("cmux-tui/bindings/python")
target = Path(os.environ["RUNNER_TEMP"]) / "cmux-python-bootstrap"
shutil.copytree(source, target)
manifest = target / "pyproject.toml"
contents = manifest.read_text(encoding="utf-8")
contents, count = re.subn(
r'(?m)^version = "[^"]+"$',
f'version = "{os.environ["CMUX_BOOTSTRAP_VERSION"]}"',
contents,
)
if count != 1:
raise SystemExit("expected one static project version")
manifest.write_text(contents, encoding="utf-8")
PY
- name: Test the prerelease source tree
run: |
cd "$RUNNER_TEMP/cmux-python-bootstrap"
PYTHONPATH=. python3 -m unittest discover -s tests -v
- name: Build deterministic bootstrap distributions
run: |
export SOURCE_DATE_EPOCH
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
python3 -m build --no-isolation --sdist --wheel \
--outdir "$GITHUB_WORKSPACE/bootstrap-dist" \
"$RUNNER_TEMP/cmux-python-bootstrap"
python3 cmux-tui/bindings/normalize_python_sdist.py \
--archive bootstrap-dist/*.tar.gz \
--epoch "$SOURCE_DATE_EPOCH"
- name: Test the exact bootstrap distributions
env:
CMUX_PYTHON_DIST_DIR: ${{ github.workspace }}/bootstrap-dist
run: |
cd "$RUNNER_TEMP/cmux-python-bootstrap"
PYTHONPATH=. python3 -m unittest tests.test_package_consumer -v
- name: Fingerprint the bootstrap distributions
id: package
run: |
set -euo pipefail
shopt -s nullglob
wheels=(bootstrap-dist/*.whl)
sdists=(bootstrap-dist/*.tar.gz)
[[ "${#wheels[@]}" == 1 && "${#sdists[@]}" == 1 ]] || {
echo "expected one bootstrap wheel and one source distribution" >&2
exit 1
}
artifact_sha256="$(
cd bootstrap-dist
sha256sum *.whl *.tar.gz | sort -k2 | sha256sum | cut -d ' ' -f 1
)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-python-bootstrap-dist-${{ github.run_attempt }}
path: bootstrap-dist/*
if-no-files-found: error
preflight:
needs: build
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
outputs:
need_publish: ${{ steps.decision.outputs.need_publish }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
artifact-ids: ${{ needs.build.outputs.artifact_id }}
path: bootstrap-dist
- name: Check whether the PyPI project exists
id: project
run: |
python3 - <<'PY'
import json
import os
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
request = Request(
"https://pypi.org/pypi/cmux-sdk/json",
headers={"Accept": "application/json"},
)
try:
with urlopen(request, timeout=20) as response:
metadata = json.loads(response.read())
except HTTPError as error:
if error.code != 404:
raise SystemExit("PyPI project lookup failed") from error
status = "missing"
except (OSError, URLError, json.JSONDecodeError) as error:
raise SystemExit("PyPI project lookup failed") from error
else:
if not isinstance(metadata, dict) or not isinstance(
metadata.get("info"), dict
):
raise SystemExit("PyPI project metadata is malformed")
status = "exists"
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"status={status}\n")
PY
- name: Check the existing bootstrap wheel
if: steps.project.outputs.status == 'exists'
id: wheel_state
run: |
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--write-github-output
- name: Check the existing bootstrap source distribution
if: steps.project.outputs.status == 'exists'
id: sdist_state
run: |
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.tar.gz \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--write-github-output
- name: Decide whether publishing is required
id: decision
env:
PROJECT_STATUS: ${{ steps.project.outputs.status }}
WHEEL_STATUS: ${{ steps.wheel_state.outputs.status }}
SDIST_STATUS: ${{ steps.sdist_state.outputs.status }}
run: |
set -euo pipefail
if [[ "$PROJECT_STATUS" == "missing" ]]; then
need_publish=true
elif [[ "$WHEEL_STATUS" == "match" && "$SDIST_STATUS" == "match" ]]; then
need_publish=false
elif [[ "$WHEEL_STATUS" == "missing" && "$SDIST_STATUS" == "missing" ]]; then
echo "cmux-sdk exists without the expected bootstrap release" >&2
exit 1
elif { [[ "$WHEEL_STATUS" == "match" && "$SDIST_STATUS" == "missing" ]] ||
[[ "$WHEEL_STATUS" == "missing" && "$SDIST_STATUS" == "match" ]]; }; then
need_publish=true
else
echo "unexpected bootstrap registry state" >&2
exit 1
fi
echo "need_publish=$need_publish" >> "$GITHUB_OUTPUT"
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
if: steps.project.outputs.status == 'exists'
with:
python-version: "3.12.8"
- name: Install the pinned provenance verifier
if: steps.project.outputs.status == 'exists'
run: |
python3 -m pip install \
--disable-pip-version-check \
"pypi-attestations==0.0.29"
- name: Verify existing bootstrap with pypi-attestations verify pypi
if: steps.project.outputs.status == 'exists'
env:
WHEEL_STATUS: ${{ steps.wheel_state.outputs.status }}
SDIST_STATUS: ${{ steps.sdist_state.outputs.status }}
run: |
set -euo pipefail
shopt -s nullglob
filenames=()
if [[ "$WHEEL_STATUS" == "match" ]]; then
wheels=(bootstrap-dist/*.whl)
filenames+=(--filename "$(basename "${wheels[0]}")")
fi
if [[ "$SDIST_STATUS" == "match" ]]; then
sdists=(bootstrap-dist/*.tar.gz)
filenames+=(--filename "$(basename "${sdists[0]}")")
fi
python3 cmux-tui/bindings/verify_pypi_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--repository https://github.com/manaflow-ai/cmux \
--owner lawrencecchen \
--workflow sdk-bootstrap-pypi.yml \
--environment pypi-bootstrap \
"${filenames[@]}"
publish:
needs:
- build
- preflight
if: needs.preflight.outputs.need_publish == 'true'
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
id-token: write
environment:
name: pypi-bootstrap
url: https://pypi.org/p/cmux-sdk
outputs:
outcome: ${{ steps.publish.outcome }}
steps:
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
artifact-ids: ${{ needs.build.outputs.artifact_id }}
path: bootstrap-dist
- name: Verify the immutable bootstrap distributions
env:
EXPECTED_ARTIFACT_SHA256: ${{ needs.build.outputs.artifact_sha256 }}
run: |
set -euo pipefail
[[ "$EXPECTED_ARTIFACT_SHA256" =~ ^[0-9a-f]{64}$ ]] || exit 1
actual_sha256="$(
cd bootstrap-dist
sha256sum *.whl *.tar.gz | sort -k2 | sha256sum | cut -d ' ' -f 1
)"
[[ "$actual_sha256" == "$EXPECTED_ARTIFACT_SHA256" ]] || {
echo "downloaded Python bootstrap artifact digest mismatch" >&2
exit 1
}
- name: Revalidate protected source before bootstrap publication
run: |
set -euo pipefail
[[ "$GITHUB_REPOSITORY" == "manaflow-ai/cmux" ]] || {
echo "bootstrap repository must be manaflow-ai/cmux" >&2
exit 1
}
[[ "$GITHUB_REF" == "refs/heads/main" ]] || {
echo "bootstrap credential job must run from main" >&2
exit 1
}
[[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "bootstrap commit is malformed" >&2
exit 1
}
main_sha="$(
git ls-remote \
https://github.com/manaflow-ai/cmux.git \
refs/heads/main |
awk 'NR == 1 { print $1 }'
)"
[[ "$main_sha" == "$GITHUB_SHA" ]] || {
echo "workflow commit $GITHUB_SHA is not current main $main_sha" >&2
exit 1
}
- name: Publish the attested bootstrap distributions
id: publish
continue-on-error: true
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: bootstrap-dist
attestations: true
skip-existing: true
verify:
needs:
- build
- preflight
- publish
if: >-
always() &&
needs.build.result == 'success' &&
needs.preflight.result == 'success' &&
(needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
artifact-ids: ${{ needs.build.outputs.artifact_id }}
path: bootstrap-dist
- name: Install the pinned provenance verifier
run: |
python3 -m pip install \
--disable-pip-version-check \
"pypi-attestations==0.0.29"
- name: Reconcile exact bootstrap distributions
run: |
set -euo pipefail
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--wait-seconds 300 \
--require-match
python3 cmux-tui/bindings/reconcile_registry_artifact.py check \
--registry pypi \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--artifact bootstrap-dist/*.tar.gz \
--allowed-artifact bootstrap-dist/*.whl \
--allowed-artifact bootstrap-dist/*.tar.gz \
--wait-seconds 300 \
--require-match
- name: Verify trusted-publisher provenance with pypi-attestations verify pypi
run: |
set -euo pipefail
shopt -s nullglob
wheels=(bootstrap-dist/*.whl)
sdists=(bootstrap-dist/*.tar.gz)
python3 cmux-tui/bindings/verify_pypi_provenance.py \
--package cmux-sdk \
--version "$BOOTSTRAP_VERSION" \
--filename "$(basename "${wheels[0]}")" \
--filename "$(basename "${sdists[0]}")" \
--repository https://github.com/manaflow-ai/cmux \
--owner lawrencecchen \
--workflow sdk-bootstrap-pypi.yml \
--environment pypi-bootstrap
+84 -45
View File
@@ -1,19 +1,24 @@
name: sdk publish crates
name: sdk preflight crates
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate"
required: true
type: string
workflow_dispatch:
inputs:
version:
description: "SDK version to validate/publish, for example 0.1.0"
description: "SDK version to validate, for example 0.1.0"
required: true
type: string
permissions: {}
env:
RUST_TOOLCHAIN: "1.95.0"
concurrency:
group: sdk-publish-crates-${{ github.ref }}
cancel-in-progress: false
@@ -29,6 +34,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
@@ -37,13 +43,15 @@ jobs:
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-sdk-v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
@@ -51,6 +59,8 @@ jobs:
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
python3 - "$version" <<'PY'
import json
import pathlib
@@ -63,6 +73,7 @@ jobs:
"typescript package.json": json.loads((root / "cmux-tui/bindings/typescript/package.json").read_text())["version"],
"python pyproject.toml": tomllib.loads((root / "cmux-tui/bindings/python/pyproject.toml").read_text())["project"]["version"],
"rust Cargo.toml": tomllib.loads((root / "cmux-tui/bindings/rust/Cargo.toml").read_text())["package"]["version"],
"rust-sidebar Cargo.toml": tomllib.loads((root / "cmux-tui/bindings/rust-sidebar/Cargo.toml").read_text())["package"]["version"],
}
mismatches = {name: got for name, got in versions.items() if got != expected}
if mismatches:
@@ -71,6 +82,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-rust:
@@ -95,44 +109,69 @@ jobs:
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Rust version
- name: Install pinned Rust toolchain
run: |
rustc --version || true
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
cargo --version
rustc --version
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Rust binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require rust
publish:
needs: bindings-e2e-rust
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
id-token: write
environment:
name: crates-io
url: https://crates.io/crates/cmux-client
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Authenticate with crates.io trusted publishing
id: auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish cmux-client
- name: Test Rust SDK packages
working-directory: cmux-tui
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: cargo publish -p cmux-client --locked
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
cargo test -p cmux-sdk -p cmux-sidebar --locked
cargo package -p cmux-sdk --locked
cargo package \
-p cmux-sidebar \
--locked \
--no-verify \
--config \
"patch.crates-io.cmux-sdk.path='$GITHUB_WORKSPACE/cmux-tui/bindings/rust'"
verify_root="$RUNNER_TEMP/cmux-rust-package-verify"
mkdir -p "$verify_root"
tar -xzf \
"target/package/cmux-sdk-$CMUX_SDK_VERSION.crate" \
-C "$verify_root"
tar -xzf \
"target/package/cmux-sidebar-$CMUX_SDK_VERSION.crate" \
-C "$verify_root"
cargo test \
--manifest-path \
"$verify_root/cmux-sidebar-$CMUX_SDK_VERSION/Cargo.toml" \
--config \
"patch.crates-io.cmux-sdk.path='$verify_root/cmux-sdk-$CMUX_SDK_VERSION'" \
--all-targets
- name: Upload validated cmux-sdk crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-rust-sdk-crate
path: cmux-tui/target/package/cmux-sdk-${{ needs.version.outputs.version }}.crate
if-no-files-found: error
overwrite: true
- name: Upload validated cmux-sidebar crate
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-rust-sidebar-crate
path: cmux-tui/target/package/cmux-sidebar-${{ needs.version.outputs.version }}.crate
if-no-files-found: error
overwrite: true
- name: Rust SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-rust.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language rust \
--require rust \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +rust +live-creation-exit-restart-unix$' "$report"
+156 -17
View File
@@ -1,10 +1,22 @@
name: sdk publish go
name: sdk validate go
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate or verify"
required: true
type: string
verify_tag:
description: "Resolve the coordinated public Go module tag"
required: false
default: false
type: boolean
release_ref:
description: "Exact coordinated Go module tag ref"
required: false
default: ""
type: string
workflow_dispatch:
inputs:
version:
@@ -29,28 +41,66 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
env:
CALLER_WORKFLOW_REF: ${{ github.workflow_ref }}
DISPATCH_VERSION: ${{ inputs.version }}
RELEASE_REF: ${{ inputs.release_ref }}
VERIFY_TAG: ${{ inputs.verify_tag }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-tui/bindings/go/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
echo "tag must match cmux-tui/bindings/go/vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-tui/bindings/go/v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "requested version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
if [[ "$VERIFY_TAG" == "true" ]]; then
expected_caller="$GITHUB_REPOSITORY/.github/workflows/sdk-release-cut.yml@$GITHUB_REF"
[[ "$CALLER_WORKFLOW_REF" == "$expected_caller" ]] || {
echo "Public Go tag verification is only available through sdk-release-cut.yml." >&2
exit 1
}
tag="cmux-tui/bindings/go/v$version"
expected_ref="refs/tags/$tag"
[[ "$RELEASE_REF" == "$expected_ref" ]] || {
echo "Refusing to verify Go ref $RELEASE_REF; expected $expected_ref." >&2
exit 1
}
git fetch --force origin main --tags
git tag --list 'cmux-sdk-v*' | \
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version" \
--require-latest-tag
release_sha="$(git rev-parse "refs/tags/$tag^{commit}")" || {
echo "release tag does not exist: $tag" >&2
exit 1
}
git merge-base --is-ancestor "$release_sha" origin/main || {
echo "release tag $tag is not an ancestor of protected main" >&2
exit 1
}
[[ "$release_sha" == "$GITHUB_SHA" ]] || {
echo "release tag $tag resolves to $release_sha, expected workflow commit $GITHUB_SHA" >&2
exit 1
}
fi
python3 - "$version" <<'PY'
import json
import pathlib
@@ -71,9 +121,13 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-go:
if: inputs.verify_tag != true
needs: version
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 40
@@ -110,15 +164,20 @@ jobs:
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Go binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require go
- name: Go SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-go.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language go \
--require go \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +go +live-creation-exit-restart-unix$' "$report"
validate-go-module:
if: inputs.verify_tag != true
needs: bindings-e2e-go
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
@@ -136,5 +195,85 @@ jobs:
- name: Validate Go module
working-directory: cmux-tui/bindings/go
run: |
go test ./...
go build ./...
go vet ./...
verify-versioned-go-module:
if: inputs.verify_tag == true
needs: version
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 35
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ github.sha }}
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.22.x"
cache: false
- name: Resolve the public module tag from a clean consumer
env:
CMUX_SDK_VERSION: ${{ needs.version.outputs.version }}
run: |
set -euo pipefail
module="github.com/manaflow-ai/cmux/cmux-tui/bindings/go"
expected="v$CMUX_SDK_VERSION"
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' EXIT
export GOENV=off
export GOFLAGS=""
export GOINSECURE=""
export GOPROXY=https://proxy.golang.org
export GOSUMDB=sum.golang.org
export GOPRIVATE=""
export GONOPROXY=none
export GONOSUMDB=none
export GOMODCACHE="$scratch/modcache"
export GOCACHE="$scratch/buildcache"
export GOWORK=off
mkdir "$scratch/consumer"
cd "$scratch/consumer"
go mod init cmux-release-consumer
python3 "$GITHUB_WORKSPACE/cmux-tui/bindings/wait_for_go_module.py" \
--module "$module" \
--version "$expected" \
--wait-seconds 1800 \
--retry-seconds 30
go get "$module@$expected"
go mod download "$module@$expected"
go mod verify
resolved="$(go list -m -f '{{.Version}}' "$module")"
[[ "$resolved" == "$expected" ]] || {
echo "resolved $module@$resolved, expected $expected" >&2
exit 1
}
module_dir="$(go list -m -f '{{.Dir}}' "$module")"
python3 "$GITHUB_WORKSPACE/cmux-tui/bindings/verify_go_module_source.py" \
--repository "$GITHUB_WORKSPACE" \
--commit "$GITHUB_SHA" \
--module-subdir cmux-tui/bindings/go \
--downloaded-root "$module_dir"
cat > release_test.go <<EOF
package consumer
import (
"testing"
cmux "$module"
raw "$module/raw"
)
func TestReleasedPackagesCompile(t *testing.T) {
_ = cmux.ClientOptions{}
_ = raw.Options{}
}
EOF
gofmt -w release_test.go
go test -mod=readonly ./...
+16 -25
View File
@@ -1,10 +1,6 @@
name: sdk publish java
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_dispatch:
inputs:
version:
@@ -36,21 +32,11 @@ jobs:
DISPATCH_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
fi
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "workflow_dispatch version must match X.Y.Z" >&2
exit 1
}
python3 - "$version" <<'PY'
import json
import pathlib
@@ -71,6 +57,7 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py --expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-java:
@@ -113,13 +100,17 @@ jobs:
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Java binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require java
- name: Java SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-java.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language java \
--require java \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +java +live-creation-exit-restart-unix$' "$report"
maven-central-todo:
needs: bindings-e2e-java
+82 -75
View File
@@ -1,24 +1,31 @@
name: sdk publish npm
name: sdk preflight npm
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate"
required: true
type: string
outputs:
artifact_id:
description: "Immutable ID of the validated npm artifact"
value: ${{ jobs.bindings-e2e-typescript.outputs.artifact_id }}
artifact_sha256:
description: "SHA-256 of the validated npm tarball"
value: ${{ jobs.bindings-e2e-typescript.outputs.artifact_sha256 }}
workflow_dispatch:
inputs:
version:
description: "SDK version to validate/publish, for example 0.1.0"
description: "SDK version to validate, for example 0.1.0"
required: true
type: string
confirm_npm_cmux:
description: "Set true only for the coordinated npm cmux SDK publish"
required: true
default: false
type: boolean
permissions: {}
env:
RUST_TOOLCHAIN: "1.95.0"
concurrency:
group: sdk-publish-npm-${{ github.ref }}
cancel-in-progress: false
@@ -34,6 +41,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
@@ -42,13 +50,15 @@ jobs:
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-sdk-v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
@@ -56,6 +66,8 @@ jobs:
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
python3 - "$version" <<'PY'
import json
import pathlib
@@ -76,6 +88,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-typescript:
@@ -84,6 +99,9 @@ jobs:
timeout-minutes: 40
permissions:
contents: read
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -100,73 +118,62 @@ jobs:
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Rust version
- name: Install Rust toolchain
run: |
rustc --version || true
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
rustc --version
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: TypeScript binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require typescript
publish:
# The npm package name "cmux" is currently a different live package
# (the cloud-VM CLI). Publishing the SDK there is a coordinated breaking
# action, so tag pushes never publish to npm and manual runs must opt in.
if: github.event_name == 'workflow_dispatch'
needs: bindings-e2e-typescript
# npm --provenance rejects self-hosted runners; the attestation is only
# verifiable from a GitHub-hosted runner. This one publish job must stay on
# ubuntu-latest (github-hosted), unlike the routed self-hosted jobs above.
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
permissions:
contents: read
id-token: write
environment:
name: npm
url: https://www.npmjs.com/package/cmux
steps:
- name: Require npm cmux confirmation
if: inputs.confirm_npm_cmux != true
run: |
echo "Refusing to publish npm package cmux without confirm_npm_cmux=true." >&2
exit 1
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node.js
- name: Set up Node.js for conformance
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22.14.0"
registry-url: https://registry.npmjs.org
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Upgrade npm for OIDC trusted publishing
# Node 22 bundles npm 10, which signs provenance but cannot
# authenticate the publish via OIDC trusted publishing (the PUT is
# unauthenticated and 404s). npm >= 11.5.1 performs the OIDC token
# exchange for the publish itself.
run: npm install -g npm@^11.5.1
- name: Build package
- name: Install TypeScript adapter dependencies
working-directory: cmux-tui/bindings/typescript
run: |
npm ci --no-audit --no-fund
npm run build
npm test
- name: Publish package to npm
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: TypeScript SDK conformance
run: |
test "$(node -p 'typeof WebSocket')" = "function"
report="$RUNNER_TEMP/cmux-sdk-conformance-typescript.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language typescript \
--require typescript \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +typescript +live-creation-exit-restart-unix$' "$report"
grep -Eq '^PASS +typescript +live-creation-exit-restart-websocket$' "$report"
- name: Pack the validated npm artifact
id: package
working-directory: cmux-tui/bindings/typescript
# The npm `cmux` name still serves the cloud-VM CLI on the `latest`
# dist-tag (0.8.3). The SDK ships on its own `sdk` tag so installing
# bare `cmux` keeps resolving the CLI; use `npm i cmux@sdk` for the SDK.
run: npm publish --provenance --tag sdk
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/cmux-npm-dist"
npm pack --pack-destination "$RUNNER_TEMP/cmux-npm-dist"
shopt -s nullglob
packages=("$RUNNER_TEMP"/cmux-npm-dist/*.tgz)
[[ "${#packages[@]}" == 1 ]] || {
echo "expected one validated npm artifact" >&2
exit 1
}
artifact_sha256="$(sha256sum "${packages[0]}" | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- name: Upload the validated npm artifact
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-npm-dist-${{ github.run_attempt }}
path: ${{ runner.temp }}/cmux-npm-dist/*.tgz
if-no-files-found: error
+103 -47
View File
@@ -1,19 +1,31 @@
name: sdk publish python
name: sdk preflight python
on:
push:
tags:
- "mux-sdk-v*"
- "cmux-sdk-v*"
workflow_call:
inputs:
version:
description: "SDK version to validate"
required: true
type: string
outputs:
artifact_id:
description: "Immutable ID of the validated Python distributions"
value: ${{ jobs.build.outputs.artifact_id }}
artifact_sha256:
description: "SHA-256 of the validated distribution digest manifest"
value: ${{ jobs.build.outputs.artifact_sha256 }}
workflow_dispatch:
inputs:
version:
description: "SDK version to validate/publish, for example 0.1.0"
description: "SDK version to validate, for example 0.1.0"
required: true
type: string
permissions: {}
env:
RUST_TOOLCHAIN: "1.95.0"
concurrency:
group: sdk-publish-python-${{ github.ref }}
cancel-in-progress: false
@@ -29,6 +41,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 0
- name: Validate tag and package versions
id: version
@@ -37,13 +50,15 @@ jobs:
run: |
set -euo pipefail
if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then
[[ "$GITHUB_REF_NAME" =~ ^(mux|cmux)-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match mux-sdk-vX.Y.Z or cmux-sdk-vX.Y.Z" >&2
[[ "$GITHUB_REF_NAME" =~ ^cmux-sdk-v[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
echo "tag must match cmux-sdk-vX.Y.Z" >&2
exit 1
}
version="${GITHUB_REF_NAME#cmux-sdk-v}"
[[ "$DISPATCH_VERSION" == "$version" ]] || {
echo "workflow_dispatch version $DISPATCH_VERSION does not match tag version $version" >&2
exit 1
}
version="$GITHUB_REF_NAME"
version="${version#mux-sdk-v}"
version="${version#cmux-sdk-v}"
else
version="$DISPATCH_VERSION"
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
@@ -51,6 +66,8 @@ jobs:
exit 1
}
fi
python3 cmux-tui/bindings/validate_release_version.py \
--version "$version"
python3 - "$version" <<'PY'
import json
import pathlib
@@ -71,6 +88,9 @@ jobs:
raise SystemExit(1)
print(f"All package versions match {expected}")
PY
python3 cmux-tui/bindings/check-versions.py \
--published-only \
--expected "$version"
echo "version=$version" >> "$GITHUB_OUTPUT"
bindings-e2e-python:
@@ -84,6 +104,10 @@ jobs:
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
@@ -95,65 +119,97 @@ jobs:
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Rust version
- name: Install Rust toolchain
run: |
rustc --version || true
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
rustc --version
- name: Build cmux-tui server
working-directory: cmux-tui
run: cargo build -p cmux-tui
run: cargo build -p cmux-tui --bin cmux-tui --locked
- name: Python conformance fixtures
run: python3 cmux-tui/bindings/conformance/runner.py
- name: Install declared Python build backend
run: |
python3 -m pip install \
--disable-pip-version-check \
"setuptools==80.9.0"
- name: Python binding e2e
run: bash cmux-tui/bindings/conformance/e2e.sh --require python
- name: Test Python SDK package
working-directory: cmux-tui/bindings/python
run: PYTHONPATH=. python3 -m unittest discover -s tests -v
- name: Python SDK conformance
run: |
report="$RUNNER_TEMP/cmux-sdk-conformance-python.txt"
python3 cmux-tui/bindings/conformance/runner.py \
--language python \
--require python \
--cmux-tui-bin "$GITHUB_WORKSPACE/cmux-tui/target/debug/cmux-tui" |
tee "$report"
grep -Eq '^PASS +python +live-creation-exit-restart-unix$' "$report"
build:
needs: bindings-e2e-python
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
artifact_sha256: ${{ steps.package.outputs.artifact_sha256 }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.8"
- name: Install pinned Python packaging tools
run: |
python3 -m pip install \
--disable-pip-version-check \
"build==1.3.0" \
"setuptools==80.9.0" \
"wheel==0.45.1"
- name: Build sdist and wheel
working-directory: cmux-tui/bindings/python
run: |
python3 -m pip install --upgrade build
python3 -m build --sdist --wheel
set -euo pipefail
SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")"
export SOURCE_DATE_EPOCH
python3 -m build --no-isolation --sdist --wheel
python3 ../normalize_python_sdist.py \
--archive dist/*.tar.gz \
--epoch "$SOURCE_DATE_EPOCH"
- name: Test the exact Python distributions
working-directory: cmux-tui/bindings/python
env:
CMUX_PYTHON_DIST_DIR: ${{ github.workspace }}/cmux-tui/bindings/python/dist
run: PYTHONPATH=. python3 -m unittest tests.test_package_consumer -v
- name: Fingerprint the validated Python distributions
id: package
run: |
set -euo pipefail
cd cmux-tui/bindings/python/dist
shopt -s nullglob
files=(*.whl *.tar.gz)
[[ "${#files[@]}" == 2 ]] || {
echo "expected one wheel and one source distribution" >&2
exit 1
}
artifact_sha256="$(sha256sum "${files[@]}" | sort -k2 | sha256sum | cut -d ' ' -f 1)"
[[ "$artifact_sha256" =~ ^[0-9a-f]{64}$ ]] || exit 1
echo "artifact_sha256=$artifact_sha256" >> "$GITHUB_OUTPUT"
- name: Upload distributions
id: upload
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-python-dist
name: cmux-python-dist-${{ github.run_attempt }}
path: cmux-tui/bindings/python/dist/*
if-no-files-found: error
publish:
needs: build
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
contents: read
id-token: write
environment:
name: pypi
url: https://pypi.org/p/cmux
steps:
- name: Download distributions
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: cmux-python-dist
path: dist
- name: Publish package distributions to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:
packages-dir: dist
attestations: true
File diff suppressed because it is too large Load Diff
+1 -22
View File
@@ -59,28 +59,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
BUILD_FLAVOR="crashsubdir-cmux-crash-v1"
TAG="xcframework-$GHOSTTY_SHA-$BUILD_FLAVOR"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
./scripts/download-prebuilt-ghosttykit.sh
- name: Install zig
run: |
+1 -22
View File
@@ -180,28 +180,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
GHOSTTY_SHA=$(git -C ghostty rev-parse HEAD)
BUILD_FLAVOR="crashsubdir-cmux-crash-v1"
TAG="xcframework-$GHOSTTY_SHA-$BUILD_FLAVOR"
URL="https://github.com/manaflow-ai/ghostty/releases/download/$TAG/GhosttyKit.xcframework.tar.gz"
echo "Downloading xcframework for ghostty $GHOSTTY_SHA"
MAX_RETRIES=30
RETRY_DELAY=20
for i in $(seq 1 $MAX_RETRIES); do
if curl -fSL -o GhosttyKit.xcframework.tar.gz "$URL"; then
echo "Download succeeded on attempt $i"
break
fi
if [ "$i" -eq "$MAX_RETRIES" ]; then
echo "Failed to download xcframework after $MAX_RETRIES attempts" >&2
exit 1
fi
echo "Attempt $i/$MAX_RETRIES failed, retrying in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
done
tar xzf GhosttyKit.xcframework.tar.gz
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
./scripts/download-prebuilt-ghosttykit.sh
- name: Install zig
run: |
+13
View File
@@ -162,6 +162,10 @@ jobs:
run: |
swift test --package-path Packages/iOS/CmuxMobilePairedMac
- name: Run CmuxMobileChanges package tests
run: |
swift test --package-path Packages/iOS/CmuxMobileChanges
- name: Run CmuxMobileShell package tests
run: |
# iOS shell replay/liveness regressions live in this package target.
@@ -169,6 +173,12 @@ jobs:
# terminal mirror behavior without relying on local-only SwiftPM runs.
swift test --package-path Packages/iOS/CmuxMobileShell
- name: Run CmuxMobileShellModel package tests
run: |
# Pure shell-model queue and ordering regressions live in this package.
# Keep them beside the shell package gate so input framing stays covered.
swift test --package-path Packages/iOS/CmuxMobileShellModel
ios-simulator:
needs: detect-ios-changes
if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }}
@@ -395,6 +405,9 @@ jobs:
xcrun simctl boot "$SIMULATOR_ID" >/dev/null 2>&1 || true
xcrun simctl bootstatus "$SIMULATOR_ID" -b
if xcodebuild "${XCODEBUILD_ARGS[@]}" 2>&1 | tee "$LOG_PATH"; then
./scripts/ci/require_selected_test_execution.sh \
"$LOG_PATH" \
"${TEST_FILTER:-}"
exit 0
fi
status="${PIPESTATUS[0]}"
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: GhosttyKit.xcframework
key: ghosttykit-${{ hashFiles('.gitmodules', 'ghostty/**') }}
key: ghosttykit-sentry-off-v1-${{ hashFiles('.gitmodules', 'ghostty/**') }}
- name: Download pre-built GhosttyKit.xcframework
if: steps.cache-ghosttykit.outputs.cache-hit != 'true'
+65 -15
View File
@@ -4,7 +4,11 @@ on:
workflow_dispatch:
inputs:
version:
description: "TUI package version to publish, for example 0.1.0"
description: "Package version to publish, for example 0.1.0"
required: true
type: string
artifact_run_id:
description: "Successful cmux-tui release run containing verified packages"
required: true
type: string
confirm_tui_cmux:
@@ -28,6 +32,7 @@ jobs:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
permissions:
actions: read
contents: read
outputs:
release_sha: ${{ steps.release.outputs.release_sha }}
@@ -74,24 +79,67 @@ jobs:
echo "release_sha=$release_sha"
} >> "$GITHUB_OUTPUT"
build-package:
needs: validate-version
permissions:
contents: read
uses: ./.github/workflows/cmux-tui-build-package.yml
with:
version: ${{ inputs.version }}
package_npm: true
package_pypi: false
include_windows: false
checkout_ref: ${{ needs.validate-version.outputs.release_sha }}
- name: Require successful verified artifact run
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
RELEASE_SHA: ${{ steps.release.outputs.release_sha }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
[[ "$ARTIFACT_RUN_ID" =~ ^[0-9]+$ ]] || {
echo "artifact_run_id must be a GitHub Actions run ID" >&2
exit 1
}
artifact_path=".github/workflows/cmux-tui-release.yml"
artifact_status=""
artifact_conclusion=""
release_sha="$RELEASE_SHA"
for _ in {1..24}; do
IFS=$'\t' read -r actual_path artifact_head_sha artifact_head_branch artifact_status artifact_conclusion <<<"$(
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$ARTIFACT_RUN_ID" \
--jq '[.path, .head_sha, .head_branch, .status, (.conclusion // "")] | @tsv'
)"
if [[ "$actual_path" != "$artifact_path" ]]; then
echo "artifact run $ARTIFACT_RUN_ID came from $actual_path, expected $artifact_path" >&2
exit 1
fi
if [[ "$artifact_head_sha" != "$release_sha" ]]; then
echo "artifact run commit $artifact_head_sha does not match release commit $release_sha" >&2
exit 1
fi
if [[ "$artifact_head_branch" != "$RELEASE_TAG" ]]; then
echo "artifact run ref $artifact_head_branch does not match release tag $RELEASE_TAG" >&2
exit 1
fi
if [[ "$artifact_status" == "completed" ]]; then
break
fi
case "$artifact_status" in
queued|in_progress|pending|waiting|requested)
sleep 5
;;
*)
echo "artifact run $ARTIFACT_RUN_ID has unexpected status $artifact_status" >&2
exit 1
;;
esac
done
if [[ "$artifact_status" != "completed" ]]; then
echo "artifact run $ARTIFACT_RUN_ID did not complete in time" >&2
exit 1
fi
if [[ "$artifact_conclusion" != "success" ]]; then
echo "artifact run $ARTIFACT_RUN_ID concluded $artifact_conclusion" >&2
exit 1
fi
publish:
needs:
- validate-version
- build-package
needs: validate-version
runs-on: ubuntu-latest # github-hosted-required: npm provenance needs a github-hosted runner
permissions:
actions: read
contents: read
id-token: write
environment:
@@ -114,6 +162,8 @@ jobs:
with:
name: npm-packages
path: dist
run-id: ${{ inputs.artifact_run_id }}
github-token: ${{ github.token }}
- name: Restore npm package executable modes
run: |
+64 -12
View File
@@ -7,6 +7,10 @@ on:
description: "TUI package version to publish, for example 0.1.0"
required: true
type: string
artifact_run_id:
description: "Successful cmux-tui release run containing verified packages"
required: true
type: string
permissions: {}
@@ -19,6 +23,7 @@ jobs:
name: validate release source
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
actions: read
contents: read
outputs:
release_sha: ${{ steps.release.outputs.release_sha }}
@@ -65,22 +70,67 @@ jobs:
echo "release_sha=$release_sha"
} >> "$GITHUB_OUTPUT"
build-package:
needs: validate-version
permissions:
contents: read
uses: ./.github/workflows/cmux-tui-build-package.yml
with:
version: ${{ inputs.version }}
package_npm: false
package_pypi: true
include_windows: false
checkout_ref: ${{ needs.validate-version.outputs.release_sha }}
- name: Require successful verified artifact run
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_RUN_ID: ${{ inputs.artifact_run_id }}
RELEASE_SHA: ${{ steps.release.outputs.release_sha }}
RELEASE_TAG: ${{ steps.release.outputs.tag }}
run: |
set -euo pipefail
[[ "$ARTIFACT_RUN_ID" =~ ^[0-9]+$ ]] || {
echo "artifact_run_id must be a GitHub Actions run ID" >&2
exit 1
}
artifact_path=".github/workflows/cmux-tui-release.yml"
artifact_status=""
artifact_conclusion=""
release_sha="$RELEASE_SHA"
for _ in {1..24}; do
IFS=$'\t' read -r actual_path artifact_head_sha artifact_head_branch artifact_status artifact_conclusion <<<"$(
gh api "repos/$GITHUB_REPOSITORY/actions/runs/$ARTIFACT_RUN_ID" \
--jq '[.path, .head_sha, .head_branch, .status, (.conclusion // "")] | @tsv'
)"
if [[ "$actual_path" != "$artifact_path" ]]; then
echo "artifact run $ARTIFACT_RUN_ID came from $actual_path, expected $artifact_path" >&2
exit 1
fi
if [[ "$artifact_head_sha" != "$release_sha" ]]; then
echo "artifact run commit $artifact_head_sha does not match release commit $release_sha" >&2
exit 1
fi
if [[ "$artifact_head_branch" != "$RELEASE_TAG" ]]; then
echo "artifact run ref $artifact_head_branch does not match release tag $RELEASE_TAG" >&2
exit 1
fi
if [[ "$artifact_status" == "completed" ]]; then
break
fi
case "$artifact_status" in
queued|in_progress|pending|waiting|requested)
sleep 5
;;
*)
echo "artifact run $ARTIFACT_RUN_ID has unexpected status $artifact_status" >&2
exit 1
;;
esac
done
if [[ "$artifact_status" != "completed" ]]; then
echo "artifact run $ARTIFACT_RUN_ID did not complete in time" >&2
exit 1
fi
if [[ "$artifact_conclusion" != "success" ]]; then
echo "artifact run $ARTIFACT_RUN_ID concluded $artifact_conclusion" >&2
exit 1
fi
publish:
needs: build-package
needs: validate-version
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
permissions:
actions: read
contents: read
id-token: write
environment:
@@ -92,6 +142,8 @@ jobs:
with:
name: pypi-wheels
path: dist
run-id: ${{ inputs.artifact_run_id }}
github-token: ${{ github.token }}
- name: Trusted publisher setup note
run: |
+22
View File
@@ -0,0 +1,22 @@
name: Vercel auth health
on:
workflow_dispatch:
schedule:
- cron: "17 6 * * *"
permissions:
contents: read
jobs:
check:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
- name: Verify Vercel token
run: bunx [email protected] whoami
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
+149
View File
@@ -2,6 +2,155 @@
All notable changes to cmux are documented here.
## [0.64.22] - 2026-08-03
### Fixed
- 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!
### Thanks to 5 contributors!
- [@8bit-void](https://github.com/8bit-void)
- [@austinywang](https://github.com/austinywang)
- [@KousukeUchiyama](https://github.com/KousukeUchiyama)
- [@PhilipPinckaers](https://github.com/PhilipPinckaers)
- [@seanyoungberg](https://github.com/seanyoungberg)
## [0.64.21] - 2026-08-02
### Added
- 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): chronological notification feed ([#8210](https://github.com/manaflow-ai/cmux/pull/8210)) -- thanks @azooz2003-bit!
- iOS (beta): launch agent workspaces straight from the task composer ([#7670](https://github.com/manaflow-ai/cmux/pull/7670))
- iOS (beta): Tailscale connection method opt-in with QR-authorized pairing ([#9247](https://github.com/manaflow-ai/cmux/pull/9247)) -- 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!
- Exclude `.attrib` from watched filesystem events ([#8659](https://github.com/manaflow-ai/cmux/pull/8659)) -- thanks @varomorf!
- Preserve surface IDs in workstream events ([#8703](https://github.com/manaflow-ai/cmux/pull/8703)) -- thanks @revanthreddy-hai!
- 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!
### Thanks to 13 contributors!
- [@austinywang](https://github.com/austinywang)
- [@azooz2003-bit](https://github.com/azooz2003-bit)
- [@bencollins2](https://github.com/bencollins2)
- [@djova](https://github.com/djova)
- [@ejc3](https://github.com/ejc3)
- [@fml09](https://github.com/fml09)
- [@joshfree](https://github.com/joshfree)
- [@lawrencecchen](https://github.com/lawrencecchen)
- [@mrohan-sq](https://github.com/mrohan-sq)
- [@mykmelez](https://github.com/mykmelez)
- [@oscarbrey](https://github.com/oscarbrey)
- [@revanthreddy-hai](https://github.com/revanthreddy-hai)
- [@varomorf](https://github.com/varomorf)
## [0.64.20] - 2026-07-19
### Added
- 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))
- Resize browser pane viewports ([#8072](https://github.com/manaflow-ai/cmux/pull/8072))
- 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))
- Fix tmux mirror pane sizing and divider drag synchronization ([#7996](https://github.com/manaflow-ai/cmux/pull/7996)) -- thanks @ejc3!
- Fix new-surface targeting and tab rename for remote tmux panes ([#8403](https://github.com/manaflow-ai/cmux/pull/8403), [#8404](https://github.com/manaflow-ai/cmux/pull/8404)); fix ssh-tmux lifecycle and window-focus routing ([#8405](https://github.com/manaflow-ai/cmux/pull/8405), [#8402](https://github.com/manaflow-ai/cmux/pull/8402))
- SSH: clear the auth marker after successful startup ([#8410](https://github.com/manaflow-ai/cmux/pull/8410)); fix the Ghostty SSH wrapper path in embedded app bundles ([#8109](https://github.com/manaflow-ai/cmux/pull/8109))
- Coalesce terminal resizes during split-divider drags ([#8240](https://github.com/manaflow-ai/cmux/pull/8240))
- Fix interaction paths in capped session panes ([#8250](https://github.com/manaflow-ai/cmux/pull/8250))
- Fix automatic terminal top inset ([#8168](https://github.com/manaflow-ai/cmux/pull/8168))
- Fix Files panel contrast across appearances ([#8290](https://github.com/manaflow-ai/cmux/pull/8290))
- Fix inconsistent table border thickness ([#8193](https://github.com/manaflow-ai/cmux/pull/8193))
- Fix a visible popover resize crash ([#8115](https://github.com/manaflow-ai/cmux/pull/8115)) -- thanks @azooz2003-bit! -- and update-popover resize reentrancy ([#8195](https://github.com/manaflow-ai/cmux/pull/8195))
- Bound overflowing confirmation dialog content ([#8296](https://github.com/manaflow-ai/cmux/pull/8296))
- Fix Settings shortcut display for legacy overrides ([#8091](https://github.com/manaflow-ai/cmux/pull/8091))
- Browser: fix Space key handling ([#8079](https://github.com/manaflow-ai/cmux/pull/8079)), numeric eval formatting ([#8077](https://github.com/manaflow-ai/cmux/pull/8077)), and wedged automation recovery ([#8094](https://github.com/manaflow-ai/cmux/pull/8094))
- iOS (beta): authenticated Iroh transport with cold-start retries and stale-session recovery ([#7908](https://github.com/manaflow-ai/cmux/pull/7908), [#8181](https://github.com/manaflow-ai/cmux/pull/8181), [#8286](https://github.com/manaflow-ai/cmux/pull/8286), [#8196](https://github.com/manaflow-ai/cmux/pull/8196), [#8424](https://github.com/manaflow-ai/cmux/pull/8424)) -- thanks @azooz2003-bit!
- iOS (beta): match terminal themes across chrome and live reloads ([#7919](https://github.com/manaflow-ai/cmux/pull/7919))
- iOS (beta): smooth workspace-list scrolling with exact row heights ([#8186](https://github.com/manaflow-ai/cmux/pull/8186)); fix reconnect and build isolation ([#8299](https://github.com/manaflow-ai/cmux/pull/8299)) -- thanks @azooz2003-bit!
### Thanks to 5 contributors!
- [@austinywang](https://github.com/austinywang)
- [@azooz2003-bit](https://github.com/azooz2003-bit)
- [@ejc3](https://github.com/ejc3)
- [@lawrencecchen](https://github.com/lawrencecchen)
- [@silouanwright](https://github.com/silouanwright)
## [0.64.19] - 2026-07-14
### Fixed
+54 -208
View File
@@ -1,265 +1,111 @@
# cmux agent notes
## Initial setup
## Setup
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).
Compile-only check, no launch:
```bash
./scripts/reload.sh --tag fix-zsh-autosuggestions
xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-<tag> build
```
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:
```bash
./scripts/reload.sh --tag fix-zsh-autosuggestions --launch
cd ghostty && zig build -Demit-xcframework=true -Dxcframework-target=universal -Doptimize=ReleaseFast
```
`reload.sh` prints an `App path:` line with the absolute path to the built `.app`. Use that path to build a cmd-clickable `file://` URL. Steps:
Clean up older tags you started this session (quit the app, remove its `/tmp` socket and derived data) before launching a new one.
1. Grab the path from the `App path:` line in `reload.sh` output.
2. Prepend `file://` and URL-encode spaces as `%20`. Do not hardcode any part of the path.
3. Format it as a markdown link using the template for your agent type.
## Tag-bound debug CLI
Example. If `reload.sh` output contains:
```text
App path:
/Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux DEV my-tag.app
```
**Claude Code** outputs:
```markdown
-------------------------------------------------------
[cmux DEV my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app)
-------------------------------------------------------
```
**Codex** outputs:
```markdown
-------------------------------------------------------
[my-tag: file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app](file:///Users/someone/Library/Developer/Xcode/DerivedData/cmux-my-tag/Build/Products/Debug/cmux%20DEV%20my-tag.app)
-------------------------------------------------------
```
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.
```bash
CMUX_TAG=<tag> scripts/cmux-debug-cli.sh list-workspaces
CMUX_TAG=<tag> scripts/cmux-debug-cli.sh send --workspace workspace:1 --surface surface:1 "echo ok"
```
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.
```bash
xcodebuild -project cmux.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-<your-tag> build
```
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.
```bash
cd ghostty
git remote -v # origin = upstream, manaflow = fork
git checkout -b <branch>
git add <files>
git commit -m "..."
git push manaflow <branch>
```
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 patch # bump patch (0.15.0 → 0.15.1)
./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.
Manual release steps (if not using the command):
```bash
./scripts/release-pretag-guard.sh
git tag vX.Y.Z
git push origin vX.Y.Z
gh run watch --repo manaflow-ai/cmux
```
Notes:
- Requires GitHub secrets: `APPLE_CERTIFICATE_BASE64`, `APPLE_CERTIFICATE_PASSWORD`,
`APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`.
- The release asset is `cmux-macos.dmg` attached to the tag.
- README download button points to `releases/latest/download/cmux-macos.dmg`.
- Versioning: bump the minor version for updates unless explicitly asked otherwise.
- Changelog: update `CHANGELOG.md`; docs changelog is rendered from it.
When a user says tests missed a bug, add behavior-level coverage around the exact repro path before claiming the fix is complete.
## Skills
Detailed cmux contributor rules live in repo skills under `skills/`; use the task-specific skill before changing that area.
Detailed contributor rules live in `skills/`. Use the task-specific skill before changing that area.
Core skill map:
- `cmux-dev-workflow`: setup, tagged reloads, Xcode project normalization, sidebar extension tagging, local dev build isolation.
- `cmux-architecture`: package boundaries, refactor architecture, file/API discipline, testability, Swift concurrency rules.
- `cmux-dev-workflow`: setup, tagged reloads, Xcode project normalization, sidebar extension tagging, build isolation.
- `cmux-architecture`: package boundaries, file/API discipline, testability, Swift concurrency.
- `cmux-backend`: backend TypeScript, Effect, Cloud VM control plane, provider secrets, Postgres and migrations.
- `cmux-billing`: Stripe checkout, entitlements, webhooks, pricing dev stack, live provisioning.
- `cmux-debugging`: debug event log, Debug menu, runtime pitfalls, typing-sensitive paths, SwiftUI list boundaries.
- `cmux-localization`: user-facing strings, localization files, shortcut text, and localization audit.
- `cmux-localization`: user-facing strings, localization files, shortcut text, localization audit.
- `cmux-testing`: regression policy, Swift Testing, test quality, test wiring, local vs CI validation.
- `cmux-socket-policy`: socket command threading and focus preservation.
- `cmux-shared-behavior`: shared action paths for multi-entrypoint behavior and optimistic updates.
- `cmux-ghostty`: Ghostty submodule and GhosttyKit workflow.
- `cmux-release`: release, version bump, changelog, pretag guard, and release asset workflow.
- `cmux-release`: release, version bump, changelog, pretag guard, release assets.
+5
View File
@@ -0,0 +1,5 @@
/// Stable observability stages for failures that would otherwise drop hook state silently.
enum AgentHookFailureStage: String, Sendable {
case targetResolution = "target-resolution"
case notificationDelivery = "notification-delivery"
}
+20
View File
@@ -189,6 +189,26 @@ enum AgentHookNotificationClassifier {
enum AgentHookNotificationPolicy {
static let dedupeEligibleAgents: Set<String> = ["grok", "antigravity"]
static func notificationTitle(
agentName: String,
displayName: String,
surfaceTitle: String?
) -> String {
guard agentName == "pi",
let surfaceTitle = surfaceTitle?.trimmingCharacters(in: .whitespacesAndNewlines),
!surfaceTitle.isEmpty else {
return displayName
}
if surfaceTitle.caseInsensitiveCompare(displayName) == .orderedSame
|| surfaceTitle.range(
of: "\(displayName) · ",
options: [.anchored, .caseInsensitive]
) != nil {
return surfaceTitle
}
return "\(displayName) · \(surfaceTitle)"
}
/// Stable per-session fingerprint. Grok 0.2.91 emits an identical generic
/// "Tool permission requested" Notification for every tool step, even in
/// auto-approve mode where nothing awaits the user; those repeats dedupe by
@@ -0,0 +1,39 @@
import Foundation
extension CMUXCLI {
/// The ownership result returned by a guarded agent resume-binding clear.
enum AgentSurfaceResumeBindingClearOutcome: Equatable {
case cleared
case checkpointDidNotOwnBinding
case failed
}
func clearAgentSurfaceResumeBindingOutcome(
client: SocketClient,
workspaceId: String,
surfaceId: String,
sessionId: String?,
sessionDidEnd: Bool = false
) -> AgentSurfaceResumeBindingClearOutcome {
let normalizedSessionId = normalizedHookValue(sessionId)
var params: [String: Any] = [
"surface_id": surfaceId,
"source": "agent-hook"
]
if let normalizedSessionId {
params["checkpoint_id"] = normalizedSessionId
}
if sessionDidEnd, normalizedSessionId != nil {
params["agent_session_ended"] = true
}
do {
let result = try client.sendV2(method: "surface.resume.clear", params: params)
guard let cleared = result["cleared"] as? Bool else {
return .failed
}
return cleared ? .cleared : .checkpointDidNotOwnBinding
} catch {
return .failed
}
}
}
+5 -1
View File
@@ -1,4 +1,5 @@
import CmuxFoundation
import CmuxSentryReporting
import Darwin
import Foundation
@@ -47,6 +48,7 @@ final class CLISocketSentryTelemetry {
private let surfaceId: String?
private let disabledByEnv: Bool
private let noiseFilter: SentryNoiseFilter
private let sentryPolicy: CLISocketSentryPolicy
private var pendingBreadcrumbs: [PendingBreadcrumb] = []
#if canImport(Sentry)
@@ -117,6 +119,7 @@ final class CLISocketSentryTelemetry {
processEnv["CMUX_CLI_SENTRY_DISABLED"] == "1" ||
processEnv["CMUX_CLAUDE_HOOK_SENTRY_DISABLED"] == "1"
self.noiseFilter = SentryNoiseFilter()
self.sentryPolicy = CLISocketSentryPolicy(environment: processEnv)
}
func breadcrumb(_ message: String, data: [String: Any] = [:]) {
@@ -132,7 +135,8 @@ final class CLISocketSentryTelemetry {
guard !noiseFilter.isExpectedCLISocketTransportFailure(
stage: stage,
message: errorDescription,
dataKeys: Set(data.keys)
dataKeys: Set(data.keys),
allowSandboxPolicyDenial: sentryPolicy.allowsSandboxPolicyDenial
) else {
return
}
+1 -2
View File
@@ -142,8 +142,7 @@ extension CMUXCLI {
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
aliases: ["agy"],
sessionEndIsTurnBoundary: true,
feedHookEvents: ["PreToolUse", "PostToolUse"]
sessionEndIsTurnBoundary: true
),
AgentHookDef(
name: "rovodev", displayName: "Rovo Dev", statusKey: "rovodev",
+25 -7
View File
@@ -1,8 +1,13 @@
import CMUXAgentLaunch
import Foundation
extension CMUXCLI {
// MARK: - Generic agent hook system
// The client deadline must fire before the generated agent-hook timeout.
static let feedHookProcessTimeoutMilliseconds = 120_000
static let feedHookClientDeadlineSeconds = Double(feedHookProcessTimeoutMilliseconds) / 1_000 - 2
static let feedHookDecisionWaitSeconds = feedHookClientDeadlineSeconds - 3
/// Configuration for a hook-based agent integration.
struct AgentHookDef {
let name: String // CLI name: "cursor", "gemini", etc.
@@ -39,11 +44,7 @@ extension CMUXCLI {
/// separate `session-finalize` subcommand / ``AgentHookAction/sessionFinalize``
/// action, which performs the destructive cleanup this flag suppresses.
let sessionEndIsTurnBoundary: Bool
/// Feed-hook events. Each entry installs a second hook for
/// `agentEvent` that invokes `cmux hooks feed --source <name>`
/// with a 120s timeout so the socket reply wait doesn't trip the
/// agent's default hook timeout when the user takes time to
/// approve/deny a permission / plan / question.
/// Events that install a `cmux hooks feed --source <name>` bridge.
let feedHookEvents: [String]
let postInstallAction: PostInstallAction?
/// Optional CLI note printed after a successful install (or
@@ -222,8 +223,13 @@ extension CMUXCLI {
}
}
static let stdinDrainingHookNoOpShellCommand = "cat >/dev/null 2>/dev/null || true; echo '{}'"
private static func shellNoOpSnippet(_ noOpCommand: String) -> String {
noOpCommand == "echo '{}'" ? noOpCommand : "{ \(noOpCommand); }"
let command = noOpCommand == "echo '{}'"
? stdinDrainingHookNoOpShellCommand
: noOpCommand
return "{ \(command); }"
}
private static let grokPinnedHookMarker = "cmux-grok-hook-v2"
@@ -304,7 +310,7 @@ extension CMUXCLI {
} else {
dispatch = "command -v cmux >/dev/null 2>&1 && \(fallbackInvocation) || \(noOpSnippet)"
}
return ": \(pinnedHookMarker(for: def)); \(shellTraceStart); printenv \(def.disableEnvVar) | grep -qx 1 && { \(shellTraceDisabled); \(noOpCommand); } || { \(dispatch); cmux_hook_status=$?; \(shellTraceExit); exit $cmux_hook_status; }"
return ": \(pinnedHookMarker(for: def)); \(shellTraceStart); printenv \(def.disableEnvVar) | grep -qx 1 && { \(shellTraceDisabled); \(noOpSnippet); } || { \(dispatch); cmux_hook_status=$?; \(shellTraceExit); exit $cmux_hook_status; }"
}
private static func pinnedHookInvocation(
@@ -417,6 +423,9 @@ extension CMUXCLI {
if usesPinnedHookDispatch(def), command.contains(pinnedHookMarker(for: def)) {
return true
}
if def.name == "codex", isCmuxOwnedCodexHookScriptCommand(command) {
return true
}
if def.events.contains(where: { hookCommandString(for: def, event: $0) == command })
|| def.feedHookEvents.contains(where: { feedHookCommandString(for: def, agentEvent: $0) == command })
{
@@ -425,6 +434,15 @@ extension CMUXCLI {
return includeLegacy && isLegacyCmuxOwnedHookCommand(command, for: def)
}
private static func isCmuxOwnedCodexHookScriptCommand(_ command: String) -> Bool {
guard let hooksDirectory = codexHookScriptsDirectory() else { return false }
let url = URL(fileURLWithPath: command, isDirectory: false)
let name = url.lastPathComponent
return CodexHookScriptName(filename: name) != nil
&& url.deletingLastPathComponent().standardizedFileURL
== hooksDirectory.standardizedFileURL
}
private static func isLegacyCmuxOwnedHookCommand(_ command: String, for def: AgentHookDef) -> Bool {
// Codex also had older top-level codex-hook/feed-hook commands.
// Other generic agents can have stale `cmux hooks ...` files from
@@ -0,0 +1,59 @@
import Foundation
import OSLog
nonisolated private let agentHookDeliveryLogger = Logger(
subsystem: "com.cmuxterm.cli",
category: "AgentHookDelivery"
)
extension CMUXCLI {
/// Chooses the live wrapper PID for Codex while preserving legacy precedence for other agents.
func preferredAgentHookEventPID(
agentName: String,
mappedPID: Int?,
inferredPID: Int?
) -> Int? {
agentName == "codex"
? inferredPID ?? mappedPID
: mappedPID ?? inferredPID
}
/// Reports a persistently throttled hook failure without serializing raw transport details.
func reportAgentHookFailure(
stage: AgentHookFailureStage,
agentName: String,
sessionId: String,
event: String,
error: Error? = nil,
store: ClaudeHookSessionStore,
telemetry: CLISocketSentryTelemetry
) {
guard (try? store.claimAgentHookFailureReport(
agentName: agentName,
stage: stage.rawValue,
sessionId: sessionId
)) == true else {
return
}
let shortSessionId = String(sessionId.prefix(12))
let errorType = error.map { String(reflecting: type(of: $0)) } ?? "unresolved-target"
let reportableError = NSError(
domain: "com.cmuxterm.cli.agent-hook.\(stage.rawValue)",
code: 1,
userInfo: ["underlying_error_type": errorType]
)
agentHookDeliveryLogger.error(
"Agent hook failed stage=\(stage.rawValue, privacy: .public) event=\(event, privacy: .public) agent=\(agentName, privacy: .public) session=\(shortSessionId, privacy: .private(mask: .hash)) errorType=\(errorType, privacy: .private(mask: .hash))"
)
telemetry.captureError(
stage: "agent-hook-\(stage.rawValue)",
error: reportableError,
data: [
"agent": agentName,
"hook_event": event,
"has_session_id": !sessionId.isEmpty,
"underlying_error_type": errorType,
]
)
}
}
+122
View File
@@ -0,0 +1,122 @@
import Foundation
extension CMUXCLI {
func liveAgentControllingTTYBinding(
pid: Int?,
client: SocketClient
) -> AgentHookProcessBindingProbe {
guard !client.isRelayBacked, let pid, pid > 0 else {
return .notAttempted
}
let payload: [String: Any]
do {
payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: [
"pid": pid,
"pid_resolution": AgentProcessBindingResolution.controllingTTY.rawValue,
],
responseTimeout: 2
)
} catch let error as CLIError where error.v2Code == "method_not_found"
|| error.v2Code == "unrecognized_method" {
return .unsupported
} catch {
return .failed
}
guard (payload["source"] as? String) == "pid",
(payload["pid_resolution"] as? String) == AgentProcessBindingResolution.controllingTTY.rawValue,
let workspaceId = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(workspaceId),
let surfaceId = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceId) else {
return .failed
}
return .resolved(CallerTerminalBinding(workspaceId: workspaceId, surfaceId: surfaceId))
}
func resolveAgentHookProcessBinding(
pid: Int?,
resolution: AgentProcessBindingResolution,
client: SocketClient
) -> AgentHookProcessBindingResult {
guard resolution == .controllingTTY else {
return corroboratedAgentHookProcessBinding(pid: pid, client: client)
}
switch liveAgentControllingTTYBinding(pid: pid, client: client) {
case .resolved(let binding):
return AgentHookProcessBindingResult(binding: binding, source: .liveProcess, rejectsAmbientClaim: false)
case .unsupported:
return corroboratedAgentHookProcessBinding(pid: pid, client: client)
case .failed:
return AgentHookProcessBindingResult(binding: nil, source: nil, rejectsAmbientClaim: true)
case .notAttempted:
return AgentHookProcessBindingResult(
binding: uniqueCallerTerminalBindingByTTY(client: client),
source: .ambientTTY,
rejectsAmbientClaim: false
)
}
}
private func corroboratedAgentHookProcessBinding(
pid: Int?,
client: SocketClient
) -> AgentHookProcessBindingResult {
if let binding = uniqueCallerTerminalBindingByTTY(client: client) {
return AgentHookProcessBindingResult(binding: binding, source: .ambientTTY, rejectsAmbientClaim: false)
}
return AgentHookProcessBindingResult(
binding: resolveAgentProcessTerminalBinding(pid: pid, client: client),
source: .liveProcess,
rejectsAmbientClaim: false
)
}
func clearSupersededAgentHookSessions(
_ initialRecords: [ClaudeHookSessionRecord],
owner: ClaudeHookSessionRecord,
statusKey: String,
store: ClaudeHookSessionStore,
client: SocketClient
) {
var records = initialRecords
if records.isEmpty {
records = (try? store.pendingSupersededSessionCleanupCandidates(for: owner)) ?? []
}
var clearedRecords: [ClaudeHookSessionRecord] = []
for record in records {
let resumeClearOutcome = clearAgentSurfaceResumeBindingOutcome(
client: client,
workspaceId: record.workspaceId,
surfaceId: record.surfaceId,
sessionId: record.sessionId
)
guard resumeClearOutcome != .failed else {
continue
}
if record.surfaceId == owner.surfaceId {
// Registering the replacement structured PID on this panel has
// already evicted the superseded key. Avoid a redundant
// key-miss clear while the replacement may not have published
// its own PID yet.
clearedRecords.append(record)
continue
}
let pidKey = "\(statusKey).\(record.sessionId)"
do {
_ = try sendV1Command(
"clear_agent_pid \(pidKey) --tab=\(record.workspaceId)\(socketPanelOption(record.surfaceId)) --clear-status --require-owned-key",
client: client
)
clearedRecords.append(record)
} catch {
continue
}
}
try? store.acknowledgeSupersededSessionCleanup(clearedRecords)
}
}
@@ -0,0 +1,8 @@
extension CMUXCLI {
enum AgentHookProcessBindingProbe {
case notAttempted
case unsupported
case failed
case resolved(CallerTerminalBinding)
}
}
@@ -0,0 +1,12 @@
extension CMUXCLI {
struct AgentHookProcessBindingResult {
let binding: CallerTerminalBinding?
let source: AgentHookProcessBindingSource?
let rejectsAmbientClaim: Bool
func canReplaceAmbientWorkspace(_ workspaceId: String?) -> Bool {
guard let workspaceId else { return true }
return source == .liveProcess || binding?.workspaceId == workspaceId
}
}
}
@@ -0,0 +1,6 @@
extension CMUXCLI {
enum AgentHookProcessBindingSource {
case ambientTTY
case liveProcess
}
}
+20
View File
@@ -141,6 +141,26 @@ struct AutoNamingEnvironmentPolicy: Sendable {
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return override.isEmpty ? "haiku" : override
}
/// Inline MCP configuration passed with `--strict-mcp-config` so the
/// summarizer starts no MCP servers. Claude Code validates this JSON
/// against a schema requiring an `mcpServers` record, so a bare `{}` is
/// rejected during argument parsing and the subprocess exits before it
/// can produce a title (cmux#9457).
static let emptyMCPConfigJSON = #"{"mcpServers":{}}"#
/// Argument vector for the tool-disabled `claude -p` summarizer call.
func claudeSummarizerArguments(from env: [String: String]) -> [String] {
[
"-p",
"--model", claudeModel(from: env),
"--tools", "",
"--disable-slash-commands",
"--no-session-persistence",
"--strict-mcp-config",
"--mcp-config", Self.emptyMCPConfigJSON
]
}
}
/// Pure auto-naming logic: throttle decisions, transcript extraction,
+1 -9
View File
@@ -123,15 +123,7 @@ extension CMUXCLI {
guard let executable else { return nil }
return runAutoNamingSummarizer(
executable: executable,
arguments: [
"-p",
"--model", policy.claudeModel(from: env),
"--tools", "",
"--disable-slash-commands",
"--no-session-persistence",
"--strict-mcp-config",
"--mcp-config", "{}"
],
arguments: policy.claudeSummarizerArguments(from: env),
prompt: prompt,
environment: policy.summarizerEnvironment(from: env),
timeout: timeout
+12 -2
View File
@@ -76,15 +76,25 @@ extension CMUXCLI {
client: SocketClient,
includeAmbientTTY: Bool = true
) -> CallerTerminalBinding? {
guard let ttyName = resolveCallerTTYName(includeAmbientTTY: includeAmbientTTY),
let payload = try? client.sendV2(method: "debug.terminals") else {
guard let ttyName = resolveCallerTTYName(includeAmbientTTY: includeAmbientTTY) else {
return nil
}
return uniqueCallerTerminalBindingByTTY(ttyName: ttyName, client: client)
}
func uniqueCallerTerminalBindingByTTY(
ttyName: String,
client: SocketClient,
workspaceId: String? = nil
) -> CallerTerminalBinding? {
guard let payload = try? client.sendV2(method: "debug.terminals") else { return nil }
let terminals = payload["terminals"] as? [[String: Any]] ?? []
let scopedWorkspaceId = normalizedHandleValue(workspaceId)
var matched: [CallerTerminalBinding] = []
for terminal in terminals {
guard normalizedTTYName(terminal["tty"] as? String) == ttyName,
let workspaceId = normalizedHandleValue(terminal["workspace_id"] as? String),
scopedWorkspaceId == nil || workspaceId == scopedWorkspaceId,
let surfaceId = normalizedHandleValue(terminal["surface_id"] as? String) else {
continue
}
+16 -21
View File
@@ -1,21 +1,7 @@
import CMUXAgentLaunch
import Foundation
extension CMUXCLI {
/// The per-invocation Codex hook events the wrapper injects, paired with the
/// cmux subcommand they call and the codex hook timeout (ms). Lifecycle
/// events are short; feed events (`PreToolUse`/`PermissionRequest`) are long
/// because the user may take time to approve. This is the single source of
/// truth for `cmux-codex-wrapper`'s injection, mirrored from the historic
/// hand-rolled `cmux_codex_add_hook` calls in the wrapper.
static let codexWrapperInjectionEvents: [(agentEvent: String, cmuxSubcommand: String, timeoutMs: Int)] = [
("SessionStart", "session-start", 10000),
("UserPromptSubmit", "prompt-submit", 10000),
("Stop", "stop", 10000),
("PreToolUse", "pre-tool-use", 120000),
("PostToolUse", "post-tool-use", 10000),
("PermissionRequest", "notification", 120000),
]
/// Emit, NUL-separated to stdout, the exact codex arg list the wrapper must
/// splice ahead of the user's args to enable + inject cmux's fire-and-forget
/// hooks for one codex invocation. Returns the arg list:
@@ -41,7 +27,7 @@ extension CMUXCLI {
// inline snippet so the working path can never regress.
let hooksDir = Self.codexHookScriptsDirectory()
var args: [String] = ["--enable", "hooks", "--dangerously-bypass-hook-trust"]
for event in Self.codexWrapperInjectionEvents {
for event in CodexHookInjectionSchema.current.events {
let ff = Self.codexFireAndForgetAgentHookShellCommand(
"cmux hooks codex \(event.cmuxSubcommand)", for: codexDef
)
@@ -100,11 +86,19 @@ extension CMUXCLI {
/// directly rather than through a shell. Content is identical across
/// invocations, so the file is only rewritten when missing or changed.
static func writeCodexHookScript(subcommand: String, body: String, in dir: URL) -> String? {
let safeName = subcommand.replacingOccurrences(
of: "[^A-Za-z0-9_-]", with: "-", options: .regularExpression
)
let url = dir.appendingPathComponent("cmux-codex-hook-\(safeName).sh", isDirectory: false)
let contents = "#!/bin/sh\n\(body)\n"
guard let scriptName = CodexHookScriptName(
contents: contents,
subcommand: subcommand
) else {
return nil
}
// Keep generated scripts immutable. Older cmux processes may still write
// the legacy path while newer Codex sessions reference this content ID.
let url = dir.appendingPathComponent(
scriptName.filename,
isDirectory: false
)
let fileManager = FileManager.default
if let existing = try? String(contentsOf: url, encoding: .utf8), existing == contents {
// Ensure it stays executable, then reuse.
@@ -123,11 +117,12 @@ extension CMUXCLI {
static func codexFireAndForgetAgentHookShellCommand(_ command: String, for def: AgentHookDef) -> String {
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( sleep 30; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\""
let noOp = stdinDrainingHookNoOpShellCommand
return [
"cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"",
"if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi",
"agent_pid=\"${CMUX_CODEX_PID:-${PPID:-}}\"",
"if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then payload=\"$(mktemp \"${TMPDIR:-/tmp}/cmux-codex-hook.XXXXXX\" 2>/dev/null || mktemp -t cmux-codex-hook 2>/dev/null)\" || { echo '{}'; exit 0; }; cat >\"$payload\" || true; if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments) >/dev/null 2>&1 & else CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" \(routedArguments) >/dev/null 2>&1 & fi; echo '{}'; else echo '{}'; fi",
"if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then payload=\"$(mktemp \"${TMPDIR:-/tmp}/cmux-codex-hook.XXXXXX\" 2>/dev/null || mktemp -t cmux-codex-hook 2>/dev/null)\" || { \(noOp); exit 0; }; cat >\"$payload\" || true; if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments) >/dev/null 2>&1 & else CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" \(routedArguments) >/dev/null 2>&1 & fi; echo '{}'; else \(noOp); fi",
].joined(separator: "; ")
}
}
+6
View File
@@ -53,6 +53,7 @@ extension CMUXCLI {
static let topLevelCommandNames: Set<String> = [
"__codex-teams-watch",
"__internal_flags",
"__sidebar_footer_icon_balance",
"__tmux-compat",
"agent-hibernation",
"ai-accounts",
@@ -107,6 +108,7 @@ extension CMUXCLI {
"hooks",
"identify",
"is-webview-focused",
"ios",
"join-pane",
"jump-to-unread",
"last-pane",
@@ -127,6 +129,8 @@ extension CMUXCLI {
"mark-notification-read",
"memory",
"mobile",
"mosh",
"mosh-tmux",
"move-surface",
"move-tab-to-new-workspace",
"move-workspace-to-window",
@@ -162,6 +166,7 @@ extension CMUXCLI {
"resize-pane",
"respawn-pane",
"restore-session",
"restore",
"right-sidebar",
"rpc",
"select-workspace",
@@ -178,6 +183,7 @@ extension CMUXCLI {
"setup-hooks",
"shortcuts",
"simulate-app-active",
"simulator",
"sidebar",
"sidebar-state",
"split-off",
+94 -20
View File
@@ -3,6 +3,7 @@ import Darwin
import Foundation
private struct EventStreamLimitReached: Error {}
private struct EventStreamSnapshotCaptured: Error {}
extension CMUXCLI {
private struct EventsCommandOptions {
@@ -12,6 +13,8 @@ extension CMUXCLI {
var categories: [String] = []
var reconnect = false
var limit: Int?
var timeout: TimeInterval?
var snapshotOnly = false
var printAck = true
var printHeartbeats = true
}
@@ -28,15 +31,56 @@ extension CMUXCLI {
var lastSeq = options.afterSeq
var emittedEvents = 0
// The --timeout budget is measured on a MONOTONIC clock so a
// wall-clock change (NTP step, timezone, manual set) can neither
// expire the whole command instantly nor extend it indefinitely.
// The socket layer takes wall-clock Dates, so each blocking call
// derives a fresh short-lived Date from the monotonic remainder;
// a wall jump can then only skew the single wait in flight, never
// the accumulated budget.
let budgetClock = ContinuousClock()
let budgetDeadline = options.timeout.map { budgetClock.now.advanced(by: .seconds($0)) }
func remainingBudget() -> TimeInterval? {
guard let budgetDeadline else { return nil }
let remaining = budgetClock.now.duration(to: budgetDeadline)
let seconds = Double(remaining.components.seconds)
+ Double(remaining.components.attoseconds) / 1e18
return max(0, seconds)
}
func socketDeadline() -> Date? {
remainingBudget().map { Date(timeIntervalSinceNow: $0) }
}
func timeoutError() -> CLIError {
CLIError(message: String(
localized: "cli.events.error.timeout",
defaultValue: "Timed out waiting for a matching event"
))
}
while true {
if let remaining = remainingBudget(), remaining <= 0 {
throw timeoutError()
}
let client = SocketClient(path: socketPath)
do {
try client.connect()
if let connectDeadline = socketDeadline() {
try client.connect(deadline: connectDeadline)
} else {
try client.connect()
}
// Connection setup may have consumed the rest of the budget;
// re-check before starting authentication so it always gets a
// non-negative timeout.
let authRemaining = remainingBudget()
if let authRemaining, authRemaining <= 0 {
throw timeoutError()
}
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
socketPath: socketPath,
responseTimeout: authRemaining,
deadline: socketDeadline()
)
var params: [String: Any] = [
@@ -52,7 +96,11 @@ extension CMUXCLI {
params["categories"] = options.categories
}
try client.streamV2(method: "events.stream", params: params) { line in
try client.streamV2(
method: "events.stream",
params: params,
deadline: socketDeadline()
) { line in
guard !line.isEmpty else { return }
let frame = try parseEventStreamFrame(line)
let type = frame["type"] as? String ?? ""
@@ -67,15 +115,17 @@ extension CMUXCLI {
eventSequence = nil
}
if type == "ack", !options.printAck {
return
}
if type == "heartbeat", !options.printHeartbeats {
return
let shouldPrint =
(type != "ack" || options.printAck)
&& (type != "heartbeat" || options.printHeartbeats)
if shouldPrint {
print(line)
fflush(stdout)
}
print(line)
fflush(stdout)
if type == "ack", options.snapshotOnly {
throw EventStreamSnapshotCaptured()
}
if let eventSequence {
if let cursorFile = options.cursorFile {
@@ -88,15 +138,25 @@ extension CMUXCLI {
}
}
}
} catch is EventStreamSnapshotCaptured {
client.close()
return
} catch is EventStreamLimitReached {
client.close()
return
} catch {
client.close()
if let remaining = remainingBudget(), remaining <= 0 {
throw timeoutError()
}
guard options.reconnect, isTransientEventStreamError(error) else {
throw error
}
waitBeforeReconnectingEventStream()
let remaining = remainingBudget() ?? 1
guard remaining > 0 else {
throw timeoutError()
}
waitBeforeReconnectingEventStream(maximumDelay: remaining)
continue
}
}
@@ -133,15 +193,16 @@ extension CMUXCLI {
|| description.contains("timed out")
}
func waitBeforeReconnectingEventStream() {
let deadline = Date(timeIntervalSinceNow: 1.0)
var didFire = false
let timer = Timer(timeInterval: 1.0, repeats: false) { _ in
didFire = true
}
RunLoop.current.add(timer, forMode: .default)
while !didFire, RunLoop.current.run(mode: .default, before: deadline) {}
timer.invalidate()
func waitBeforeReconnectingEventStream(maximumDelay: TimeInterval = 1) {
let delay = min(1, max(0, maximumDelay))
guard delay > 0 else { return }
// This retry path runs on the CLI's synchronous command thread, which
// pumps no run loop: a Timer + RunLoop.run() wait can spin or park
// with `didFire` as its only exit. A bounded thread sleep is the
// deterministic wait; the caller already clamps the delay to the
// command's remaining --timeout budget, and killing the process (the
// CLI's only cancellation) interrupts it.
Thread.sleep(forTimeInterval: delay)
}
private func parseEventsOptions(_ args: [String]) throws -> EventsCommandOptions {
@@ -178,6 +239,19 @@ extension CMUXCLI {
throw CLIError(message: "--limit must be greater than 0")
}
options.limit = limit
case "--timeout":
let raw = try requireValue()
guard let timeout = TimeInterval(raw),
timeout.isFinite,
timeout > 0 else {
throw CLIError(message: String(
localized: "cli.events.error.invalidTimeout",
defaultValue: "--timeout must be greater than 0"
))
}
options.timeout = timeout
case "--snapshot":
options.snapshotOnly = true
case "--no-ack":
options.printAck = false
case "--no-heartbeat", "--no-heartbeats":
+69 -1
View File
@@ -3,6 +3,14 @@ import Darwin
import Foundation
extension CMUXCLI {
func managedTerminalRequiredMessage(displayName: String) -> String {
let format = String(
localized: "cli.tmux-compat.error.managedTerminalRequired",
defaultValue: "%@ must be launched from a cmux-managed terminal surface. Open a terminal surface in cmux and run this command there."
)
return String(format: format, displayName)
}
func missingProviderExecutableMessage(displayName: String, executableName: String) -> String {
let format = String(
localized: "agentSession.error.missingProviderExecutable",
@@ -77,7 +85,14 @@ extension CMUXCLI {
let candidate = URL(fileURLWithPath: entry, isDirectory: true)
.appendingPathComponent(name, isDirectory: false)
.path
guard FileManager.default.isExecutableFile(atPath: candidate) else { continue }
// `isExecutableFile(atPath:)` is true for directories, so a directory named
// like the provider binary would otherwise shadow the real executable and
// fail at execv (#8743). Reject directories the way the configured-candidate
// path in `resolveClaudeExecutable` already does.
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue,
FileManager.default.isExecutableFile(atPath: candidate) else { continue }
guard !isBundledProviderExecutable(at: candidate) else { continue }
if let skip, skip(candidate) { continue }
return candidate
@@ -148,6 +163,59 @@ extension CMUXCLI {
)
}
/// Whether a Claude-backed launcher will exit after printing help or version
/// information. Reuse the launch parser so flag-shaped prompt text and option values
/// cannot downgrade a real agent session to launcher-only tmux compatibility.
func tmuxCompatIsInformationalInvocation(commandArgs: [String]) -> Bool {
["--help", "-h", "--version", "-v"].contains { option in
AgentLaunchSanitizer.claudeTeamsLaunchHasOption(option, args: commandArgs)
}
}
func claudeTeamsIsNonLaunchInvocation(commandArgs: [String]) -> Bool {
tmuxCompatIsInformationalInvocation(commandArgs: commandArgs)
|| AgentLaunchInvocationClassifier().claudeTeamsLaunchIsManagementCommand(args: commandArgs)
}
/// Whether cmux delegates the complete argument tail to a managed provider.
/// These commands own flags such as `--json` and nested `--help`; cmux must
/// not consume them as presentation options or generic subcommand help.
func managedProviderArgumentsPassThrough(command: String) -> Bool {
switch command {
case "claude-teams", "codex-teams", "omo", "omx", "omc":
return true
default:
return false
}
}
/// Whether cmux should render its own subcommand help before launching a provider.
///
/// Claude and Codex own their help arguments. The legacy OMO/OMX/OMC wrappers
/// retain cmux's root `--help` contract while forwarding nested help unchanged.
func shouldDispatchCmuxSubcommandHelp(command: String, commandArgs: [String]) -> Bool {
switch command {
case "claude-teams", "codex-teams":
return false
case "omo", "omx", "omc":
return commandArgs.count == 1 && ["--help", "-h"].contains(commandArgs[0])
default:
return true
}
}
func codexTeamsIsInformationalInvocation(commandArgs: [String]) -> Bool {
AgentLaunchInvocationClassifier().codexTeamsLaunchIsInformational(args: commandArgs)
}
func omoIsNonLaunchInvocation(commandArgs: [String]) -> Bool {
AgentLaunchInvocationClassifier().omoLaunchIsNonLaunch(args: commandArgs)
}
func omxIsNonLaunchInvocation(commandArgs: [String]) -> Bool {
AgentLaunchInvocationClassifier().omxLaunchIsNonLaunch(args: commandArgs)
}
/// Environment the lead `claude` is launched with. CLAUDE_CODE_SANDBOXED skips
/// Claude Code's interactive "Do you trust this folder?" gate so the unattended
/// lead/teammate panes don't deadlock on it (#6447). That gate is a real safety
+15
View File
@@ -0,0 +1,15 @@
import Foundation
extension CMUXCLI {
func jsonString(_ object: Any) -> String {
var options: JSONSerialization.WritingOptions = [.prettyPrinted]
options.insert(.sortedKeys)
options.insert(.withoutEscapingSlashes)
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: options),
let output = String(data: data, encoding: .utf8) else {
return "{}"
}
return output
}
}
+1 -45
View File
@@ -165,7 +165,7 @@ extension CMUXCLI {
let processCount = padLeft(String(topInt(group["process_count"]) ?? 0), width: 5)
let name = topLabelText(group["name"] as? String)
let command = name.padding(toLength: 26, withPad: " ", startingAt: 0)
let attribution = memoryAttributionText(group["top_attribution"], idFormat: idFormat)
let attribution = memoryGroupAttributionText(group, idFormat: idFormat)
lines.append("\(rss) \(processCount) \(command) \(attribution)")
}
@@ -182,48 +182,4 @@ extension CMUXCLI {
)
}
private func memoryAttributionText(_ raw: Any?, idFormat: CLIIDFormat) -> String {
guard let attribution = raw as? [String: Any] else {
return String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
}
var parts: [String] = []
if let workspace = memoryAttributionHandle(attribution, prefix: "workspace", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.workspaceAttribution", defaultValue: "workspace %@"),
workspace
))
}
if let pane = memoryAttributionHandle(attribution, prefix: "pane", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.paneAttribution", defaultValue: "pane %@"),
pane
))
}
if let surface = memoryAttributionHandle(attribution, prefix: "surface", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.surfaceAttribution", defaultValue: "surface %@"),
surface
))
}
return parts.isEmpty ? String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed") : parts.joined(separator: " / ")
}
private func memoryAttributionHandle(
_ attribution: [String: Any],
prefix: String,
idFormat: CLIIDFormat
) -> String? {
let ref = topLabelText(attribution["\(prefix)_ref"] as? String)
let id = topLabelText(attribution["\(prefix)_id"] as? String)
switch idFormat {
case .refs:
return ref.isEmpty ? (id.isEmpty ? nil : id) : ref
case .uuids:
return id.isEmpty ? (ref.isEmpty ? nil : ref) : id
case .both:
let values = [ref, id].filter { !$0.isEmpty }
return values.isEmpty ? nil : values.joined(separator: " ")
}
}
}
+79
View File
@@ -0,0 +1,79 @@
import Foundation
extension CMUXCLI {
func memoryGroupAttributionText(
_ group: [String: Any],
idFormat: CLIIDFormat
) -> String {
guard let groupAttribution = group["group_attribution"] as? [String: Any],
let kind = groupAttribution["kind"] as? String else {
return memoryAttributionText(group["top_attribution"], idFormat: idFormat)
}
switch kind {
case "common":
return memoryAttributionText(groupAttribution["owner"], idFormat: idFormat)
case "multiple":
let workspaceCount = topInt(groupAttribution["workspace_count"]) ?? 0
if workspaceCount > 1 {
return String.localizedStringWithFormat(
String(localized: "memory.attribution.multipleWorkspaces", defaultValue: "%lld workspaces"),
workspaceCount
)
}
return String(localized: "memory.attribution.multipleOwners", defaultValue: "multiple owners")
case "partial":
return String(localized: "memory.attribution.partial", defaultValue: "partially attributed")
case "unattributed":
return String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
default:
return memoryAttributionText(group["top_attribution"], idFormat: idFormat)
}
}
private func memoryAttributionText(_ raw: Any?, idFormat: CLIIDFormat) -> String {
guard let attribution = raw as? [String: Any] else {
return String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
}
var parts: [String] = []
if let workspace = memoryAttributionHandle(attribution, prefix: "workspace", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.workspaceAttribution", defaultValue: "workspace %@"),
workspace
))
}
if let pane = memoryAttributionHandle(attribution, prefix: "pane", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.paneAttribution", defaultValue: "pane %@"),
pane
))
}
if let surface = memoryAttributionHandle(attribution, prefix: "surface", idFormat: idFormat) {
parts.append(String.localizedStringWithFormat(
String(localized: "cli.memory.output.surfaceAttribution", defaultValue: "surface %@"),
surface
))
}
return parts.isEmpty
? String(localized: "cli.memory.output.unattributed", defaultValue: "unattributed")
: parts.joined(separator: " / ")
}
private func memoryAttributionHandle(
_ attribution: [String: Any],
prefix: String,
idFormat: CLIIDFormat
) -> String? {
let ref = topLabelText(attribution["\(prefix)_ref"] as? String)
let id = topLabelText(attribution["\(prefix)_id"] as? String)
switch idFormat {
case .refs:
return ref.isEmpty ? (id.isEmpty ? nil : id) : ref
case .uuids:
return id.isEmpty ? (ref.isEmpty ? nil : ref) : id
case .both:
let values = [ref, id].filter { !$0.isEmpty }
return values.isEmpty ? nil : values.joined(separator: " ")
}
}
}
+84
View File
@@ -0,0 +1,84 @@
import CmuxFoundation
import Foundation
extension CMUXCLI {
func runMoshTmux(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
try runSSH(
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride,
defaultTerminalTransport: .mosh,
terminalProfile: .defaultTmux
)
}
func buildMoshTerminalStartupCommand(
options: SSHCommandOptions,
remoteBootstrapScript: String?,
localCommandScript: String?,
sshFallbackCommand: String
) -> String {
let invocationOptions = sshCommandOptionsWithoutRemoteCommand(options)
let capabilityProbeSSHArguments = sshArgumentsOverridingHostRemoteCommand(
baseSSHArguments(invocationOptions)
)
let sessionSSHArguments = sshArgumentsOverridingHostRemoteCommand(
baseSSHArguments(invocationOptions)
)
let remoteCommandArguments: [String]
let preparationShellScript: String?
if !options.extraArguments.isEmpty {
remoteCommandArguments = options.extraArguments
preparationShellScript = nil
} else if let remoteBootstrapScript,
!remoteBootstrapScript.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
guard let staging = RemoteBootstrapStagingCommandBuilder(
installerSSHArguments: capabilityProbeSSHArguments,
destination: options.destination,
remoteRelayPort: options.remoteRelayPort,
bootstrapScript: remoteBootstrapScript
) else {
return sshFallbackCommand
}
remoteCommandArguments = staging.remoteExecutionCommandArguments
preparationShellScript = staging.preparationShellScript
} else {
remoteCommandArguments = []
preparationShellScript = nil
}
return MoshTerminalCommandBuilder(
capabilityProbeSSHArguments: capabilityProbeSSHArguments,
sessionSSHArguments: sessionSSHArguments,
destination: options.destination,
remoteCommandArguments: remoteCommandArguments,
remoteRelayPort: options.remoteRelayPort,
preparationShellScript: preparationShellScript,
managementReadyShellScript: localCommandScript,
sshFallbackCommand: sshFallbackCommand,
localMoshMissingMessage: String(
localized: "cli.ssh.mosh.localMissing",
defaultValue: "[cmux] Mosh is not installed locally; continuing over SSH."
),
localMoshUnsupportedMessage: String(
localized: "cli.ssh.mosh.localUnsupported",
defaultValue: "[cmux] The local Mosh client lacks required SSH integration; continuing over SSH."
),
remoteMoshMissingMessage: String(
localized: "cli.ssh.mosh.remoteMissing",
defaultValue: "[cmux] mosh-server is not installed on the remote host; continuing over SSH."
),
remoteMoshProbeFailedMessage: String(
localized: "cli.ssh.mosh.probeFailed",
defaultValue: "[cmux] Could not verify remote Mosh support; continuing over SSH."
)
).command()
}
}
+153 -12
View File
@@ -74,6 +74,7 @@ function base64NulSeparated(values: string[]): string {
function hookEnvironment(cwd: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = { ...process.env };
env.CMUX_OMP_PID = String(process.pid);
if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) {
const argv = normalizedLaunchArgv();
env.CMUX_AGENT_LAUNCH_KIND = "omp";
@@ -87,6 +88,7 @@ function hookEnvironment(cwd: string): NodeJS.ProcessEnv {
interface HookInvocation {
cmux: string;
cwd: string;
sessionId: string;
payload: string;
env: NodeJS.ProcessEnv;
}
@@ -128,9 +130,20 @@ function lastAssistantMessage(event: AgentEndEvent): string | undefined {
return undefined;
}
function boundedHookText(value: string | undefined): string | undefined {
if (value === undefined || value.length <= 32768) return value;
return value.slice(0, 32768);
}
function isNestedArtifactSession(ctx: ExtensionContext): boolean {
const sessionFile = firstString(ctx.sessionManager.getSessionFile());
return sessionFile !== null && fs.existsSync(`${path.dirname(sessionFile)}.jsonl`);
}
function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record<string, unknown> = {}): HookInvocation | null {
if (process.env.CMUX_OMP_HOOKS_DISABLED === "1") return null;
if (!process.env.CMUX_SURFACE_ID) return null;
if (isNestedArtifactSession(ctx)) return null;
const sessionId = firstString(ctx.sessionManager.getSessionId());
if (!sessionId) return null;
@@ -147,36 +160,160 @@ function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record
return {
cmux,
cwd,
sessionId,
payload: JSON.stringify(payload),
env: hookEnvironment(cwd),
};
}
async function sendHook(subcommand: string, ctx: ExtensionContext, extra: Record<string, unknown> = {}): Promise<void> {
const invocation = hookInvocation(subcommand, ctx, extra);
if (!invocation) return;
await new Promise<void>((resolve) => {
interface RunningHook {
completion: Promise<void>;
cancel: () => void;
}
function startHook(invocation: HookInvocation, subcommand: string): RunningHook {
let child: ReturnType<typeof spawn> | null = null;
let settle = () => {};
const terminate = () => {
if (child && !child.killed) child.kill("SIGKILL");
};
const completion = new Promise<void>((resolve) => {
let settled = false;
const settle = () => {
const timeout = setTimeout(() => {
terminate();
}, 5000);
timeout.unref();
settle = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve();
};
try {
const child = spawn(invocation.cmux, ["hooks", "omp", subcommand], {
child = spawn(invocation.cmux, ["hooks", "omp", subcommand], {
env: invocation.env,
stdio: ["pipe", "ignore", "ignore"],
detached: true,
});
child.on("error", settle);
child.stdin.on("error", settle);
child.stdin.on("finish", settle);
child.unref();
child.on("close", settle);
child.stdin.on("error", () => {});
child.stdin.end(invocation.payload);
} catch (_) {
settle();
}
});
return {
completion,
cancel: () => {
terminate();
},
};
}
interface QueuedHook {
invocation: HookInvocation;
subcommand: string;
}
function hookPriority(subcommand: string): number {
switch (subcommand) {
case "stop":
return 2;
case "session-start":
return 1;
default:
return 0;
}
}
const maxQueuedHooks = 16;
const hookShutdownDeadlineMs = 2000;
const hookQueue: QueuedHook[] = [];
let hookWorker: Promise<void> | null = null;
let activeHook: RunningHook | null = null;
let activeHookSubcommand: string | null = null;
async function drainHookQueue(): Promise<void> {
while (hookQueue.length > 0) {
const next = hookQueue.shift();
if (!next) continue;
const running = startHook(next.invocation, next.subcommand);
activeHook = running;
activeHookSubcommand = next.subcommand;
await running.completion;
if (activeHook === running) {
activeHook = null;
activeHookSubcommand = null;
}
}
}
function startHookWorker(): void {
if (hookWorker) return;
hookWorker = drainHookQueue().finally(() => {
hookWorker = null;
if (hookQueue.length > 0) startHookWorker();
});
}
async function waitForHookWorker(worker: Promise<void>, timeoutMs: number): Promise<boolean> {
let completed = false;
let timeout: ReturnType<typeof setTimeout> | null = null;
await Promise.race([
worker.then(() => {
completed = true;
}),
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, timeoutMs);
}),
]);
if (timeout) clearTimeout(timeout);
return completed;
}
async function awaitHookQueueDrain(): Promise<void> {
for (let index = hookQueue.length - 1; index >= 0; index -= 1) {
if (hookQueue[index]?.subcommand === "prompt-submit") hookQueue.splice(index, 1);
}
const worker = hookWorker;
if (!worker) return;
if (await waitForHookWorker(worker, hookShutdownDeadlineMs)) return;
for (let index = hookQueue.length - 1; index >= 0; index -= 1) {
if (hookQueue[index]?.subcommand !== "stop") hookQueue.splice(index, 1);
}
if (activeHookSubcommand !== "stop") activeHook?.cancel();
if (await waitForHookWorker(worker, hookShutdownDeadlineMs)) return;
hookQueue.splice(0);
activeHook?.cancel();
await worker;
}
function enqueueHook(invocation: HookInvocation, subcommand: string): void {
const duplicate = hookQueue.findIndex(
(queued) => queued.invocation.sessionId === invocation.sessionId && queued.subcommand === subcommand
);
if (duplicate >= 0) {
hookQueue.splice(duplicate, 1);
hookQueue.push({ invocation, subcommand });
} else {
if (hookQueue.length >= maxQueuedHooks) {
const priority = hookPriority(subcommand);
const evictable = hookQueue.findIndex((queued) => hookPriority(queued.subcommand) < priority);
if (evictable >= 0) hookQueue.splice(evictable, 1);
else return;
}
hookQueue.push({ invocation, subcommand });
}
startHookWorker();
}
function sendHook(subcommand: string, ctx: ExtensionContext, extra: Record<string, unknown> = {}): Promise<void> {
const invocation = hookInvocation(subcommand, ctx, extra);
if (!invocation) return Promise.resolve();
enqueueHook(invocation, subcommand);
return Promise.resolve();
}
export default function cmuxOmpSessionExtension(api: ExtensionAPI) {
@@ -185,11 +322,15 @@ export default function cmuxOmpSessionExtension(api: ExtensionAPI) {
});
api.on("before_agent_start", async (event, ctx) => {
await sendHook("prompt-submit", ctx, { prompt: event.prompt });
await sendHook("prompt-submit", ctx, { prompt: boundedHookText(event.prompt) });
});
api.on("agent_end", async (event, ctx) => {
await sendHook("stop", ctx, { last_assistant_message: lastAssistantMessage(event) });
await sendHook("stop", ctx, { last_assistant_message: boundedHookText(lastAssistantMessage(event)) });
});
api.on("session_shutdown", async () => {
await awaitHookQueueDrain();
});
}
"""#
+426
View File
@@ -0,0 +1,426 @@
import Foundation
extension CMUXCLI {
/// Reduces any Pi tool result to structural metadata at the CLI trust boundary.
///
/// Generated extensions project results before dispatch, but installed older
/// versions can still send raw output. Treat both forms as untrusted so a cmux
/// upgrade cannot persist command output before the extension is reinstalled.
static func sanitizedPiPostToolUseFeedValue(_ value: Any) -> [String: Any] {
var summary: [String: Any] = ["_cmux_sanitized": true]
if value is NSNull {
summary["kind"] = "null"
return summary
}
if value is Bool {
summary["kind"] = "boolean"
return summary
}
if value is NSNumber {
summary["kind"] = "number"
return summary
}
if let text = value as? String {
summary["kind"] = "text"
summary["length"] = text.count
return summary
}
if let array = value as? [Any] {
summary["kind"] = "array"
summary["count"] = array.count
return summary
}
guard let dictionary = value as? [String: Any] else {
summary["kind"] = "unknown"
return summary
}
summary["_cmux_original_key_count"] = dictionary.count
let allowedKinds = Set(["null", "text", "boolean", "number", "array", "object", "undefined"])
var retainedKeyCount = 0
if let kind = dictionary["kind"] as? String, allowedKinds.contains(kind) {
summary["kind"] = kind
retainedKeyCount += 1
} else {
summary["kind"] = "object"
}
for key in ["length", "count", "key_count", "omitted_terminal_count"] {
if let count = dictionary[key] as? Int, count >= 0 {
summary[key] = count
retainedKeyCount += 1
}
}
for key in ["truncated", "cmux_truncated"] {
if let flag = dictionary[key] as? Bool {
summary[key] = flag
retainedKeyCount += 1
}
}
if summary["kind"] as? String == "object", summary["key_count"] == nil {
summary["key_count"] = dictionary.count
}
let omittedKeyCount = dictionary.count - retainedKeyCount
if omittedKeyCount > 0 {
summary["_cmux_omitted_key_count"] = omittedKeyCount
}
return summary
}
/// Routes a bounded Pi terminal-event batch through the ordinary Feed protocol.
func routePiCompactedFeedEvents(
commandArgs: [String],
rawObject: [String: Any],
agentPid: Int,
fallbackWorkspaceId: String?,
client: SocketClient?,
socketPath: String?,
socketPassword: String?
) throws -> String? {
guard let rawCompactedEvents = rawObject["cmux_compacted_terminal_events"] else {
return nil
}
guard rawCompactedEvents is [[String: Any]] else {
throw piFeedAcknowledgmentError()
}
if let client {
return try sendPiCompactedFeedEvents(
commandArgs: commandArgs,
rawObject: rawObject,
agentPid: agentPid,
fallbackWorkspaceId: fallbackWorkspaceId,
client: client
)
} else if let socketPath {
let batchClient = SocketClient(path: socketPath)
defer { batchClient.close() }
try batchClient.connect()
try authenticateClientIfNeeded(
batchClient,
explicitPassword: socketPassword,
socketPath: socketPath,
responseTimeout: 1
)
return try sendPiCompactedFeedEvents(
commandArgs: commandArgs,
rawObject: rawObject,
agentPid: agentPid,
fallbackWorkspaceId: fallbackWorkspaceId,
client: batchClient
)
}
return nil
}
private func sendPiCompactedFeedEvents(
commandArgs: [String],
rawObject: [String: Any],
agentPid: Int,
fallbackWorkspaceId: String?,
client: SocketClient
) throws -> String {
let target = try resolvePiFeedClaim(commandArgs: commandArgs, client: client)
let request = PiCompactedFeedEventExpander(
agentPid: agentPid,
workspaceId: target?.workspaceId ?? fallbackWorkspaceId,
surfaceId: target?.surfaceId,
maximumRequestCount: client.isRelayBacked ? 2 : nil
).acknowledgedBatchRequest(from: rawObject)
guard let request else { throw piFeedAcknowledgmentError() }
let response = try client.send(
command: request.line,
responseTimeout: 4
)
let acknowledgedTarget = try validatePiFeedAcknowledgment(
response,
expectedItemCount: request.eventCount
)
return piHookResolvedTargetOutput(acknowledgedTarget)
}
/// Preserves exact Pi Feed claims for authoritative acceptance by the app.
///
/// UUID claims need no preliminary socket request. Legacy numeric and handle
/// references still resolve here because the Feed protocol carries UUIDs.
func resolvePiFeedClaim(
commandArgs: [String],
client: SocketClient
) throws -> (workspaceId: String?, surfaceId: String)? {
let arguments = piHookTargetArguments(commandArgs)
guard let rawSurface = arguments.surface else {
return try resolveStrictPiHookTarget(commandArgs: commandArgs, client: client).map {
($0.workspaceId, $0.surfaceId)
}
}
let surface = rawSurface.trimmingCharacters(in: .whitespacesAndNewlines)
guard !surface.isEmpty else {
throw piHookSurfaceNotFoundError(rawSurface)
}
let workspace = normalizedHandleValue(arguments.workspace)
if arguments.explicitWorkspace != nil, workspace == nil {
throw piHookSurfaceNotFoundError(arguments.explicitWorkspace ?? "")
}
if isUUID(surface), workspace == nil || workspace.map(isUUID) == true {
return (workspace, surface)
}
return try resolveStrictPiHookTarget(commandArgs: commandArgs, client: client).map {
($0.workspaceId, $0.surfaceId)
}
}
/// Resolves a Pi extension target without crossing an explicitly selected workspace boundary.
func resolveStrictPiHookTarget(
commandArgs: [String],
client: SocketClient
) throws -> (workspaceId: String, surfaceId: String)? {
let arguments = piHookTargetArguments(commandArgs)
if arguments.surface == nil, arguments.explicitWorkspace == nil {
return nil
}
let surface = arguments.surface?.trimmingCharacters(in: .whitespacesAndNewlines)
if let surface {
guard !surface.isEmpty,
isUUID(surface)
|| Int(surface) != nil
|| piHookHandleRef(surface, kind: "surface")
else {
throw piHookSurfaceNotFoundError(arguments.surface ?? "")
}
}
let trimmedWorkspace = normalizedHandleValue(arguments.workspace)
if arguments.explicitWorkspace != nil, trimmedWorkspace == nil {
throw piHookSurfaceNotFoundError(arguments.explicitWorkspace ?? "")
}
if let workspace = trimmedWorkspace {
guard isUUID(workspace)
|| Int(workspace) != nil
|| piHookHandleRef(workspace, kind: "workspace")
else {
throw piHookSurfaceNotFoundError(workspace)
}
}
let resolvedWorkspaceId: String?
if let workspace = trimmedWorkspace, isUUID(workspace) {
// Exact workspace IDs are only preferred hints for exact surfaces;
// the global live-surface resolver below remains authoritative.
resolvedWorkspaceId = workspace
} else {
do {
resolvedWorkspaceId = try resolveWorkspaceId(trimmedWorkspace, client: client)
} catch let error as CLIError where error.v2Code == "not_found" {
if trimmedWorkspace != nil {
// A supplied index/ref failed to resolve and cannot be
// discarded without violating the caller's explicit scope.
throw CLIError(
message: error.message,
exitCode: Self.piHookSurfaceUnavailableExitCode,
v2Code: error.v2Code
)
}
resolvedWorkspaceId = nil
}
}
guard let surface else {
guard let resolvedWorkspaceId else {
throw piHookSurfaceNotFoundError(arguments.explicitWorkspace ?? "")
}
do {
let listed = try client.sendV2(
method: "surface.list",
params: ["workspace_id": resolvedWorkspaceId]
)
let surfaces = listed["surfaces"] as? [[String: Any]] ?? []
guard let surfaceId = surfaces.first(where: {
($0["focused"] as? Bool) == true
})?["id"] as? String else {
throw piHookSurfaceNotFoundError(arguments.explicitWorkspace ?? "")
}
return (resolvedWorkspaceId, surfaceId)
} catch let error as CLIError {
throw CLIError(
message: error.message,
exitCode: Self.piHookSurfaceUnavailableExitCode,
v2Code: error.v2Code ?? "not_found"
)
}
}
if isUUID(surface) {
var params: [String: Any] = ["surface_id": surface]
if let resolvedWorkspaceId, isUUID(resolvedWorkspaceId) {
params["workspace_id"] = resolvedWorkspaceId
}
do {
let payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: params,
responseTimeout: 2
)
if (payload["source"] as? String) == "surface",
let workspaceId = normalizedHandleValue(payload["workspace_id"] as? String),
let workspaceUUID = UUID(uuidString: workspaceId),
let returnedSurfaceId = normalizedHandleValue(payload["surface_id"] as? String),
let returnedSurfaceUUID = UUID(uuidString: returnedSurfaceId) {
// The relay can rewrite a restored surface alias before the
// app resolves it, so the app's returned UUID is authoritative.
return (workspaceUUID.uuidString, returnedSurfaceUUID.uuidString)
}
throw piHookSurfaceNotFoundError(surface)
} catch let error as CLIError where error.v2Code == "method_not_found"
|| error.v2Code == "unrecognized_method" {
// Older apps lack the surface-scoped resolver. Preserve the
// legacy workspace-local lookup without making supported
// apps snapshot every surface for each Pi tool event.
} catch let error as CLIError where error.v2Code == "not_found" {
throw piHookSurfaceNotFoundError(surface)
}
}
if let workspaceId = resolvedWorkspaceId {
let listed = try client.sendV2(method: "surface.list", params: ["workspace_id": workspaceId])
let surfaces = listed["surfaces"] as? [[String: Any]] ?? []
let surfaceId: String? = if isUUID(surface) {
surfaces.first(where: { ($0["id"] as? String) == surface })?["id"] as? String
} else if let index = Int(surface) {
surfaces.first(where: { piHookInteger($0["index"]) == index })?["id"] as? String
} else {
surfaces.first(where: { ($0["ref"] as? String) == surface })?["id"] as? String
}
if let surfaceId {
return (workspaceId, surfaceId)
}
}
throw piHookSurfaceNotFoundError(surface)
}
private func piHookTargetArguments(
_ commandArgs: [String]
) -> (explicitWorkspace: String?, workspace: String?, surface: String?) {
let explicitWorkspace = optionValue(commandArgs, name: "--workspace")
let explicitSurface = optionValue(commandArgs, name: "--surface")
let environment = ProcessInfo.processInfo.environment
return (
explicitWorkspace,
explicitWorkspace ?? environment["CMUX_WORKSPACE_ID"],
explicitSurface ?? (explicitWorkspace == nil ? environment["CMUX_SURFACE_ID"] : nil)
)
}
private func piHookHandleRef(_ raw: String, kind: String) -> Bool {
let pieces = raw.split(separator: ":", omittingEmptySubsequences: false)
return pieces.count == 2
&& pieces[0].lowercased() == kind
&& Int(pieces[1]) != nil
}
private func piHookInteger(_ value: Any?) -> Int? {
if let value = value as? Int { return value }
if let value = value as? NSNumber { return value.intValue }
if let value = value as? String { return Int(value) }
return nil
}
/// Builds the localized failure shared by strict Pi lifecycle and feed routing.
func piHookSurfaceNotFoundError(_ rawSurface: String) -> CLIError {
CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.claude-hook.error.surfaceNotFound",
defaultValue: "Surface not found: %@"
),
rawSurface
), exitCode: Self.piHookSurfaceUnavailableExitCode, v2Code: "not_found")
}
/// Stable process status consumed by the generated extension without parsing localized stderr.
static let piHookSurfaceUnavailableExitCode: Int32 = 69
func piHookResolvedTargetOutput(
_ target: (workspaceId: String, surfaceId: String)?
) -> String {
guard let target,
let data = try? JSONSerialization.data(withJSONObject: [
"workspace_id": target.workspaceId,
"surface_id": target.surfaceId,
]),
let output = String(data: data, encoding: .utf8)
else { return "{}" }
return output
}
/// Rejects a Pi feed response unless the server confirms ingestion.
func validatePiFeedAcknowledgment(
_ response: String,
expectedItemCount: Int? = nil
) throws -> (workspaceId: String, surfaceId: String)? {
let decodedResponse: Any
do {
decodedResponse = try JSONSerialization.jsonObject(with: Data(response.utf8))
} catch {
throw piFeedAcknowledgmentError()
}
guard let responseObject = decodedResponse as? [String: Any] else {
throw piFeedAcknowledgmentError()
}
if responseObject["ok"] as? Bool == false,
let error = responseObject["error"] as? [String: Any],
error["code"] as? String == "not_found" {
throw CLIError(
message: String(
localized: "agent.deliveryTarget.error.notFound",
defaultValue: "No live delivery target"
),
exitCode: Self.piHookSurfaceUnavailableExitCode,
v2Code: "not_found"
)
}
guard responseObject["ok"] as? Bool == true,
let result = responseObject["result"] as? [String: Any],
result["status"] as? String == "acknowledged"
else {
throw piFeedAcknowledgmentError()
}
if let expectedItemCount {
if expectedItemCount == 1,
let itemId = result["item_id"] as? String,
UUID(uuidString: itemId) != nil {
return piFeedAcknowledgedTarget(result)
}
guard expectedItemCount > 0,
let itemIds = result["item_ids"] as? [String],
itemIds.count == expectedItemCount,
itemIds.allSatisfy({ UUID(uuidString: $0) != nil })
else {
throw piFeedAcknowledgmentError()
}
} else {
guard let itemId = result["item_id"] as? String,
UUID(uuidString: itemId) != nil
else {
throw piFeedAcknowledgmentError()
}
}
return piFeedAcknowledgedTarget(result)
}
private func piFeedAcknowledgedTarget(
_ result: [String: Any]
) -> (workspaceId: String, surfaceId: String)? {
guard let workspaceId = normalizedHandleValue(result["workspace_id"] as? String),
isUUID(workspaceId),
let surfaceId = normalizedHandleValue(result["surface_id"] as? String),
isUUID(surfaceId) else {
return nil
}
return (workspaceId, surfaceId)
}
private func piFeedAcknowledgmentError() -> CLIError {
CLIError(message: String(
localized: "cli.hooks.pi.error.feedIngestionNotAcknowledged",
defaultValue: "cmux did not receive acknowledgment for Pi feed ingestion"
))
}
}
+130 -8
View File
@@ -1,4 +1,5 @@
import Foundation
import Darwin
extension CMUXCLI {
private static let piExtensionMarker = "cmux-pi-session-extension-marker"
@@ -26,6 +27,89 @@ extension CMUXCLI {
}
}
@discardableResult
private func withPiExtensionMutationLock<T>(
at extensionURL: URL,
createParentDirectory: Bool,
acquireNonBlocking: Bool = false,
fileManager: FileManager = .default,
_ operation: () throws -> T
) throws -> T? {
let directoryURL = extensionURL.deletingLastPathComponent()
if createParentDirectory {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
}
let lockURL = directoryURL.appendingPathComponent(".cmux-session.lock", isDirectory: false)
let descriptor = Darwin.open(
lockURL.path,
O_CREAT | O_RDWR | O_CLOEXEC | O_NOFOLLOW,
mode_t(S_IRUSR | S_IWUSR)
)
guard descriptor >= 0 else {
throw piExtensionReadError(at: extensionURL)
}
defer { Darwin.close(descriptor) }
var metadata = stat()
guard Darwin.fstat(descriptor, &metadata) == 0,
metadata.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else {
throw piExtensionReadError(at: extensionURL)
}
let lockOperation = LOCK_EX | (acquireNonBlocking ? LOCK_NB : 0)
guard flock(descriptor, lockOperation) == 0 else {
if acquireNonBlocking, errno == EWOULDBLOCK || errno == EAGAIN {
return nil
}
throw piExtensionReadError(at: extensionURL)
}
defer { flock(descriptor, LOCK_UN) }
return try operation()
}
private func piExtensionReadError(at url: URL) -> CLIError {
CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.error.readFailed",
defaultValue: "Failed to read %@"
),
url.path
))
}
func refreshManagedPiExtensionIfNeeded(_ def: AgentHookDef) {
let extensionURL = piExtensionURL(for: def)
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: extensionURL.path) else { return }
do {
try withPiExtensionMutationLock(
at: extensionURL,
createParentDirectory: false,
acquireNonBlocking: true,
fileManager: fileManager
) {
guard fileManager.fileExists(atPath: extensionURL.path) else { return }
let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fileManager)
if existing.isEmpty {
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
return
}
guard existing.contains(Self.piExtensionMarker),
existing != Self.piExtensionSource
else {
return
}
// Revalidate immediately before replacement. All cmux install, refresh,
// and uninstall mutations share this lock, so an in-flight refresh
// cannot recreate an extension that another cmux process removed.
guard try existingPiExtensionContents(at: extensionURL, fileManager: fileManager) == existing else {
return
}
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
}
} catch {
// Hook delivery must continue when a managed extension cannot be refreshed.
}
}
func installPiExtensionHooks(_ def: AgentHookDef) throws {
let extensionURL = piExtensionURL(for: def)
let fileManager = FileManager.default
@@ -64,11 +148,25 @@ extension CMUXCLI {
return
}
}
try fileManager.createDirectory(
at: extensionURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
try withPiExtensionMutationLock(
at: extensionURL,
createParentDirectory: true,
fileManager: fileManager
) {
let current = try existingPiExtensionContents(at: extensionURL, fileManager: fileManager)
if !current.isEmpty, !current.contains(Self.piExtensionMarker) {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.error.notCmuxExtension",
defaultValue: "%@ exists and is not a cmux extension; leaving it alone"
),
extensionURL.path
))
}
if current != Self.piExtensionSource {
try Self.piExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8)
}
}
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.installed",
@@ -91,8 +189,23 @@ extension CMUXCLI {
))
return
}
let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fm)
guard existing.contains(Self.piExtensionMarker) else {
var removed = false
var refused = false
try withPiExtensionMutationLock(
at: extensionURL,
createParentDirectory: false,
fileManager: fm
) {
let existing = try existingPiExtensionContents(at: extensionURL, fileManager: fm)
guard !existing.isEmpty else { return }
guard existing.contains(Self.piExtensionMarker) else {
refused = true
return
}
try fm.removeItem(at: extensionURL)
removed = true
}
if refused {
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.refuseRemoveMissingMarker",
@@ -102,7 +215,16 @@ extension CMUXCLI {
))
return
}
try fm.removeItem(at: extensionURL)
guard removed else {
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.noneFound",
defaultValue: "No Pi cmux extension found at %@"
),
extensionURL.path
))
return
}
print(String.localizedStringWithFormat(
String(
localized: "cli.hooks.pi.removed",
+5 -1
View File
@@ -1,3 +1,7 @@
extension CMUXCLI {
static let piExtensionSource = piExtensionSourcePart1 + "\n" + piExtensionSourcePart2
static let piExtensionSource = [
piExtensionSourcePart1,
piExtensionSourceDispatch,
piExtensionSourcePart2,
].joined(separator: "\n")
}
+507
View File
@@ -0,0 +1,507 @@
extension CMUXCLI {
static let piExtensionSourceDispatch = #"""
function piFeedValueSummary(value: unknown): Record<string, unknown> {
if (value === null) return { kind: "null" };
if (typeof value === "string") return { kind: "text", length: value.length };
if (typeof value === "boolean" || typeof value === "number") return { kind: typeof value };
if (Array.isArray(value)) return { kind: "array" };
return { kind: typeof value };
}
function piTerminalFeedSummary(payload: Record<string, unknown>): Record<string, unknown> {
const summary: Record<string, unknown> = {};
for (const key of ["session_id", "turn_id", "tool_call_id", "tool_name", "cwd"] as const) {
const value = payload[key];
if (typeof value === "string") summary[key] = value.slice(0, 2048);
}
if (typeof payload.is_error === "boolean") summary.is_error = payload.is_error;
if (payload.tool_result !== undefined) summary.tool_result = piFeedValueSummary(payload.tool_result);
return summary;
}
class PiCmuxCommandDispatcher {
private static readonly surfaceUnavailableExitCode = 69;
private static readonly maxPendingFeedCommands = 8;
private static readonly maxQueuedFeedCommands = 32;
private static readonly maxActiveFeedCommands = 2;
private static readonly maxCompactedTerminalSummaries = 64;
// Leave headroom for the feed.push envelope under the relay's 16 KiB frame limit.
private static readonly maxFeedInputBytes = 12 * 1024;
// The app may spend three seconds committing acknowledged Feed ingress and the
// CLI owns a four-second end-to-end deadline. Observe that outcome before the
// extension classifies a terminal delivery as failed.
private static readonly feedDrainDeadlineMs = 4500;
private controlQueues = new Map<string | null, Promise<void>>();
private pendingFeedCommands = new Map<string, PiFeedCommand>();
private pendingFeedKeysBySession = new Map<string | null, string[]>();
private priorityFeedCommands = new Map<string | null, PiFeedCommand[]>();
private feedDrainWaiters = new Map<string, Array<() => void>>();
private feedSessionQueue: Array<string | null> = [];
private scheduledFeedSessions = new Set<string | null>();
private unavailableSessions = new Set<string>();
private activeFeeds = new Map<string | null, {
cancellation: PiCommandCancellation;
command: PiFeedCommand;
}>();
canDispatch(sessionId: string | null): boolean {
return !sessionId || !this.unavailableSessions.has(sessionId);
}
releaseSession(sessionId: string): void {
this.unavailableSessions.delete(sessionId);
}
run(
args: string[],
cwd: string,
input: string | undefined,
context: PiExtensionContextSnapshot,
): Promise<CommandResult> {
const sessionId = context.sessionId;
const previous = this.controlQueues.get(sessionId) || Promise.resolve();
const scheduled = previous.then(() => this.execute(args, cwd, input, context));
let tail: Promise<void>;
tail = scheduled
.then(() => undefined, () => undefined)
.finally(() => {
if (this.controlQueues.get(sessionId) === tail) this.controlQueues.delete(sessionId);
});
this.controlQueues.set(sessionId, tail);
return scheduled;
}
enqueueFeed(key: string, command: PiFeedCommand): void {
const sessionId = command.context.sessionId;
if (!this.canDispatch(sessionId)) {
if (command.terminal) command.onFailure?.();
return;
}
const existing = this.pendingFeedCommands.get(key);
if (existing) {
// Once a completion is pending for a tool, never replace it with a late start event.
if (existing.terminal && !command.terminal) return;
} else {
if (this.queuedFeedCount(sessionId) >= PiCmuxCommandDispatcher.maxPendingFeedCommands) {
if (!command.terminal) return;
if (!this.evictPendingStartForCompletion(sessionId)) {
if (!this.compactPendingCompletion(command)) command.onFailure?.();
return;
}
}
if (this.totalQueuedFeedCount() >= PiCmuxCommandDispatcher.maxQueuedFeedCommands) {
if (!command.terminal) return;
if (!this.evictAnyPendingStart()) {
if (!this.compactPendingCompletion(command)) command.onFailure?.();
return;
}
}
}
// Reinsert coalesced entries so per-session order reflects event arrival.
this.removePendingFeed(key);
this.appendPendingFeed(key, command);
this.scheduleFeed(sessionId);
}
async finishFeedForSession(sessionId: string): Promise<void> {
for (const key of [...(this.pendingFeedKeysBySession.get(sessionId) || [])]) {
const command = this.removePendingFeed(key);
if (command?.terminal) this.appendPriorityFeed(command);
}
const active = this.activeFeeds.get(sessionId);
if (active && !active.command.terminal) {
active.cancellation.cancelled = true;
active.cancellation.cancel?.();
}
this.scheduleFeed(sessionId);
await this.waitForFeedDrainUntilDeadline(sessionId);
}
private queuedFeedCount(sessionId: string | null): number {
return (this.pendingFeedKeysBySession.get(sessionId)?.length || 0)
+ (this.priorityFeedCommands.get(sessionId)?.length || 0);
}
private totalQueuedFeedCount(): number {
let count = this.pendingFeedCommands.size;
for (const commands of this.priorityFeedCommands.values()) count += commands.length;
return count;
}
private appendPendingFeed(key: string, command: PiFeedCommand): void {
const sessionId = command.context.sessionId;
const keys = this.pendingFeedKeysBySession.get(sessionId) || [];
keys.push(key);
this.pendingFeedKeysBySession.set(sessionId, keys);
this.pendingFeedCommands.set(key, command);
}
private removePendingFeed(key: string): PiFeedCommand | undefined {
const command = this.pendingFeedCommands.get(key);
if (!command) return undefined;
this.pendingFeedCommands.delete(key);
const sessionId = command.context.sessionId;
const keys = this.pendingFeedKeysBySession.get(sessionId) || [];
const index = keys.indexOf(key);
if (index >= 0) keys.splice(index, 1);
if (keys.length) this.pendingFeedKeysBySession.set(sessionId, keys);
else this.pendingFeedKeysBySession.delete(sessionId);
return command;
}
private appendPriorityFeed(command: PiFeedCommand): void {
const sessionId = command.context.sessionId;
const commands = this.priorityFeedCommands.get(sessionId) || [];
commands.push(command);
this.priorityFeedCommands.set(sessionId, commands);
}
private takeNextFeed(sessionId: string | null): PiFeedCommand | undefined {
const priority = this.priorityFeedCommands.get(sessionId);
const command = priority?.shift();
if (priority && !priority.length) this.priorityFeedCommands.delete(sessionId);
if (command) return command;
const key = this.pendingFeedKeysBySession.get(sessionId)?.[0];
return key === undefined ? undefined : this.removePendingFeed(key);
}
private waitForFeedDrain(sessionId: string): Promise<void> {
if (!this.hasFeedWork(sessionId)) return Promise.resolve();
return new Promise<void>((resolve) => {
const waiters = this.feedDrainWaiters.get(sessionId) || [];
waiters.push(resolve);
this.feedDrainWaiters.set(sessionId, waiters);
});
}
private waitForFeedDrainUntilDeadline(sessionId: string): Promise<void> {
if (!this.hasFeedWork(sessionId)) return Promise.resolve();
const drained = this.waitForFeedDrain(sessionId);
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(deadline);
resolve();
};
const deadline = setTimeout(() => {
this.failTerminalFeedForSession(sessionId);
this.discardFeedForSession(sessionId);
finish();
}, PiCmuxCommandDispatcher.feedDrainDeadlineMs);
void drained.then(finish);
});
}
private hasFeedWork(sessionId: string): boolean {
return this.activeFeeds.has(sessionId) || this.queuedFeedCount(sessionId) > 0;
}
private resolveDrainedFeedSession(sessionId: string): void {
if (this.hasFeedWork(sessionId)) return;
const waiters = this.feedDrainWaiters.get(sessionId) || [];
this.feedDrainWaiters.delete(sessionId);
for (const resolve of waiters) resolve();
}
private evictPendingStartForCompletion(sessionId: string | null): boolean {
for (const key of this.pendingFeedKeysBySession.get(sessionId) || []) {
if (!this.pendingFeedCommands.get(key)?.terminal) {
this.removePendingFeed(key);
return true;
}
}
return false;
}
private failTerminalFeedForSession(sessionId: string): void {
const active = this.activeFeeds.get(sessionId)?.command;
if (active?.terminal) active.onFailure?.();
for (const command of this.priorityFeedCommands.get(sessionId) || []) {
if (command.terminal) command.onFailure?.();
}
for (const key of this.pendingFeedKeysBySession.get(sessionId) || []) {
const command = this.pendingFeedCommands.get(key);
if (command?.terminal) command.onFailure?.();
}
}
private evictAnyPendingStart(): boolean {
for (const [key, command] of this.pendingFeedCommands) {
if (!command.terminal) {
this.removePendingFeed(key);
return true;
}
}
return false;
}
private compactPendingCompletion(command: PiFeedCommand): boolean {
const sessionId = command.context.sessionId;
const keys = this.pendingFeedKeysBySession.get(sessionId) || [];
for (let index = keys.length - 1; index >= 0; index -= 1) {
const key = keys[index];
const pending = this.pendingFeedCommands.get(key);
if (!pending) continue;
if (!pending.terminal) continue;
this.pendingFeedCommands.set(key, this.compactedTerminalCommand(pending, command));
return true;
}
const priority = this.priorityFeedCommands.get(sessionId) || [];
for (let index = priority.length - 1; index >= 0; index -= 1) {
const pending = priority[index];
if (!pending.terminal) continue;
priority[index] = this.compactedTerminalCommand(pending, command);
return true;
}
return false;
}
private compactedTerminalCommand(existing: PiFeedCommand, incoming: PiFeedCommand): PiFeedCommand {
const existingPayload = { ...existing.payload };
const incomingPayload = incoming.payload;
const existingSummaries = Array.isArray(existingPayload.cmux_compacted_terminal_events)
? existingPayload.cmux_compacted_terminal_events
: [piTerminalFeedSummary(existingPayload)];
const incomingSummaries = Array.isArray(incomingPayload.cmux_compacted_terminal_events)
? incomingPayload.cmux_compacted_terminal_events
: [piTerminalFeedSummary(incomingPayload)];
const existingCount = this.compactedTerminalCount(existingPayload, existingSummaries.length);
const incomingCount = this.compactedTerminalCount(incomingPayload, incomingSummaries.length);
const combined = [...existingSummaries, ...incomingSummaries];
const summaryLimit = PiCmuxCommandDispatcher.maxCompactedTerminalSummaries;
const summaries = combined.length <= summaryLimit
? combined
: [...combined.slice(0, summaryLimit / 2), ...combined.slice(-summaryLimit / 2)];
const totalCount = existingCount + incomingCount;
delete existingPayload.tool_input;
delete existingPayload.tool_result;
existingPayload.cmux_compacted_terminal_count = totalCount;
existingPayload.cmux_compacted_terminal_omitted_count = Math.max(0, totalCount - summaries.length);
existingPayload.cmux_compacted_terminal_events = summaries;
return { ...existing, payload: existingPayload };
}
private compactedTerminalCount(payload: Record<string, unknown>, fallback: number): number {
const count = payload.cmux_compacted_terminal_count;
return typeof count === "number" && Number.isFinite(count) && count >= fallback ? count : fallback;
}
private discardFeedForSession(sessionId: string): void {
for (const key of this.pendingFeedKeysBySession.get(sessionId) || []) {
this.pendingFeedCommands.delete(key);
}
this.pendingFeedKeysBySession.delete(sessionId);
this.priorityFeedCommands.delete(sessionId);
this.scheduledFeedSessions.delete(sessionId);
this.feedSessionQueue = this.feedSessionQueue.filter((queued) => queued !== sessionId);
const active = this.activeFeeds.get(sessionId);
if (active) {
active.cancellation.cancelled = true;
active.cancellation.cancel?.();
}
this.resolveDrainedFeedSession(sessionId);
}
private scheduleFeed(sessionId: string | null): void {
if (sessionId && !this.canDispatch(sessionId)) {
this.failTerminalFeedForSession(sessionId);
this.discardFeedForSession(sessionId);
return;
}
if (!this.activeFeeds.has(sessionId) && this.queuedFeedCount(sessionId) > 0 &&
!this.scheduledFeedSessions.has(sessionId)) {
this.scheduledFeedSessions.add(sessionId);
this.feedSessionQueue.push(sessionId);
}
this.startScheduledFeeds();
}
private startScheduledFeeds(): void {
while (this.activeFeeds.size < PiCmuxCommandDispatcher.maxActiveFeedCommands) {
const sessionId = this.feedSessionQueue.shift();
if (sessionId === undefined) return;
this.scheduledFeedSessions.delete(sessionId);
if (this.activeFeeds.has(sessionId)) continue;
const command = this.takeNextFeed(sessionId);
if (!command) {
if (sessionId) this.resolveDrainedFeedSession(sessionId);
continue;
}
const cancellation: PiCommandCancellation = { cancelled: false };
this.activeFeeds.set(sessionId, { cancellation, command });
const input = boundedPiFeedInput(command.payload, PiCmuxCommandDispatcher.maxFeedInputBytes);
void this.execute(command.args, command.cwd, input, command.context, cancellation)
.then((result) => {
if (result.ok && command.context.sessionId) {
rememberSurfaceTarget(this, command.context.sessionId, result);
}
if (result.surfaceUnavailable) {
const sessionId = command.context.sessionId;
if (sessionId) {
this.failTerminalFeedForSession(sessionId);
this.discardFeedForSession(sessionId);
}
} else if (result.error instanceof Error && result.error.message.includes("timed out after")) {
const sessionId = command.context.sessionId;
if (sessionId) {
this.failTerminalFeedForSession(sessionId);
this.discardFeedForSession(sessionId);
}
} else if (!result.ok && command.terminal && !result.surfaceUnavailable && !cancellation.cancelled) {
command.onFailure?.();
}
})
.catch(() => {})
.finally(() => {
if (this.activeFeeds.get(sessionId)?.cancellation === cancellation) this.activeFeeds.delete(sessionId);
this.scheduleFeed(sessionId);
if (sessionId) this.resolveDrainedFeedSession(sessionId);
});
}
}
private async execute(
args: string[],
cwd: string,
input: string | undefined,
context: PiExtensionContextSnapshot,
cancellation?: PiCommandCancellation,
): Promise<CommandResult> {
const sessionId = context.sessionId;
if (!this.canDispatch(sessionId)) {
return this.surfaceUnavailableResult();
}
const result = await this.spawnCmux(args, cwd, input, cancellation);
if (this.isSurfaceResolutionFailure(result)) {
const shouldWarn = !sessionId || !this.unavailableSessions.has(sessionId);
if (sessionId) this.unavailableSessions.add(sessionId);
if (shouldWarn) {
warn(context, "cmux hook command failed", {
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
surface_unavailable: true,
dispatch_disabled: true,
});
}
return { ...result, surfaceUnavailable: true };
}
return result;
}
private spawnCmux(
args: string[],
cwd: string,
input?: string,
cancellation?: PiCommandCancellation,
): Promise<CommandResult> {
return new Promise<CommandResult>((resolve) => {
let settled = false;
let stdout = "";
let stderr = "";
let inputError: unknown;
let timeout: ReturnType<typeof setTimeout> | null = null;
let terminateGrace: ReturnType<typeof setTimeout> | null = null;
let forceSettleTimeout: ReturnType<typeof setTimeout> | null = null;
let terminationError: Error | undefined;
const appendOutput = (current: string, chunk: unknown): string => {
const limit = 1024 * 1024;
if (current.length >= limit) return current;
return current + String(chunk).slice(0, limit - current.length);
};
const settle = (result: CommandResult) => {
if (settled) return;
settled = true;
if (timeout) clearTimeout(timeout);
if (terminateGrace) clearTimeout(terminateGrace);
if (forceSettleTimeout) clearTimeout(forceSettleTimeout);
if (cancellation) cancellation.cancel = undefined;
resolve(result);
};
const terminatedResult = (): CommandResult => ({
ok: false,
status: null,
stdout,
stderr,
error: terminationError,
});
try {
const child = spawn(cmuxExecutable(), args, {
env: hookEnvironment(cwd, true),
stdio: ["pipe", "pipe", "pipe"],
});
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout = appendOutput(stdout, chunk);
});
child.stderr.on("data", (chunk) => {
stderr = appendOutput(stderr, chunk);
});
child.stdin.on("error", (error) => {
inputError = error;
});
const beginTermination = (error: Error) => {
if (terminationError) return;
terminationError = error;
child.stdin.destroy();
try {
child.kill("SIGTERM");
} catch (_) {}
terminateGrace = setTimeout(() => {
try {
child.kill("SIGKILL");
} catch (_) {}
forceSettleTimeout = setTimeout(() => {
child.stdout.destroy();
child.stderr.destroy();
child.unref();
settle(terminatedResult());
}, 250);
}, 250);
};
child.on("error", (error) => {
settle(terminationError ? terminatedResult() : { ok: false, status: null, stdout, stderr, error });
});
child.on("close", (code) => {
if (terminationError) {
settle(terminatedResult());
return;
}
const status = typeof code === "number" ? code : null;
settle({
ok: status === 0 && inputError === undefined,
status,
stdout,
stderr,
error: inputError,
});
});
if (cancellation) {
cancellation.cancel = () => beginTermination(new Error("cmux feed command cancelled"));
if (cancellation.cancelled) cancellation.cancel();
}
timeout = setTimeout(() => {
beginTermination(new Error("cmux command timed out after 5000ms"));
}, 5000);
child.stdin.end(input);
} catch (error) {
settle({ ok: false, status: null, stdout, stderr, error });
}
});
}
private isSurfaceResolutionFailure(result: CommandResult): boolean {
return !result.ok && result.status === PiCmuxCommandDispatcher.surfaceUnavailableExitCode;
}
private surfaceUnavailableResult(): CommandResult {
return {
ok: false,
status: null,
stdout: "",
stderr: "",
surfaceUnavailable: true,
};
}
}
"""#
}
+322 -44
View File
@@ -5,16 +5,26 @@ extension CMUXCLI {
// Installed by `cmux hooks pi install` or `cmux hooks setup`.
// DO NOT EDIT MANUALLY. cmux upgrades this file in place.
import { spawn, spawnSync } from "node:child_process";
import { Buffer } from "node:buffer";
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
type HookExtra = Record<string, unknown>;
interface PendingCompletion {
lastAssistantMessage?: string;
notificationType: string;
turnId: string;
suppressNotification: boolean;
}
interface SessionState {
nextTurn: number;
activeTurnId?: string;
pendingCompletion?: PendingCompletion;
feedDeliveryFailed: boolean;
stopped: boolean;
}
@@ -24,9 +34,14 @@ interface CommandResult {
stdout: string;
stderr: string;
error?: unknown;
surfaceUnavailable?: boolean;
}
const sessionStates = new Map<string, SessionState>();
interface PiExtensionContextSnapshot {
readonly sessionId: string | null;
readonly cwd: string;
readonly notifyWarning?: () => void;
}
function firstString(...values: unknown[]): string | null {
for (const value of values) {
@@ -44,6 +59,137 @@ function objectValue(value: unknown, keys: string[]): unknown {
return undefined;
}
function utf8Prefix(value: unknown, maximumBytes: number): string | undefined {
if (typeof value !== "string") return undefined;
const candidate = value.length > maximumBytes ? value.slice(0, maximumBytes) : value;
const bytes = Buffer.from(candidate, "utf8");
if (bytes.byteLength <= maximumBytes) return candidate;
return bytes.subarray(0, maximumBytes).toString("utf8").replace(/\uFFFD+$/u, "");
}
interface PiFeedProjectionState {
remainingNodes: number;
seen: WeakSet<object>;
}
function projectPiFeedValue(value: unknown, state: PiFeedProjectionState, depth = 0, preserveText = true): unknown {
if (value === null || typeof value === "boolean") return value;
if (typeof value === "string") return preserveText ? utf8Prefix(value, 512) : piFeedValueSummary(value);
if (typeof value === "number") {
return preserveText && Number.isFinite(value) ? value : piFeedValueSummary(value);
}
if (typeof value !== "object") return piFeedValueSummary(value);
if (depth >= 4 || state.remainingNodes <= 0) return piFeedValueSummary(value);
if (state.seen.has(value)) return { kind: "circular" };
state.remainingNodes -= 1;
state.seen.add(value);
try {
if (Array.isArray(value)) {
const out: unknown[] = [];
const retained = Math.min(value.length, 12);
for (let index = 0; index < retained; index += 1) {
try {
out.push(projectPiFeedValue(value[index], state, depth + 1, preserveText));
} catch (_) {
out.push({ kind: "unavailable" });
}
}
if (value.length > retained) out.push({ kind: "omitted", count: value.length - retained });
return out;
}
const out: Record<string, unknown> = {};
let scanned = 0;
try {
for (const key in value as Record<string, unknown>) {
if (scanned >= 12) {
out.cmux_truncated = true;
break;
}
scanned += 1;
if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
const projectedKey = utf8Prefix(key, 128);
if (!projectedKey) continue;
try {
out[projectedKey] = projectPiFeedValue(
(value as Record<string, unknown>)[key],
state,
depth + 1,
preserveText,
);
} catch (_) {
out[projectedKey] = { kind: "unavailable" };
}
}
} catch (_) {
return piFeedValueSummary(value);
}
return out;
} finally {
state.seen.delete(value);
}
}
function boundedPiFeedInput(payload: Record<string, unknown>, maximumBytes: number): string {
const serialized = JSON.stringify(payload);
if (Buffer.byteLength(serialized, "utf8") <= maximumBytes) return serialized;
const summaries = Array.isArray(payload.cmux_compacted_terminal_events)
? payload.cmux_compacted_terminal_events
: [];
const latest = summaries.length > 0 && typeof summaries[summaries.length - 1] === "object"
? summaries[summaries.length - 1] as Record<string, unknown>
: undefined;
const rawCount = payload.cmux_compacted_terminal_count;
const totalCount = typeof rawCount === "number" && Number.isFinite(rawCount)
? Math.max(summaries.length, rawCount)
: summaries.length;
const rawOmitted = payload.cmux_compacted_terminal_omitted_count;
const omittedCount = typeof rawOmitted === "number" && Number.isFinite(rawOmitted)
? Math.max(0, rawOmitted, totalCount - 1)
: Math.max(0, totalCount - 1);
const safe: Record<string, unknown> = {};
for (const key of ["session_id", "cwd", "turn_id", "tool_call_id", "tool_name"] as const) {
const value = utf8Prefix(payload[key], 256);
if (value !== undefined) safe[key] = value;
}
for (const key of ["hook_event_name", "event"] as const) {
const value = utf8Prefix(payload[key], 64);
if (value !== undefined) safe[key] = value;
}
if (typeof payload.is_error === "boolean") safe.is_error = payload.is_error;
if (latest) {
const summary: Record<string, unknown> = {};
for (const key of ["session_id", "cwd", "turn_id", "tool_call_id", "tool_name"] as const) {
const value = utf8Prefix(latest[key] ?? payload[key], 256);
if (value !== undefined) summary[key] = value;
}
if (typeof latest.is_error === "boolean") summary.is_error = latest.is_error;
safe.cmux_compacted_terminal_count = totalCount;
safe.cmux_compacted_terminal_omitted_count = omittedCount;
safe.cmux_compacted_terminal_events = [summary];
} else if (firstString(payload.hook_event_name, payload.event) === "PostToolUse") {
safe.cmux_compacted_terminal_count = 1;
safe.cmux_compacted_terminal_omitted_count = 0;
safe.cmux_compacted_terminal_events = [piTerminalFeedSummary(payload)];
} else if (payload.tool_input !== undefined) {
safe.tool_input = piFeedValueSummary(payload.tool_input);
}
const compacted = JSON.stringify(safe);
if (Buffer.byteLength(compacted, "utf8") <= maximumBytes) return compacted;
const fallbackEvent = utf8Prefix(payload.hook_event_name, 64) || "PostToolUse";
return JSON.stringify({
session_id: utf8Prefix(payload.session_id, 128),
hook_event_name: fallbackEvent,
event: fallbackEvent,
tool_call_id: "compacted-overflow",
tool_name: "cmux_compacted_terminal_overflow",
tool_input: fallbackEvent === "PostToolUse"
? { omitted_terminal_count: Math.max(1, totalCount) }
: piFeedValueSummary(payload.tool_input),
});
}
function resolveExecutable(name: string): string {
const pathEnv = process.env.PATH || "";
for (const dir of pathEnv.split(path.delimiter)) {
@@ -74,14 +220,95 @@ function looksLikePiScript(value: string): boolean {
);
}
interface NormalizedLaunchArgvCache {
key: string;
argv: string[];
}
let normalizedLaunchArgvCache: NormalizedLaunchArgvCache | undefined;
function normalizedLaunchArgv(): string[] {
const raw = Array.isArray(process.argv) ? process.argv.map((value) => String(value)) : [];
if (raw.length === 0) return [resolveExecutable("pi")];
if (looksLikePiExecutable(raw[0])) return raw;
if (raw.length > 1 && looksLikePiScript(raw[1])) {
return [resolveExecutable("pi"), ...raw.slice(2)];
// Pi's argv and inherited PATH are stable for the lifetime of this extension.
// Memoize executable discovery so every hook subprocess does not synchronously
// stat the full PATH again. Keep the key dynamic for test harnesses and hosts
// that deliberately rewrite process argv at runtime.
const cacheKey = [process.env.PATH || "", ...raw].join("\0");
if (normalizedLaunchArgvCache?.key === cacheKey) {
return normalizedLaunchArgvCache.argv;
}
return [resolveExecutable("pi"), ...raw.slice(1)];
let argv: string[];
if (raw.length === 0) {
argv = [resolveExecutable("pi")];
} else if (looksLikePiExecutable(raw[0])) {
argv = raw;
} else if (raw.length > 1 && looksLikePiScript(raw[1])) {
argv = [resolveExecutable("pi"), ...raw.slice(2)];
} else {
argv = [resolveExecutable("pi"), ...raw.slice(1)];
}
normalizedLaunchArgvCache = { key: cacheKey, argv };
return argv;
}
interface DetectedPiVersionCache {
key: string;
version: string | null;
}
let detectedPiVersionCache: DetectedPiVersionCache | undefined;
function detectedPiVersion(): string | null {
const cacheKey = [
process.cwd(),
...process.argv.slice(0, 2).map((value) => String(value)),
].join("\0");
if (detectedPiVersionCache?.key === cacheKey) {
return detectedPiVersionCache.version;
}
const script = process.argv.slice(0, 2).find((value) => {
const candidate = String(value);
return looksLikePiScript(candidate) || looksLikePiExecutable(candidate);
});
let version: string | null = null;
if (script) {
let scriptPath = path.resolve(String(script));
try {
// npm launches through bin symlinks, so inspect the package containing the resolved script.
scriptPath = fs.realpathSync(scriptPath);
} catch (_) {}
let directory = path.dirname(scriptPath);
for (let depth = 0; depth < 8; depth += 1) {
try {
const packageJSON = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"));
if (
packageJSON?.name === "@earendil-works/pi-coding-agent" ||
packageJSON?.name === "@mariozechner/pi-coding-agent"
) {
version = firstString(packageJSON.version);
break;
}
} catch (_) {}
const parent = path.dirname(directory);
if (parent === directory) break;
directory = parent;
}
}
detectedPiVersionCache = { key: cacheKey, version };
return version;
}
function supportsAgentSettled(): boolean {
const version = detectedPiVersion();
if (!version) return false;
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
if (!match) return false;
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
return major > 0 || minor > 80 || (minor === 80 && patch >= 5);
}
function base64NulSeparated(values: string[]): string {
@@ -190,18 +417,38 @@ function textFromContent(content: unknown): string | null {
return parts.join("\n") || null;
}
function lastAssistantMessage(event: unknown): string | undefined {
interface AssistantCompletion {
lastAssistantMessage?: string;
suppressNotification: boolean;
}
function assistantCompletionFrom(event: unknown): AssistantCompletion {
const messagesValue = objectValue(event, ["messages"]);
const messages = Array.isArray(messagesValue) ? messagesValue : [];
let suppressNotification = false;
let inspectedLatestAssistant = false;
// Resolve text and interruption metadata in one reverse pass. agent_end may
// carry a large message array, so notification support must not rescan it.
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (!message || typeof message !== "object") continue;
const typed = message as { role?: unknown; content?: unknown };
const typed = message as {
role?: unknown;
content?: unknown;
stopReason?: unknown;
cmuxSuppressNotification?: unknown;
};
if (typed.role !== "assistant") continue;
if (!inspectedLatestAssistant) {
// Input extensions may normalize an abort to `stop` to keep Pi's UI quiet;
// the marker preserves the interruption intent across that normalization.
suppressNotification = typed.stopReason === "aborted" || typed.cmuxSuppressNotification === true;
inspectedLatestAssistant = true;
}
const text = firstString(textFromContent(typed.content));
if (text) return text;
if (text) return { lastAssistantMessage: text, suppressNotification };
}
return undefined;
return { suppressNotification };
}
function sessionIdFrom(ctx: ExtensionContext): string | null {
@@ -212,10 +459,25 @@ function cwdFrom(ctx: ExtensionContext): string {
return firstString(ctx.cwd, process.cwd()) || process.cwd();
}
function stateFor(sessionId: string): SessionState {
function snapshotContext(ctx: ExtensionContext): PiExtensionContextSnapshot {
let notifyWarning: (() => void) | undefined;
try {
const ui = (ctx as unknown as { ui?: { notify?: (message: string, type?: string) => void } }).ui;
if (typeof ui?.notify === "function") {
notifyWarning = () => ui.notify?.("cmux Pi integration warning - check the terminal for details", "warning");
}
} catch (_) {}
return {
sessionId: sessionIdFrom(ctx),
cwd: cwdFrom(ctx),
notifyWarning,
};
}
function stateFor(sessionStates: Map<string, SessionState>, sessionId: string): SessionState {
let state = sessionStates.get(sessionId);
if (!state) {
state = { nextTurn: 0, stopped: false };
state = { nextTurn: 0, feedDeliveryFailed: false, stopped: false };
sessionStates.set(sessionId, state);
}
return state;
@@ -227,67 +489,83 @@ function eventTurnId(event: unknown): string | null {
);
}
function beginTurn(sessionId: string, event: unknown): string {
const state = stateFor(sessionId);
function beginTurn(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string {
const state = stateFor(sessionStates, sessionId);
const turnId = eventTurnId(event) || `${sessionId}:turn-${state.nextTurn + 1}`;
if (!eventTurnId(event)) state.nextTurn += 1;
state.activeTurnId = turnId;
state.pendingCompletion = undefined;
state.stopped = false;
return turnId;
}
function currentTurnId(sessionId: string, event: unknown): string {
const state = stateFor(sessionId);
function currentTurnId(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string {
const state = stateFor(sessionStates, sessionId);
const turnId = eventTurnId(event) || state.activeTurnId || `${sessionId}:turn-${state.nextTurn + 1}`;
if (!eventTurnId(event) && !state.activeTurnId) state.nextTurn += 1;
return turnId;
}
function finishTurn(sessionId: string, event: unknown): string {
const state = stateFor(sessionId);
function finishTurn(sessionStates: Map<string, SessionState>, sessionId: string, event: unknown): string {
const state = stateFor(sessionStates, sessionId);
const turnId = eventTurnId(event) || state.activeTurnId || `${sessionId}:turn-${state.nextTurn + 1}`;
if (!eventTurnId(event) && !state.activeTurnId) state.nextTurn += 1;
state.activeTurnId = undefined;
state.pendingCompletion = undefined;
state.stopped = true;
return turnId;
}
function warn(ctx: ExtensionContext | null, message: string, details: Record<string, unknown> = {}): void {
function settleTurn(sessionStates: Map<string, SessionState>, sessionId: string): PendingCompletion | undefined {
const state = sessionStates.get(sessionId);
const completion = state?.pendingCompletion;
if (!state || !completion || state.stopped) return undefined;
state.activeTurnId = undefined;
state.pendingCompletion = undefined;
// Keep the settlement claim while awaiting delivery so session_shutdown cannot
// emit a second Stop when terminal-feed delivery degrades.
state.stopped = true;
return completion;
}
function warn(
ctx: PiExtensionContextSnapshot | null,
message: string,
details: Record<string, unknown> = {},
notifyUser = false,
): void {
const payload = { source: "cmux-pi-extension", level: "warning", message, ...details };
try {
console.warn(JSON.stringify(payload));
} catch (_) {
console.warn(`[cmux-pi-extension] ${message}`);
}
const ui = (ctx as unknown as { ui?: { notify?: (message: string, type?: string) => void } } | null)?.ui;
try {
ui?.notify?.("cmux Pi integration warning - check the terminal for details", "warning");
} catch (_) {}
// Hook transport is best-effort telemetry. Keep routine command failures in
// the terminal instead of interrupting Pi with a generic toast; reserve the
// UI warning for an unexpected extension-task exception.
if (notifyUser) {
try {
ctx?.notifyWarning?.();
} catch (_) {}
}
}
function cmuxExecutable(): string {
return process.env.CMUX_PI_CMUX_BIN || "cmux";
}
function runCmux(args: string[], cwd: string, input?: string): CommandResult {
try {
const result = spawnSync(cmuxExecutable(), args, {
input,
encoding: "utf8",
env: hookEnvironment(cwd, true),
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const status = typeof result.status === "number" ? result.status : null;
return {
ok: status === 0 && !result.error,
status,
stdout: typeof result.stdout === "string" ? result.stdout : "",
stderr: typeof result.stderr === "string" ? result.stderr : "",
error: result.error,
};
} catch (error) {
return { ok: false, status: null, stdout: "", stderr: "", error };
}
interface PiFeedCommand {
readonly args: string[];
readonly cwd: string;
readonly payload: Record<string, unknown>;
readonly context: PiExtensionContextSnapshot;
readonly terminal: boolean;
readonly onFailure?: () => void;
}
interface PiCommandCancellation {
cancelled: boolean;
cancel?: () => void;
}
"""#
}
+336 -97
View File
@@ -1,15 +1,18 @@
extension CMUXCLI {
static let piExtensionSourcePart2 = #"""
}
function sendHook(subcommand: string, ctx: ExtensionContext, extra: HookExtra = {}): boolean {
async function sendHook(
dispatcher: PiCmuxCommandDispatcher,
subcommand: string,
context: PiExtensionContextSnapshot,
extra: HookExtra = {},
): Promise<boolean> {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return true;
if (!process.env.CMUX_SURFACE_ID) return true;
const sessionId = sessionIdFrom(ctx);
const sessionId = context.sessionId;
if (!sessionId) return true;
const target = surfaceTargetArgs(dispatcher, sessionId);
if (!target) return !firstString(process.env.CMUX_PANEL_ID);
const cwd = cwdFrom(ctx);
const cwd = context.cwd;
const payload: HookExtra = {
session_id: sessionId,
cwd,
@@ -17,9 +20,15 @@ function sendHook(subcommand: string, ctx: ExtensionContext, extra: HookExtra =
event: eventName(subcommand),
...extra,
};
const result = runCmux(["hooks", "pi", subcommand], cwd, JSON.stringify(payload));
if (!result.ok) {
warn(ctx, "cmux hook command failed", {
const result = await dispatcher.run(
["hooks", "pi", subcommand, ...target],
cwd,
JSON.stringify(payload),
context,
);
if (result.ok) rememberSurfaceTarget(dispatcher, sessionId, result);
if (!result.ok && !result.surfaceUnavailable) {
warn(context, "cmux hook command failed", {
subcommand,
status: result.status,
stderr_available: result.stderr.trim().length > 0,
@@ -29,7 +38,20 @@ function sendHook(subcommand: string, ctx: ExtensionContext, extra: HookExtra =
return result.ok;
}
function surfaceTargetArgs(): string[] | null {
const resolvedSurfaceTargets = new WeakMap<PiCmuxCommandDispatcher, Map<string, string[]>>();
function surfaceTargetsFor(dispatcher: PiCmuxCommandDispatcher): Map<string, string[]> {
let targets = resolvedSurfaceTargets.get(dispatcher);
if (!targets) {
targets = new Map();
resolvedSurfaceTargets.set(dispatcher, targets);
}
return targets;
}
function surfaceTargetArgs(dispatcher: PiCmuxCommandDispatcher, sessionId: string): string[] | null {
const resolved = surfaceTargetsFor(dispatcher).get(sessionId);
if (resolved) return [...resolved];
const surfaceId = firstString(process.env.CMUX_SURFACE_ID);
if (!surfaceId) return null;
const args: string[] = [];
@@ -39,6 +61,31 @@ function surfaceTargetArgs(): string[] | null {
return args;
}
function rememberSurfaceTarget(
dispatcher: PiCmuxCommandDispatcher,
sessionId: string,
result: CommandResult,
): void {
const payload = parseJSONOutput(result);
const workspaceId = firstString(payload?.workspace_id);
const surfaceId = firstString(payload?.surface_id);
if (!workspaceId || !surfaceId) return;
surfaceTargetsFor(dispatcher).set(
sessionId,
["--workspace", workspaceId, "--surface", surfaceId],
);
}
function releaseSessionRuntime(
dispatcher: PiCmuxCommandDispatcher,
sessionStates: Map<string, SessionState>,
sessionId: string,
): void {
dispatcher.releaseSession(sessionId);
sessionStates.delete(sessionId);
surfaceTargetsFor(dispatcher).delete(sessionId);
}
function parseJSONOutput(result: CommandResult): Record<string, unknown> | null {
if (!result.ok) return null;
try {
@@ -129,13 +176,18 @@ function sanitizedResumeArgv(sessionId: string): string[] {
return out;
}
function ensureResumeBinding(ctx: ExtensionContext, sessionId: string, cwd: string): void {
async function ensureResumeBinding(
dispatcher: PiCmuxCommandDispatcher,
context: PiExtensionContextSnapshot,
sessionId: string,
): Promise<void> {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return;
const target = surfaceTargetArgs();
const target = surfaceTargetArgs(dispatcher, sessionId);
if (!target) return;
const cwd = context.cwd;
const resumeArgv = sanitizedResumeArgv(sessionId);
const set = runCmux([
const set = await dispatcher.run([
"--json",
"surface",
"resume",
@@ -153,27 +205,40 @@ function ensureResumeBinding(ctx: ExtensionContext, sessionId: string, cwd: stri
cwd,
"--",
...resumeArgv,
], cwd);
if (!set.ok) {
warn(ctx, "failed to set Pi resume binding", {
], cwd, undefined, context);
if (!set.ok && !set.surfaceUnavailable) {
warn(context, "failed to set Pi resume binding", {
status: set.status,
stderr_available: set.stderr.trim().length > 0,
error_available: set.error !== undefined,
});
return;
}
if (set.surfaceUnavailable) return;
const verified = parseJSONOutput(runCmux(["--json", "surface", "resume", "get", ...target], cwd));
const verification = await dispatcher.run(
["--json", "surface", "resume", "get", ...target],
cwd,
undefined,
context,
);
if (verification.surfaceUnavailable) return;
const verified = parseJSONOutput(verification);
if (!resumeBindingMatches(verified, sessionId)) {
warn(ctx, "Pi resume binding did not verify after write", { session_id: sessionId });
warn(context, "Pi resume binding did not verify after write", { session_id: sessionId });
}
}
function clearResumeBinding(ctx: ExtensionContext, sessionId: string, cwd: string): boolean {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return true;
const target = surfaceTargetArgs();
if (!target) return true;
const result = runCmux([
async function clearResumeBinding(
dispatcher: PiCmuxCommandDispatcher,
context: PiExtensionContextSnapshot,
sessionId: string,
): Promise<void> {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return;
const target = surfaceTargetArgs(dispatcher, sessionId);
if (!target) return;
const cwd = context.cwd;
const result = await dispatcher.run([
"--json",
"surface",
"resume",
@@ -183,105 +248,279 @@ function clearResumeBinding(ctx: ExtensionContext, sessionId: string, cwd: strin
sessionId,
"--source",
"agent-hook",
], cwd);
], cwd, undefined, context);
if (result.surfaceUnavailable) return;
if (!result.ok) {
warn(ctx, "failed to clear Pi resume binding", {
warn(context, "failed to clear Pi resume binding", {
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
});
}
return result.ok;
}
function sendFeed(eventName: "PreToolUse" | "PostToolUse", ctx: ExtensionContext, event: unknown, extra: HookExtra = {}): void {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return;
if (!process.env.CMUX_SURFACE_ID) return;
const sessionId = sessionIdFrom(ctx);
if (!sessionId) return;
const cwd = cwdFrom(ctx);
const payload: HookExtra = {
session_id: sessionId,
cwd,
hook_event_name: eventName,
event: eventName,
turn_id: currentTurnId(sessionId, event),
tool_call_id: firstString(objectValue(event, ["toolCallId", "tool_call_id", "id"])),
tool_name: firstString(objectValue(event, ["toolName", "tool_name", "name"])),
tool_input: objectValue(event, ["args", "input"]),
...extra,
};
try {
const child = spawn(cmuxExecutable(), ["hooks", "feed", "--source", "pi", "--event", eventName], {
env: hookEnvironment(cwd, true),
stdio: ["pipe", "ignore", "ignore"],
detached: true,
type PiFeedEventName =
| "PreToolUse"
| "PostToolUse"
| "PreCompact"
| "PostCompact"
| "SubagentStart"
| "SubagentStop";
const subagentToolNames = new Set([
"subagent",
"team_spawn",
"superpowers_dispatch",
"Task",
]);
function isSubagentTool(event: unknown): boolean {
const toolName = firstString(objectValue(event, ["toolName", "tool_name", "name"]));
return toolName !== null && (subagentToolNames.has(toolName) || /subagent/i.test(toolName));
}
function isTerminalFeedEvent(eventName: PiFeedEventName): boolean {
return eventName === "PostToolUse" || eventName === "SubagentStop";
}
function prepareFeedDispatch(
dispatcher: PiCmuxCommandDispatcher,
sessionStates: Map<string, SessionState>,
eventName: PiFeedEventName,
context: PiExtensionContextSnapshot,
event: unknown,
): (() => void) | undefined {
if (process.env.CMUX_PI_HOOKS_DISABLED === "1") return undefined;
const sessionId = context.sessionId;
if (!sessionId) return undefined;
if (!dispatcher.canDispatch(sessionId)) return undefined;
const state = stateFor(sessionStates, sessionId);
if (state.stopped) return undefined;
const cwd = context.cwd;
const toolCallId = firstString(objectValue(event, ["toolCallId", "tool_call_id", "id"]));
const toolName = firstString(objectValue(event, ["toolName", "tool_name", "name"]));
const turnId = currentTurnId(sessionStates, sessionId, event);
const toolInput = objectValue(event, ["args", "input"]);
const terminal = isTerminalFeedEvent(eventName);
const toolResult = terminal
? objectValue(event, ["result", "details", "content"])
: undefined;
const isError = terminal ? objectValue(event, ["isError", "is_error"]) : undefined;
return () => {
const target = surfaceTargetArgs(dispatcher, sessionId);
if (!target) return;
// Pi invokes tool lifecycle handlers on its UI event loop. Keep those
// callbacks lightweight by traversing and bounding tool payloads only in
// the already-detached lifecycle task.
const projectionState: PiFeedProjectionState = { remainingNodes: 48, seen: new WeakSet() };
const payload: HookExtra = {
session_id: utf8Prefix(sessionId, 256),
cwd: utf8Prefix(cwd, 2048),
hook_event_name: eventName,
event: eventName,
turn_id: utf8Prefix(turnId, 256),
};
const boundedToolCallId = utf8Prefix(toolCallId, 256);
if (boundedToolCallId !== undefined) payload.tool_call_id = boundedToolCallId;
const boundedToolName = utf8Prefix(toolName, 256);
if (boundedToolName !== undefined) payload.tool_name = boundedToolName;
if (toolInput !== undefined) payload.tool_input = projectPiFeedValue(toolInput, projectionState);
if (toolResult !== undefined) {
payload.tool_result = projectPiFeedValue(toolResult, projectionState, 0, false);
}
if (isError !== undefined) payload.is_error = projectPiFeedValue(isError, projectionState);
dispatcher.enqueueFeed(`${sessionId}:${toolCallId || toolName || "unknown"}`, {
args: ["hooks", "feed", "--source", "pi", "--event", eventName, ...target],
cwd,
payload,
context,
terminal,
onFailure: () => { state.feedDeliveryFailed = true; },
});
child.on("error", () => {});
child.stdin.on("error", () => {});
child.stdin.end(JSON.stringify(payload));
child.unref();
} catch (_) {}
};
}
async function publishPendingCompletion(
dispatcher: PiCmuxCommandDispatcher,
sessionStates: Map<string, SessionState>,
context: PiExtensionContextSnapshot,
sessionId: string,
completion: PendingCompletion,
): Promise<void> {
await dispatcher.finishFeedForSession(sessionId);
const state = stateFor(sessionStates, sessionId);
const feedDelivered = !state.feedDeliveryFailed;
state.feedDeliveryFailed = false;
if (!feedDelivered) {
warn(context, "cmux hook command failed", { session_id: sessionId });
}
const stopPayload: HookExtra = {
last_assistant_message: completion.lastAssistantMessage,
turn_id: completion.turnId,
};
if (completion.suppressNotification) {
// Stop normally creates cmux's native fallback notification when no explicit
// notification was routed. Mark intentional interruption as already handled.
stopPayload.cmux_notification_routed = true;
} else if (feedDelivered) {
const notificationRouted = await sendHook(dispatcher, "notification", context, {
message: completion.lastAssistantMessage || "Task completed",
turn_id: completion.turnId,
notification: { type: completion.notificationType },
});
if (notificationRouted) stopPayload.cmux_notification_routed = true;
}
await sendHook(dispatcher, "stop", context, stopPayload);
}
export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
const sessionId = sessionIdFrom(ctx);
const cwd = cwdFrom(ctx);
if (sessionId) stateFor(sessionId).stopped = false;
const ok = sendHook("session-start", ctx);
if (ok && sessionId) ensureResumeBinding(ctx, sessionId, cwd);
});
const dispatcher = new PiCmuxCommandDispatcher();
const sessionStates = new Map<string, SessionState>();
const lifecycleTails = new Map<string, Promise<void>>();
pi.on("before_agent_start", async (event, ctx) => {
const sessionId = sessionIdFrom(ctx);
const turnId = sessionId ? beginTurn(sessionId, event) : undefined;
sendHook("prompt-submit", ctx, { prompt: event.prompt, turn_id: turnId });
});
const enqueueLifecycleTask = (
sessionId: string,
context: PiExtensionContextSnapshot,
operation: () => Promise<unknown> | unknown,
): Promise<void> => {
const previous = lifecycleTails.get(sessionId) || Promise.resolve();
let tracked: Promise<void>;
tracked = previous
.then(operation)
.then(() => undefined)
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : undefined;
warn(context, "cmux lifecycle task failed", {
error_available: error !== undefined,
error_message: utf8Prefix(errorMessage, 512),
}, true);
})
.finally(() => {
if (lifecycleTails.get(sessionId) === tracked) lifecycleTails.delete(sessionId);
});
lifecycleTails.set(sessionId, tracked);
return tracked;
};
pi.on("tool_execution_start", async (event, ctx) => {
sendFeed("PreToolUse", ctx, event);
});
pi.on("tool_execution_end", async (event, ctx) => {
sendFeed("PostToolUse", ctx, event, {
tool_result: objectValue(event, ["result", "details", "content"]),
is_error: objectValue(event, ["isError", "is_error"]),
pi.on("session_start", (_event, ctx) => {
const context = snapshotContext(ctx);
const sessionId = context.sessionId;
if (sessionId) {
const state = stateFor(sessionStates, sessionId);
state.pendingCompletion = undefined;
state.feedDeliveryFailed = false;
state.stopped = false;
}
if (!sessionId) return;
enqueueLifecycleTask(sessionId, context, async () => {
const ok = await sendHook(dispatcher, "session-start", context);
if (ok) await ensureResumeBinding(dispatcher, context, sessionId);
});
});
pi.on("agent_end", async (event, ctx) => {
const sessionId = sessionIdFrom(ctx);
const turnId = sessionId ? finishTurn(sessionId, event) : undefined;
const message = lastAssistantMessage(event);
const notificationRouted = sendHook("notification", ctx, {
message: message || "Task completed",
turn_id: turnId,
notification: {
type: firstString(objectValue(event, ["stopReason", "reason", "terminationReason"])) || "completed",
},
});
const stopPayload: HookExtra = {
last_assistant_message: message,
turn_id: turnId,
pi.on("before_agent_start", (event, ctx) => {
const context = snapshotContext(ctx);
const sessionId = context.sessionId;
if (!sessionId) return;
const turnId = beginTurn(sessionStates, sessionId, event);
enqueueLifecycleTask(sessionId, context, () => (
sendHook(dispatcher, "prompt-submit", context, { prompt: event.prompt, turn_id: turnId })
));
});
const enqueueFeed = (
eventName: PiFeedEventName,
event: unknown,
ctx: ExtensionContext,
): void => {
const context = snapshotContext(ctx);
const sessionId = context.sessionId;
if (!sessionId) return;
const dispatch = prepareFeedDispatch(dispatcher, sessionStates, eventName, context, event);
if (!dispatch) return;
enqueueLifecycleTask(sessionId, context, dispatch);
};
pi.on("tool_execution_start", (event, ctx) => {
enqueueFeed(isSubagentTool(event) ? "SubagentStart" : "PreToolUse", event, ctx);
});
pi.on("tool_execution_end", (event, ctx) => {
enqueueFeed(isSubagentTool(event) ? "SubagentStop" : "PostToolUse", event, ctx);
});
pi.on("session_before_compact", (event, ctx) => {
enqueueFeed("PreCompact", event, ctx);
});
pi.on("session_compact", (event, ctx) => {
enqueueFeed("PostCompact", event, ctx);
});
pi.on("agent_end", (event, ctx) => {
const context = snapshotContext(ctx);
const sessionId = context.sessionId;
if (!sessionId) return;
const state = stateFor(sessionStates, sessionId);
const assistantCompletion = assistantCompletionFrom(event);
// Preserve the latest low-level result until Pi confirms no automatic work remains.
state.pendingCompletion = {
lastAssistantMessage: assistantCompletion.lastAssistantMessage || state.pendingCompletion?.lastAssistantMessage,
notificationType: firstString(objectValue(event, ["stopReason", "reason", "terminationReason"])) || "completed",
turnId: currentTurnId(sessionStates, sessionId, event),
suppressNotification: assistantCompletion.suppressNotification,
};
if (notificationRouted) stopPayload.cmux_notification_routed = true;
sendHook("stop", ctx, stopPayload);
// Older Pi versions do not emit agent_settled, so retain their established completion behavior.
if (!supportsAgentSettled()) {
const completion = settleTurn(sessionStates, sessionId);
if (completion) {
enqueueLifecycleTask(sessionId, context, () => (
publishPendingCompletion(dispatcher, sessionStates, context, sessionId, completion)
));
}
}
});
pi.on("agent_settled", (_event, ctx) => {
const context = snapshotContext(ctx);
const isIdle = ctx.isIdle();
const sessionId = context.sessionId;
if (!sessionId || !isIdle) return;
// Consume pending completion before subprocess calls so duplicate settlement cannot notify twice.
const completion = settleTurn(sessionStates, sessionId);
if (completion) {
enqueueLifecycleTask(sessionId, context, () => (
publishPendingCompletion(dispatcher, sessionStates, context, sessionId, completion)
));
}
});
pi.on("session_shutdown", async (event, ctx) => {
const sessionId = sessionIdFrom(ctx);
const context = snapshotContext(ctx);
const sessionId = context.sessionId;
if (!sessionId) return;
const state = stateFor(sessionId);
const cwd = cwdFrom(ctx);
const state = stateFor(sessionStates, sessionId);
let stopPayload: HookExtra | undefined;
if (!state.stopped) {
const turnId = finishTurn(sessionId, event);
sendHook("stop", ctx, {
const turnId = finishTurn(sessionStates, sessionId, event);
stopPayload = {
turn_id: turnId,
terminationReason: firstString(objectValue(event, ["reason"])) || "session_shutdown",
});
};
}
if (clearResumeBinding(ctx, sessionId, cwd)) sessionStates.delete(sessionId);
await enqueueLifecycleTask(sessionId, context, async () => {
await dispatcher.finishFeedForSession(sessionId);
const feedDelivered = !state.feedDeliveryFailed;
state.feedDeliveryFailed = false;
if (!feedDelivered) warn(context, "cmux hook command failed", { session_id: sessionId });
if (stopPayload) await sendHook(dispatcher, "stop", context, stopPayload);
try {
await clearResumeBinding(dispatcher, context, sessionId);
} finally {
releaseSessionRuntime(dispatcher, sessionStates, sessionId);
}
});
});
}
"""#
+436
View File
@@ -0,0 +1,436 @@
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
func controlAgentLaunchCommandPayload(
_ command: AgentLaunchCommand
) -> [String: Any] {
var payload: [String: Any] = ["arguments": command.arguments]
if let launcher = command.launcher {
payload["launcher"] = launcher
}
if let executablePath = command.executablePath {
payload["executable_path"] = executablePath
}
if let workingDirectory = command.workingDirectory {
payload["working_directory"] = workingDirectory
}
if let environment = command.environment {
payload["environment"] = environment
}
if let capturedAt = command.capturedAt {
payload["captured_at"] = capturedAt
}
if let source = command.source {
payload["source"] = source
}
return payload
}
func runRestoreCommand(
commandArgs: [String],
client: SocketClient,
processEnvironment: [String: String]
) throws {
let selector = try restoreSelector(commandArgs)
var params: [String: Any] = [:]
if let surface = selector.surface {
let surfaceID = try normalizeSurfaceHandle(
surface,
client: client,
workspaceHandle: nil,
windowHandle: nil
)
guard let surfaceID else {
throw loggedRestoreError(
stage: "surface.lookup",
detail: surface,
message: String(
localized: "cli.restore.error.surfaceNotFound",
defaultValue: "restore: the requested surface was not found. Check the surface reference, then retry."
)
)
}
params["surface_id"] = surfaceID
} else if selector.usesCurrentSurface,
let surfaceID = try currentRestoreSurfaceID(
client: client,
processEnvironment: processEnvironment
) {
params["surface_id"] = surfaceID
} else {
throw currentRestoreSurfaceUnknownError()
}
let payload = try client.sendV2(method: "surface.resume.get", params: params)
guard let rawRecord = payload["restore_record"] as? [String: Any] else {
throw loggedRestoreError(
stage: "record.missing",
message: String(
localized: "cli.restore.error.noRecord",
defaultValue: "restore: this session has nothing to restore. Start the agent again in this terminal."
)
)
}
let record = try restoreRecord(from: rawRecord)
if let expectedKind = selector.kind, expectedKind != record.kind {
throw loggedRestoreError(
stage: "record.kind-mismatch",
detail: "expected=\(expectedKind) actual=\(record.kind)",
message: String(
localized: "cli.restore.error.kindMismatch",
defaultValue: "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record."
)
)
}
if let expectedCheckpointID = selector.checkpointID,
expectedCheckpointID != record.checkpointID {
throw loggedRestoreError(
stage: "record.checkpoint-mismatch",
detail: "expected=\(expectedCheckpointID) actual=\(record.checkpointID ?? "none")",
message: String(
localized: "cli.restore.error.checkpointMismatch",
defaultValue: "restore: this command no longer matches the session. Run 'cmux restore --surface' to use the current record."
)
)
}
let environment = processEnvironment.merging(record.environment) { _, restored in
restored
}
if record.launchCommand == nil,
record.preparedArguments == nil,
let legacyCommand = record.legacyCommand {
try execLegacyRestoreRecord(
legacyCommand,
record: record,
environment: environment,
client: client
)
}
guard let mode = AgentRestoreRequestMode(rawValue: record.mode) else {
throw loggedRestoreError(
stage: "record.mode",
detail: record.mode,
message: String(
localized: "cli.restore.error.unsupportedMode",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
let requestedWorkingDirectory = requestedRestoreWorkingDirectory(for: record)
let appliedWorkingDirectory = try applyRestoreWorkingDirectory(
requestedWorkingDirectory
)
let effectiveWorkingDirectory: String? =
if requestedWorkingDirectory?.isEmpty == false {
appliedWorkingDirectory ?? FileManager.default.currentDirectoryPath
} else {
nil
}
let request = AgentRestoreRequest(
mode: mode,
kind: record.kind,
checkpointID: record.checkpointID,
source: record.source,
workingDirectory: effectiveWorkingDirectory,
environment: record.environment,
launchCommand: record.launchCommand,
preparedArguments: record.preparedArguments,
preparedArgumentsWorkingDirectory: normalizedRestoreWorkingDirectory(
record.preparedArgumentsWorkingDirectory
),
observedPermissionMode: record.permissionMode
)
guard let invocation = AgentRestorePlanner(
executableFileResolver: AgentRestoreExecutableFileResolver()
).invocation(
for: request,
ambientEnvironment: processEnvironment
) else {
if let legacyCommand = record.legacyCommand {
try execLegacyRestoreRecord(
legacyCommand,
record: record,
environment: environment,
client: client
)
}
throw loggedRestoreError(
stage: "record.incomplete",
detail: "mode=\(record.mode) kind=\(record.kind)",
message: String(
localized: "cli.restore.error.incompleteData",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
for preflight in invocation.preflightInvocations {
try runRestorePreflight(
preflight,
appliedWorkingDirectory: effectiveWorkingDirectory
)
}
client.close()
try execRestoreInvocation(
invocation,
appliedWorkingDirectory: effectiveWorkingDirectory
)
}
private func currentRestoreSurfaceID(
client: SocketClient,
processEnvironment: [String: String]
) throws -> String? {
// The remote relay and the local CLI do not share a PID namespace.
if client.isRelayBacked {
return try relayRestoreSurfaceID(
client: client,
processEnvironment: processEnvironment
)
}
let resolution = AgentProcessBindingResolution.controllingTTY.rawValue
do {
let payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: [
"pid": Int(ProcessInfo.processInfo.processIdentifier),
"pid_resolution": resolution,
]
)
guard payload["source"] as? String == "pid",
payload["pid_resolution"] as? String == resolution,
let workspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(workspaceID),
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceID) else {
throw currentRestoreSurfaceUnknownError()
}
return surfaceID
} catch let error as CLIError {
switch error.v2Code {
case "not_found":
client.close()
throw currentRestoreSurfaceUnknownError()
case "method_not_found", "unrecognized_method":
// These protocol replies were consumed in full, so the socket
// remains synchronized for the legacy discovery request.
return legacyRestoreSurfaceID(
client: client,
workspaceID: nil
)
default:
client.close()
throw error
}
} catch {
client.close()
throw error
}
}
private func relayRestoreSurfaceID(
client: SocketClient,
processEnvironment: [String: String]
) throws -> String? {
let ttyName = resolveCallerDescriptorTTYName()
?? resolveCallerTTYName(includeAmbientTTY: false)
guard let ttyName else { return nil }
let resolution = AgentTTYBindingResolution.reportedTTY.rawValue
let workspaceID = normalizedHandleValue(processEnvironment["CMUX_WORKSPACE_ID"])
var params: [String: Any] = [
"tty_name": ttyName,
"tty_resolution": resolution,
]
if let workspaceID {
// Lets an older app identify this probe as an unsupported
// workspace-only resolution. The authenticated relay rewrites
// aliases and separately stamps its authoritative owner id.
params["workspace_id"] = workspaceID
}
do {
let payload = try client.sendV2(
method: "agent.resolve_delivery_target",
params: params
)
if payload["source"] as? String == "workspace",
payload["surface_id"] == nil || payload["surface_id"] is NSNull,
let resolvedWorkspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(resolvedWorkspaceID) {
// Previous app versions ignore the TTY probe and resolve
// only workspace_id. Use their alias-rewritten result to
// scope the legacy terminal list, not the stale remote
// shell environment value that produced the request.
return legacyRestoreSurfaceID(
client: client,
workspaceID: resolvedWorkspaceID
)
}
guard payload["source"] as? String == "tty",
payload["tty_resolution"] as? String == resolution,
let resolvedWorkspaceID = normalizedHandleValue(payload["workspace_id"] as? String),
isUUID(resolvedWorkspaceID),
let surfaceID = normalizedHandleValue(payload["surface_id"] as? String),
isUUID(surfaceID) else {
throw currentRestoreSurfaceUnknownError()
}
return surfaceID
} catch let error as CLIError {
switch error.v2Code {
case "not_found":
client.close()
throw currentRestoreSurfaceUnknownError()
case "method_not_found", "unrecognized_method":
guard let workspaceID, isUUID(workspaceID) else { return nil }
return legacyRestoreSurfaceID(
client: client,
workspaceID: workspaceID
)
default:
client.close()
throw error
}
} catch {
client.close()
throw error
}
}
private func legacyRestoreSurfaceID(
client: SocketClient,
workspaceID: String?
) -> String? {
// Prefer the live descriptors. Generic TTY variables can be inherited
// across nested shells, so only dedicated cmux hints are a fallback.
let ttyName = resolveCallerDescriptorTTYName()
?? resolveCallerTTYName(includeAmbientTTY: false)
guard let ttyName,
let binding = uniqueCallerTerminalBindingByTTY(
ttyName: ttyName,
client: client,
workspaceId: workspaceID
) else {
return nil
}
return binding.surfaceId
}
private func currentRestoreSurfaceUnknownError() -> CLIError {
CLIError(
message: String(
localized: "cli.restore.error.currentSurfaceUnknown",
defaultValue: "restore: the current cmux surface could not be identified. Retry from this terminal or pass --surface <id|ref>."
)
)
}
private func restoreSelector(_ arguments: [String]) throws -> RestoreSelector {
if arguments.first == "--surface" {
if arguments.count == 1 {
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: nil,
checkpointID: nil
)
}
guard arguments.count == 2, !arguments[1].isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.surface",
defaultValue: "Usage: cmux restore --surface [id|ref]"
))
}
return RestoreSelector(
surface: arguments[1],
usesCurrentSurface: false,
kind: nil,
checkpointID: nil
)
}
guard arguments.count == 2,
!arguments[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!arguments[1].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.usage.positional",
defaultValue: "Usage: cmux restore <kind> <checkpoint-id>"
))
}
return RestoreSelector(
surface: nil,
usesCurrentSurface: true,
kind: arguments[0],
checkpointID: arguments[1]
)
}
private func restoreRecord(from object: [String: Any]) throws -> RestoreRecord {
guard let mode = object["mode"] as? String,
let kind = object["kind"] as? String else {
throw loggedRestoreError(
stage: "record.decode",
detail: "keys=\(object.keys.sorted().joined(separator: ","))",
message: String(
localized: "cli.restore.error.malformedRecord",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
let legacyCommand = object["legacy_command"] as? String
let launchCommand: AgentLaunchCommand?
do {
launchCommand = try restoreLaunchCommand(from: object["launch_command"])
} catch {
guard legacyCommand != nil else {
throw loggedRestoreError(
stage: "record.launch-command",
detail: String(reflecting: type(of: error)),
message: String(
localized: "cli.restore.error.malformedArguments",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
)
)
}
launchCommand = nil
}
return RestoreRecord(
mode: mode,
kind: kind,
checkpointID: object["checkpoint_id"] as? String,
source: object["source"] as? String,
workingDirectory: object["working_directory"] as? String,
environment: object["environment"] as? [String: String] ?? [:],
launchCommand: launchCommand,
preparedArguments: object["prepared_arguments"] as? [String],
preparedArgumentsWorkingDirectory:
object["prepared_arguments_working_directory"] as? String,
permissionMode: object["permission_mode"] as? String,
legacyCommand: legacyCommand
)
}
private func restoreLaunchCommand(from value: Any?) throws -> AgentLaunchCommand? {
guard let object = value as? [String: Any] else { return nil }
guard let arguments = object["arguments"] as? [String], !arguments.isEmpty else {
throw CLIError(message: String(
localized: "cli.restore.error.malformedArguments",
defaultValue: "restore: this session's saved restore data is not compatible. Start the agent again in this terminal."
))
}
return AgentLaunchCommand(
launcher: object["launcher"] as? String,
executablePath: object["executable_path"] as? String,
arguments: arguments,
workingDirectory: object["working_directory"] as? String,
environment: object["environment"] as? [String: String],
capturedAt: (object["captured_at"] as? NSNumber)?.doubleValue,
source: object["source"] as? String
)
}
}
+202
View File
@@ -0,0 +1,202 @@
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
@discardableResult
func applyRestoreWorkingDirectory(_ path: String?) throws -> String? {
guard let path = path?.trimmingCharacters(in: .whitespacesAndNewlines),
!path.isEmpty else {
return nil
}
if chdir(path) == 0 {
return path
}
let changeDirectoryError = errno
// Preserve the old guarded `cd`: a directory removed since capture
// falls back to the shell's current directory, while an existing but
// inaccessible path still blocks restore.
if changeDirectoryError == ENOENT || changeDirectoryError == ENOTDIR {
return nil
}
throw loggedRestoreError(
stage: "working-directory.change",
detail: path,
errorCode: changeDirectoryError,
message: String(
localized: "cli.restore.error.workingDirectoryFailed",
defaultValue: "restore: the saved working directory is inaccessible. Restore access to it, then retry."
)
)
}
func requestedRestoreWorkingDirectory(for record: RestoreRecord) -> String? {
normalizedRestoreWorkingDirectory(record.workingDirectory)
?? normalizedRestoreWorkingDirectory(record.launchCommand?.workingDirectory)
}
func normalizedRestoreWorkingDirectory(_ path: String?) -> String? {
let trimmed = path?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}
func execRestoreInvocation(
_ invocation: AgentRestoreInvocation,
appliedWorkingDirectory: String?
) throws {
var invocationEnvironment = invocation.environment
if let appliedWorkingDirectory {
invocationEnvironment["PWD"] = appliedWorkingDirectory
}
guard let first = invocation.arguments.first,
let executable = resolveRestoreExecutable(
first,
environment: invocationEnvironment
) else {
throw loggedRestoreError(
stage: "executable.resolve",
detail: invocation.arguments.first ?? "none",
message: String(
localized: "cli.restore.error.executableNotFound",
defaultValue: "restore: the saved agent command is unavailable. Make sure the agent is installed, then retry."
)
)
}
let executionError = withCStringArray(invocation.arguments) { argv in
withEnvironmentCStringArray(invocationEnvironment) { environment in
executable.withCString {
_ = execve($0, argv, environment)
return errno
}
}
}
throw loggedRestoreError(
stage: "executable.exec",
detail: executable,
errorCode: executionError,
message: String(
localized: "cli.restore.error.execveFailed",
defaultValue: "restore: the saved process could not be started. Retry the visible restore command."
)
)
}
func execLegacyRestoreRecord(
_ command: String,
record: RestoreRecord,
environment: [String: String],
client: SocketClient
) throws {
let appliedWorkingDirectory = try applyRestoreWorkingDirectory(
requestedRestoreWorkingDirectory(for: record)
)
var legacyEnvironment = environment
if let appliedWorkingDirectory {
legacyEnvironment["PWD"] = appliedWorkingDirectory
}
client.close()
try execLegacyRestoreCommand(command, environment: legacyEnvironment)
}
private func execLegacyRestoreCommand(
_ command: String,
environment: [String: String]
) throws {
let shell = restoreCompatibilityShell(environment: environment)
let arguments = [shell, "-lc", command]
let executionError = withCStringArray(arguments) { argv in
withEnvironmentCStringArray(environment) { childEnvironment in
shell.withCString {
_ = execve($0, argv, childEnvironment)
return errno
}
}
}
throw loggedRestoreError(
stage: "legacy-shell.exec",
detail: shell,
errorCode: executionError,
message: String(
localized: "cli.restore.error.compatibilityShellFailed",
defaultValue: "restore: the saved process could not be started. Retry the visible restore command."
)
)
}
private func restoreCompatibilityShell(environment: [String: String]) -> String {
if let shell = environment["SHELL"],
shell.hasPrefix("/"),
isExecutableRegularFile(atPath: shell) {
return shell
}
if let record = getpwuid(getuid()),
let shellPointer = record.pointee.pw_shell {
let shell = String(cString: shellPointer)
if isExecutableRegularFile(atPath: shell) {
return shell
}
}
return "/bin/sh"
}
func resolveRestoreExecutable(
_ executable: String,
environment: [String: String]
) -> String? {
if executable.contains("/") {
return isExecutableRegularFile(atPath: executable)
? executable
: nil
}
let path = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
// Shells treat an empty PATH component as the current directory. Restore
// may already be inside an untrusted project, so fail closed instead.
for directory in path.split(separator: ":") {
let root = String(directory)
let candidate = URL(fileURLWithPath: root, isDirectory: true)
.appendingPathComponent(executable, isDirectory: false)
.path
if isExecutableRegularFile(atPath: candidate) {
return candidate
}
}
return nil
}
private func isExecutableRegularFile(atPath path: String) -> Bool {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),
!isDirectory.boolValue else {
return false
}
return FileManager.default.isExecutableFile(atPath: path)
}
func withCStringArray<Result>(
_ strings: [String],
body: (UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Result
) -> Result {
var pointers = strings.map { strdup($0) }
pointers.append(nil)
defer {
for pointer in pointers where pointer != nil {
free(pointer)
}
}
return pointers.withUnsafeMutableBufferPointer {
body($0.baseAddress)
}
}
func withEnvironmentCStringArray<Result>(
_ environment: [String: String],
body: (UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>?) -> Result
) -> Result {
withCStringArray(
environment.keys.sorted().compactMap { key in
environment[key].map { "\(key)=\($0)" }
},
body: body
)
}
}
+23
View File
@@ -0,0 +1,23 @@
import Foundation
import OSLog
nonisolated private let restoreFailureLogger = Logger(
subsystem: "com.cmuxterm.cli",
category: "Restore"
)
extension CMUXCLI {
/// Records private restore diagnostics while returning a product-level error.
func loggedRestoreError(
stage: String,
detail: String = "none",
errorCode: Int32? = nil,
message: String
) -> CLIError {
let loggedErrorCode = errorCode.map { String($0) } ?? "none"
restoreFailureLogger.error(
"Restore failed stage=\(stage, privacy: .public) detail=\(detail, privacy: .private(mask: .hash)) errorCode=\(loggedErrorCode, privacy: .private(mask: .hash))"
)
return CLIError(message: message)
}
}
+278
View File
@@ -0,0 +1,278 @@
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
func runRestorePreflight(
_ invocation: AgentRestorePreflightInvocation,
appliedWorkingDirectory: String?
) throws {
var invocationEnvironment = invocation.environment
if let appliedWorkingDirectory {
invocationEnvironment["PWD"] = appliedWorkingDirectory
}
guard let executable = resolveRestoreExecutable(
invocation.executable,
environment: invocationEnvironment
) else {
throw loggedRestoreError(
stage: "provider.resolve",
detail: invocation.executable,
message: String(
localized: "cli.restore.error.providerSetupUnavailable",
defaultValue: "restore: provider setup is unavailable. Check the agent's provider settings, then retry."
)
)
}
var fileActions: posix_spawn_file_actions_t?
let actionsStatus = posix_spawn_file_actions_init(&fileActions)
guard actionsStatus == 0 else {
throw loggedRestoreError(
stage: "provider.file-actions",
errorCode: actionsStatus,
message: String(
localized: "cli.restore.error.providerSetupConfigurationFailed",
defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry."
)
)
}
defer { posix_spawn_file_actions_destroy(&fileActions) }
var redirectStatus = "/dev/null".withCString {
posix_spawn_file_actions_addopen(
&fileActions,
STDIN_FILENO,
$0,
O_RDONLY,
0
)
}
if redirectStatus == 0 {
redirectStatus = "/dev/null".withCString {
posix_spawn_file_actions_addopen(
&fileActions,
STDOUT_FILENO,
$0,
O_WRONLY,
0
)
}
}
if redirectStatus == 0 {
redirectStatus = "/dev/null".withCString {
posix_spawn_file_actions_addopen(
&fileActions,
STDERR_FILENO,
$0,
O_WRONLY,
0
)
}
}
guard redirectStatus == 0 else {
throw loggedRestoreError(
stage: "provider.redirect",
errorCode: redirectStatus,
message: String(
localized: "cli.restore.error.providerSetupConfigurationFailed",
defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry."
)
)
}
var processID: pid_t = 0
let status = withCStringArray(invocation.arguments) { argv in
withEnvironmentCStringArray(invocationEnvironment) { environment in
executable.withCString {
posix_spawn(
&processID,
$0,
&fileActions,
nil,
argv,
environment
)
}
}
}
guard status == 0 else {
throw loggedRestoreError(
stage: "provider.spawn",
detail: executable,
errorCode: status,
message: String(
localized: "cli.restore.error.providerSetupStartFailed",
defaultValue: "restore: provider setup could not start. Check the agent's provider settings, then retry."
)
)
}
try waitForRestorePreflight(processID)
}
private func waitForRestorePreflight(_ processID: pid_t) throws {
// This synchronous CLI is about to call `execve`; EVFILT_PROC provides
// signal-driven completion with a kernel-enforced deadline and no poll.
let exitQueue = try restorePreflightExitQueue(processID)
defer { close(exitQueue) }
guard try waitForRestorePreflightExit(
exitQueue,
timeout: 10
) else {
terminateRestorePreflight(processID, exitQueue: exitQueue)
throw loggedRestoreError(
stage: "provider.timeout",
detail: "pid=\(processID)",
message: String(
localized: "cli.restore.error.providerSetupTimedOut",
defaultValue: "restore: provider setup took too long. Check the provider connection, then retry."
)
)
}
let waitStatus = try reapRestorePreflight(processID)
let exitedNormally = waitStatus & 0x7f == 0
let exitStatus = (waitStatus >> 8) & 0xff
if exitedNormally {
guard exitStatus == 0 else {
throw loggedRestoreError(
stage: "provider.exit",
errorCode: exitStatus,
message: String(
localized: "cli.restore.error.providerSetupExited",
defaultValue: "restore: provider setup failed. Check the agent's provider settings, then retry."
)
)
}
return
}
let terminationSignal = waitStatus & 0x7f
throw loggedRestoreError(
stage: "provider.signal",
errorCode: terminationSignal,
message: String(
localized: "cli.restore.error.providerSetupSignaled",
defaultValue: "restore: provider setup failed. Check the agent's provider settings, then retry."
)
)
}
private func restorePreflightExitQueue(_ processID: pid_t) throws -> Int32 {
let queue = kqueue()
guard queue >= 0 else {
throw restorePreflightWaitError(
stage: "provider.wait-queue",
errorCode: errno
)
}
var event = kevent(
ident: UInt(processID),
filter: Int16(EVFILT_PROC),
flags: UInt16(EV_ADD | EV_ENABLE | EV_ONESHOT),
fflags: UInt32(NOTE_EXIT),
data: 0,
udata: nil
)
while kevent(queue, &event, 1, nil, 0, nil) != 0 {
if errno == EINTR {
continue
}
close(queue)
throw restorePreflightWaitError(
stage: "provider.wait-register",
errorCode: errno
)
}
return queue
}
private func waitForRestorePreflightExit(
_ queue: Int32,
timeout: TimeInterval
) throws -> Bool {
let deadline = ProcessInfo.processInfo.systemUptime + timeout
while true {
let remaining = deadline - ProcessInfo.processInfo.systemUptime
guard remaining > 0 else { return false }
var timeoutSpec = timespec(
tv_sec: Int(remaining),
tv_nsec: Int((remaining - floor(remaining)) * 1_000_000_000)
)
var triggeredEvent = kevent()
let result = kevent(queue, nil, 0, &triggeredEvent, 1, &timeoutSpec)
if result > 0 {
return true
}
if result == 0 {
return false
}
if errno != EINTR {
throw restorePreflightWaitError(
stage: "provider.wait-event",
errorCode: errno
)
}
}
}
private func terminateRestorePreflight(
_ processID: pid_t,
exitQueue: Int32
) {
_ = kill(processID, SIGTERM)
var observedExit = (try? waitForRestorePreflightExit(
exitQueue,
timeout: 0.25
)) == true
if !observedExit {
_ = kill(processID, SIGKILL)
observedExit = (try? waitForRestorePreflightExit(
exitQueue,
timeout: 1
)) == true
}
if observedExit {
_ = try? reapRestorePreflight(processID)
} else {
_ = try? reapRestorePreflight(processID, options: WNOHANG)
}
}
private func reapRestorePreflight(
_ processID: pid_t,
options: Int32 = 0
) throws -> Int32 {
var waitStatus: Int32 = 0
while true {
let waitResult = waitpid(processID, &waitStatus, options)
if waitResult == processID {
return waitStatus
}
if waitResult == 0, options & WNOHANG != 0 {
return waitStatus
}
if waitResult == -1 && errno == EINTR {
continue
}
throw restorePreflightWaitError(
stage: "provider.wait-reap",
errorCode: errno
)
}
}
private func restorePreflightWaitError(
stage: String,
errorCode: Int32
) -> CLIError {
loggedRestoreError(
stage: stage,
errorCode: errorCode,
message: String(
localized: "cli.restore.error.providerSetupWaitFailed",
defaultValue: "restore: provider setup could not complete. Retry the visible restore command."
)
)
}
}
+18
View File
@@ -0,0 +1,18 @@
import CMUXAgentLaunch
extension CMUXCLI {
/// The socket restore payload after validation and typed decoding.
struct RestoreRecord {
let mode: String
let kind: String
let checkpointID: String?
let source: String?
let workingDirectory: String?
let environment: [String: String]
let launchCommand: AgentLaunchCommand?
let preparedArguments: [String]?
let preparedArgumentsWorkingDirectory: String?
let permissionMode: String?
let legacyCommand: String?
}
}
+9
View File
@@ -0,0 +1,9 @@
extension CMUXCLI {
/// The surface identity constraints parsed from `cmux restore` arguments.
struct RestoreSelector {
let surface: String?
let usesCurrentSurface: Bool
let kind: String?
let checkpointID: String?
}
}
+70
View File
@@ -0,0 +1,70 @@
import CmuxFoundation
import Foundation
extension CMUXCLI {
struct SSHCommandOptions {
let destination: String
let displayDestination: String
let port: Int?
let identityFile: String?
let workspaceName: String?
let initialCommand: String?
let windowRaw: String?
let noFocus: Bool
var sshOptions: [String]
let extraArguments: [String]
let terminalTransport: WorkspaceRemoteTerminalTransport
let terminalProfile: WorkspaceRemoteTerminalProfile
let agentSocketPath: String?
let passwordCredential: String?
let localSocketPath: String
let remoteRelayPort: Int
let pinWorkspaceToTop: Bool
let daemonWebSocketEndpoint: VMDaemonWebSocketEndpoint?
/// True when the remote is a cloud VM with cmuxd-remote pre-baked in the image.
/// Set by `cmux vm new/shell/attach`; false for plain `cmux ssh`.
let skipDaemonBootstrap: Bool
init(
destination: String,
displayDestination: String? = nil,
port: Int?,
identityFile: String?,
workspaceName: String?,
initialCommand: String? = nil,
windowRaw: String? = nil,
noFocus: Bool,
sshOptions: [String],
extraArguments: [String],
terminalTransport: WorkspaceRemoteTerminalTransport = .ssh,
terminalProfile: WorkspaceRemoteTerminalProfile = .shell,
agentSocketPath: String? = nil,
passwordCredential: String? = nil,
localSocketPath: String,
remoteRelayPort: Int,
pinWorkspaceToTop: Bool = false,
daemonWebSocketEndpoint: VMDaemonWebSocketEndpoint? = nil,
skipDaemonBootstrap: Bool = false
) {
self.destination = destination
self.displayDestination = displayDestination ?? destination
self.port = port
self.identityFile = identityFile
self.workspaceName = workspaceName
self.initialCommand = initialCommand
self.windowRaw = windowRaw
self.noFocus = noFocus
self.sshOptions = sshOptions
self.extraArguments = extraArguments
self.terminalTransport = terminalTransport
self.terminalProfile = terminalProfile
self.agentSocketPath = agentSocketPath
self.passwordCredential = passwordCredential
self.localSocketPath = localSocketPath
self.remoteRelayPort = remoteRelayPort
self.pinWorkspaceToTop = pinWorkspaceToTop
self.daemonWebSocketEndpoint = daemonWebSocketEndpoint
self.skipDaemonBootstrap = skipDaemonBootstrap
}
}
}
+19 -3
View File
@@ -2,6 +2,22 @@ import CmuxFoundation
import Foundation
extension CMUXCLI {
/// Returns an option copy suitable for an SSH invocation where cmux
/// supplies the remote command. The caller's `RemoteCommand` remains in
/// durable workspace configuration, but must not precede cmux's own
/// `RemoteCommand=<bootstrap>` or duplicate a `RemoteCommand=none`
/// override on the actual argv.
internal func sshCommandOptionsWithoutRemoteCommand(
_ options: SSHCommandOptions
) -> SSHCommandOptions {
var sanitized = options
sanitized.sshOptions = SSHAgentSocketResolver().removingOptions(
named: "RemoteCommand",
from: options.sshOptions
)
return sanitized
}
/// Inserts `-o RemoteCommand=none` right after the `ssh` executable so a
/// host-configured (or caller-supplied) `RemoteCommand` cannot conflict
/// with the command-line remote command this invocation appends OpenSSH
@@ -10,10 +26,10 @@ extension CMUXCLI {
/// for invocations that pass their own command; the interactive session
/// hop keeps its explicit `-o RemoteCommand=<bootstrap>`.
internal func sshArgumentsOverridingHostRemoteCommand(_ arguments: [String]) -> [String] {
guard arguments.first == "ssh" else {
return SSHHostConfiguredRemoteCommand().overrideArguments + arguments
guard let executable = arguments.first else {
return SSHHostConfiguredRemoteCommand().overrideArguments
}
return [arguments[0]] + SSHHostConfiguredRemoteCommand().overrideArguments + arguments.dropFirst()
return [executable] + SSHHostConfiguredRemoteCommand().overrideArguments + arguments.dropFirst()
}
internal func openSSHLocalCommandValue(shellScript: String?) -> String? {
+77
View File
@@ -0,0 +1,77 @@
import CmuxFoundation
import Foundation
extension CMUXCLI {
func resolvedUserSSHControlOptions(for options: SSHCommandOptions) -> [String]? {
guard let output = resolvedSSHConfigurationOutput(for: options) else { return nil }
return SSHConnectionSharingOptions()
.userConfiguredControlOptions(fromSSHConfigOutput: output)
}
func resolvedCmuxControlPathOptions(for options: SSHCommandOptions) -> [String] {
let sharingOptions = SSHConnectionSharingOptions()
guard let configuredPath = sharingOptions.cmuxOwnedControlPath(in: options.sshOptions),
configuredPath.contains("%"),
let output = resolvedSSHConfigurationOutput(for: options),
let resolvedPath = sshConfigurationValue(named: "controlpath", in: output) else {
return options.sshOptions
}
let validationOptions = ["ControlMaster=auto", "ControlPath=\(resolvedPath)"]
guard sharingOptions.cmuxOwnedControlPath(in: validationOptions) == resolvedPath else {
return options.sshOptions
}
let resolver = SSHAgentSocketResolver()
return options.sshOptions.map { option in
resolver.optionKey(option) == "controlpath"
? "ControlPath=\(resolvedPath)"
: option
}
}
func resolvedSSHConfigurationOutput(
for options: SSHCommandOptions,
timeout: TimeInterval = 2
) -> String? {
let result = resolvedSSHConfigurationResult(for: options, timeout: timeout)
return result.status == 0 ? result.stdout : nil
}
func resolvedSSHConfigurationResult(
for options: SSHCommandOptions,
timeout: TimeInterval = 2
) -> CLIProcessResult {
var arguments = ["-G"]
if let port = options.port {
arguments += ["-p", String(port)]
}
if let rawIdentityFile = options.identityFile {
let trimmedIdentityFile = rawIdentityFile.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentityFile.isEmpty {
let identityFile = trimmedIdentityFile.hasPrefix("~")
? (trimmedIdentityFile as NSString).expandingTildeInPath
: trimmedIdentityFile
arguments += ["-i", identityFile]
}
}
for option in options.sshOptions {
arguments += ["-o", option]
}
arguments.append(options.destination)
return CLIProcessRunner.runProcess(
executablePath: "/usr/bin/ssh",
arguments: arguments,
timeout: timeout
)
}
func sshConfigurationValue(named name: String, in output: String) -> String? {
let loweredName = name.lowercased()
for line in output.split(whereSeparator: \.isNewline) {
let parts = line.split(maxSplits: 1, whereSeparator: \.isWhitespace)
guard parts.count == 2, parts[0].lowercased() == loweredName else { continue }
let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
return nil
}
}
+90
View File
@@ -0,0 +1,90 @@
import Foundation
extension CMUXCLI {
static var sshCommandUsage: String {
let help = String(localized: "cli.help.ssh", defaultValue: """
Usage: cmux ssh <destination> [flags] [-- <remote-command-args>]
Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace.
cmux will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host.
Flags:
--name <title> Optional workspace title
--port <n> SSH port
--identity <path> SSH identity file path
-A, --forward-agent Forward the caller's SSH agent; also honors ForwardAgent yes from ssh_config
-a, --no-forward-agent Disable SSH agent forwarding for this workspace
--ssh-option <opt> Extra SSH -o option (repeatable)
--window <id|ref|index> Target window for the managed workspace
--no-focus Create workspace without switching to it
Example:
cmux ssh dev@my-host
cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519
cmux ssh dev@my-host --forward-agent
cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no
""")
let moshHelp = String(
localized: "cli.help.ssh.mosh",
defaultValue: """
Mosh terminal transport:
--transport <ssh|mosh> Interactive terminal transport (default: ssh)
SSH continues to handle remote features; Mosh carries only the interactive
terminal. If Mosh is missing locally or remotely, cmux reports it and uses SSH.
Example:
cmux ssh dev@my-host --transport mosh
"""
)
let initialCommandHelp = String(
localized: "cli.help.ssh.initialCommand",
defaultValue: """
Initial command:
--command <text> Run text once in the initial remote terminal after shell startup
Example:
cmux ssh dev@my-host --command 'omp "investigate auth"'
"""
)
return "\(help)\n\n\(initialCommandHelp)\n\n\(moshHelp)"
}
static var moshCommandUsage: String {
String(localized: "cli.help.mosh", defaultValue: """
Usage: cmux mosh <destination> [flags] [-- <remote-command-args>]
Create a first-class remote workspace with Mosh as the interactive terminal
transport. SSH remains the management lane for remote metadata, daemon control,
proxy/egress, uploads, cwd/git integration, and reconnect actions.
Accepts the same workspace and SSH bootstrap flags as `cmux ssh`. If Mosh is
unavailable locally or remotely, cmux explains why and falls back to SSH.
Example:
cmux mosh dev@my-host
""")
}
static var moshTmuxCommandUsage: String {
String(localized: "cli.help.mosh-tmux", defaultValue: """
Usage: cmux mosh-tmux <destination> [--session <name>] [flags]
Create a first-class remote workspace whose Mosh terminal creates or attaches
to a named tmux session (default: main). The tmux profile persists across cmux
workspace reconnect and app session restore.
This is a terminal-attached tmux session that roams with Mosh. It is distinct
from `cmux ssh-tmux`, which uses SSH and tmux control mode to mirror sessions,
windows, and panes as native cmux workspaces, tabs, and splits.
`--session <name>` selects the tmux session. All other workspace and SSH
bootstrap flags match `cmux mosh`. If Mosh is unavailable, cmux runs the
same managed tmux profile over SSH.
Example:
cmux mosh-tmux dev@my-host
cmux mosh-tmux dev@my-host --session agent-main
""")
}
}
+62 -6
View File
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import CmuxFoundation
extension CLIError {
init(message: String, exitCode: SSHPTYAttachExitCode) {
@@ -8,7 +9,7 @@ extension CLIError {
}
extension CMUXCLI {
/// True when a persistent attach wrapper owns retrying a 254|255 failure.
/// True when a persistent attach wrapper has another general retry available.
/// Persistent wrappers export `CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY=1`;
/// direct invocations leave it unset, so failures there always clean up.
func sshPTYAttachWrapperRetryPending() -> Bool {
@@ -16,6 +17,42 @@ extension CMUXCLI {
.trimmingCharacters(in: .whitespacesAndNewlines) == "1"
}
func sshPTYAttachWrapperWillRetry(_ exitCode: SSHPTYAttachExitCode) -> Bool {
guard sshPTYAttachWrapperRetryPending() else { return false }
if exitCode == .bridgeClosedWithoutProgress {
let environment = ProcessInfo.processInfo.environment
guard let retry = Int(environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_RETRY"] ?? ""),
retry >= 0,
let limit = Int(environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_LIMIT"] ?? ""),
limit > 0 else {
return false
}
return SSHPTYAttachExitCode.hasNoProgressRetryRemaining(
currentRetry: retry,
limit: limit
)
}
return exitCode.isWrapperRetryable
}
func sshPTYAttachBridgeClosedExitCode(
receivedLiveOutput: Bool,
readyUptime: TimeInterval
) -> SSHPTYAttachExitCode {
let environment = ProcessInfo.processInfo.environment
let bridgeUptime = ProcessInfo.processInfo.systemUptime - readyUptime
guard sshPTYAttachWrapperRetryPending(),
environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_RETRY"] != nil,
environment["CMUX_SSH_PTY_ATTACH_NO_PROGRESS_LIMIT"] != nil,
SSHPTYAttachExitCode.bridgeClosureMadeNoProgress(
receivedLiveOutput: receivedLiveOutput,
bridgeUptime: bridgeUptime
) else {
return .bridgeClosedSessionRunning
}
return .bridgeClosedWithoutProgress
}
func cleanupFailedSSHPTYAttach(
client: SocketClient,
workspaceId: String,
@@ -93,7 +130,8 @@ extension CMUXCLI {
surfaceID: String?,
sessionID: String,
lifecycleID: String,
intentionalOnly: Bool
intentionalOnly: Bool,
sessionRunningExitCode: SSHPTYAttachExitCode = .bridgeClosedSessionRunning
) throws -> Bool {
let reconciliationFailure = "ssh-pty-attach: bridge closed before remote PTY exit could be confirmed"
let response: [String: Any]
@@ -144,9 +182,18 @@ extension CMUXCLI {
(($0["session_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") == sessionID
}
if !intentionalCleanup, sessionStillRunning {
let message: String
if sessionRunningExitCode == .bridgeClosedWithoutProgress {
message = String(
localized: "cli.sshPtyAttach.bridgeClosedWithoutProgress",
defaultValue: "ssh-pty-attach: bridge closed without receiving new output while the remote PTY session is still running"
)
} else {
message = "ssh-pty-attach: bridge closed while remote PTY session is still running"
}
throw CLIError(
message: "ssh-pty-attach: bridge closed while remote PTY session is still running",
exitCode: SSHPTYAttachExitCode.bridgeClosedSessionRunning
message: message,
exitCode: sessionRunningExitCode
)
}
guard let surfaceID else { return true }
@@ -165,7 +212,7 @@ extension CMUXCLI {
return true
}
func readSSHPTYBridgeReady(fd: Int32) throws -> String {
func readSSHPTYBridgeReady(fd: Int32) throws -> (attachmentToken: String, replayBytes: Int) {
let maxStatusBytes = 4096
// Bound only the pre-ready status wait: a bridge that accepts the TCP
// connection and then goes silent must not hang the attach (and its
@@ -189,8 +236,12 @@ extension CMUXCLI {
}
switch type {
case "ready":
return ((payload["attachment_token"] as? String)?
let attachmentToken = ((payload["attachment_token"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)) ?? ""
return (
attachmentToken: attachmentToken,
replayBytes: sshPTYBridgeReplayByteCount(payload["replay_bytes"])
)
case "error":
let message = ((payload["message"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 }
@@ -224,6 +275,11 @@ extension CMUXCLI {
throw CLIError(message: "ssh-pty-attach: bridge status exceeded \(maxStatusBytes) bytes")
}
private func sshPTYBridgeReplayByteCount(_ value: Any?) -> Int {
guard let count = value as? Int, count >= 0 else { return 0 }
return count
}
/// Ceiling for the bridge ready/error status wait. Defaults to 185s,
/// matching the `wait_for_ready` RPC response timeout in `runSSHPTYAttach`.
private func sshPTYBridgeReadyTimeoutSeconds() -> TimeInterval {
+60 -24
View File
@@ -1,3 +1,4 @@
import CmuxFoundation
import Foundation
extension CMUXCLI {
@@ -289,6 +290,9 @@ extension CMUXCLI {
? ""
: "export GHOSTTY_SHELL_FEATURES=\(shellQuote(trimmedFeatures))"
let lifecycleCleanup = buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort)
let lifecycleLaunching = remoteRelayPort > 0
? buildSSHTerminalSessionLaunchingShellCommand()
: ":"
let lifecycleRetirement = retryPTYAttachStatus
? buildSSHSessionEndShellCommand(remoteRelayPort: remoteRelayPort, lifecycleOnly: true)
: ":"
@@ -296,6 +300,8 @@ extension CMUXCLI {
.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedOneTimeCommand = oneTimeCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
let hasOneTimeCommand = trimmedOneTimeCommand?.isEmpty == false
let authRetryPolicy = SSHForegroundAuthenticationRetryPolicy()
let backoffBuilder = SSHRetryBackoffScriptBuilder(context: .startup)
var scriptLines: [String] = []
if !shellFeaturesBootstrap.isEmpty {
scriptLines.append(shellFeaturesBootstrap)
@@ -330,12 +336,8 @@ extension CMUXCLI {
scriptLines.append(trimmedControlPathPreflight)
}
if let trimmedOneTimeCommand, !trimmedOneTimeCommand.isEmpty {
scriptLines.append("trap 'cmux_ssh_cleanup_password' EXIT")
scriptLines += ["cmux_ssh_foreground_auth() {", trimmedOneTimeCommand, "}"]
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty {
scriptLines.append("cmux_ssh_preflight_control_path")
}
scriptLines += ["( cmux_ssh_foreground_auth )", "cmux_ssh_auth_status=$?", "if [ \"$cmux_ssh_auth_status\" -ne 0 ]; then exit \"$cmux_ssh_auth_status\"; fi", "trap - EXIT"]
scriptLines.append(authRetryPolicy.processTreeTerminationShellFunction())
}
let reconnectConfiguration = retryPTYAttachStatus ? [
"cmux_ssh_reconnect_limit=\"${CMUX_SSH_RECONNECT_LIMIT:-}\"",
@@ -360,40 +362,46 @@ extension CMUXCLI {
"export CMUX_SSH_STARTUP_PID",
] + reconnectConfiguration + [
"cmux_ssh_retry=0",
"cmux_ssh_reauth_required=0",
"CMUX_SSH_CHILD_PID=",
"CMUX_SSH_PENDING_SIGNAL=",
"cmux_ssh_auth_retry_limit=\(authRetryPolicy.maximumConsecutiveTransientFailures); cmux_ssh_auth_retry=0",
// Initial transient foreground-auth failures are a reconnect phase, so boot-time outages share this loop.
"cmux_ssh_reauth_required=\(hasOneTimeCommand ? 1 : 0)",
"CMUX_SSH_CHILD_PID=; CMUX_SSH_AUTH_PID=; CMUX_SSH_PENDING_SIGNAL=; CMUX_SSH_PENDING_SIGNAL_NAME=",
] + backoffBuilder.stateInitializationLines + [
"cmux_ssh_note() { if [ -t 2 ]; then printf \"$@\" >&2 || true; fi; }",
"cmux_ssh_register_attempt() { \(lifecycleLaunching); }",
"cmux_ssh_begin_attempt() { CMUX_SSH_ATTEMPT_ID=$(/usr/bin/uuidgen | /usr/bin/tr '[:upper:]' '[:lower:]') || return 1; export CMUX_SSH_ATTEMPT_ID; cmux_ssh_attempt_registration_retry=0; while ! cmux_ssh_register_attempt; do cmux_ssh_attempt_registration_retry=$((cmux_ssh_attempt_registration_retry + 1)); if [ \"$cmux_ssh_attempt_registration_retry\" -ge 3 ]; then return 1; fi; /bin/sleep 0.1; done; }",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleCleanup); }",
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; if [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
"cmux_ssh_retire_for_signal() { cmux_ssh_signal_status=\"$1\"; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleRetirement); trap - EXIT HUP INT TERM; exit \"$cmux_ssh_signal_status\"; }",
"cmux_ssh_signal_exit() { cmux_ssh_signal_status=\"$1\"; cmux_ssh_signal_name=\"$2\"; if [ -n \"${CMUX_SSH_AUTH_PID:-}\" ]; then cmux_ssh_terminate_auth_process_tree \"$CMUX_SSH_AUTH_PID\" \"$CMUX_SSH_STARTUP_PID\"; wait \"$CMUX_SSH_AUTH_PID\" 2>/dev/null || true; CMUX_SSH_AUTH_PID=; \(backoffBuilder.signalHandlerBranches) elif [ -z \"${CMUX_SSH_CHILD_PID:-}\" ]; then CMUX_SSH_PENDING_SIGNAL=\"$cmux_ssh_signal_status\"; CMUX_SSH_PENDING_SIGNAL_NAME=\"$cmux_ssh_signal_name\"; return; fi; cmux_ssh_retire_for_signal \"$cmux_ssh_signal_status\"; }",
"trap 'cmux_ssh_session_end' EXIT",
"trap 'cmux_ssh_signal_exit 129' HUP",
"trap 'cmux_ssh_signal_exit 130' INT",
"trap 'cmux_ssh_signal_exit 143' TERM",
"trap 'cmux_ssh_signal_exit 129 HUP' HUP",
"trap 'cmux_ssh_signal_exit 130 INT' INT",
"trap 'cmux_ssh_signal_exit 143 TERM' TERM",
"while :; do",
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_retire_for_signal \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
]
if hasOneTimeCommand {
scriptLines.append(" if [ \"$cmux_ssh_reauth_required\" -eq 1 ]; then")
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty {
scriptLines.append(" cmux_ssh_preflight_control_path")
}
scriptLines += [" ( cmux_ssh_foreground_auth )", " cmux_ssh_status=$?", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; elif [ \"$cmux_ssh_status\" -ne 255 ]; then break; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
scriptLines += [" ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; cmux_ssh_auth_retry=0; else case \"$cmux_ssh_status\" in 254) cmux_ssh_auth_retry=$((cmux_ssh_auth_retry + 1)); if [ \"$cmux_ssh_auth_retry\" -ge \"$cmux_ssh_auth_retry_limit\" ]; then cmux_ssh_status=255; break; fi ;; \(authRetryPolicy.unclassifiedFailureExitStatus)) cmux_ssh_status=255; break ;; *) break ;; esac; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
}
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty,
!hasOneTimeCommand {
scriptLines.append(" cmux_ssh_preflight_control_path")
}
if retryPTYAttachStatus {
// Advertise per attempt whether another 254|255 retry is queued so
// Advertise per attempt whether another 251|254|255 retry is queued so
// ssh-pty-attach only suppresses its pty_attach_end cleanup while a
// retry is actually pending; see CMUXCLI.sshPTYAttachWrapperRetryPending
// and keep in sync with CMUXCLI.sshPTYAttachRetryLoopLines /
// SSHPTYAttachStartupCommandBuilder.retryingAttachLines.
// and SSHPTYAttachRetryScriptBuilder.
scriptLines += [
" if [ \"$cmux_ssh_reconnect_unbounded\" -eq 1 ] || [ \"$cmux_ssh_retry\" -lt \"$cmux_ssh_reconnect_limit\" ]; then CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY=1; else CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY=0; fi",
" export CMUX_SSH_PTY_ATTACH_WRAPPER_CAN_RETRY",
]
}
scriptLines += [
" cmux_ssh_begin_attempt || exit 1",
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_retire_for_signal \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
]
if isShellSnippet {
scriptLines += [
" (",
@@ -403,7 +411,7 @@ extension CMUXCLI {
} else {
scriptLines.append(" command \(sshCommand) <&0 &")
}
let retryableStatusPattern = retryPTYAttachStatus ? "254|255" : "255"
let retryableStatusPattern = retryPTYAttachStatus ? "251|254|255" : "255"
scriptLines += [
" CMUX_SSH_CHILD_PID=$!",
" if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi",
@@ -414,7 +422,10 @@ extension CMUXCLI {
" case \"$cmux_ssh_status\" in \(retryableStatusPattern)) ;; *) break ;; esac",
]
if retryPTYAttachStatus {
scriptLines.append(" if [ \"$cmux_ssh_status\" -eq 254 ]; then cmux_ssh_reconnect_delay=\"$cmux_ssh_reconnect_initial_delay\"; fi")
let establishedBridgeFailed = hasOneTimeCommand
? "[ \"$cmux_ssh_status\" -eq 254 ] && [ \"$cmux_ssh_reauth_required\" -eq 0 ]"
: "[ \"$cmux_ssh_status\" -eq 254 ]"
scriptLines.append(" if \(establishedBridgeFailed); then cmux_ssh_reconnect_delay=\"$cmux_ssh_reconnect_initial_delay\"; fi")
}
if hasOneTimeCommand {
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
if retryPTYAttachStatus {
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")
}
@@ -488,11 +499,36 @@ extension CMUXCLI {
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"\"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --terminal-lifecycle-id \"${CMUX_TERMINAL_LIFECYCLE_ID:-}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"elif command -v cmux >/dev/null 2>&1",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"cmux ssh-session-end --relay-port \(remoteRelayPort) --workspace \"${CMUX_WORKSPACE_ID}\" --surface \"${CMUX_SURFACE_ID}\" --terminal-lifecycle-id \"${CMUX_TERMINAL_LIFECYCLE_ID:-}\" --session-id \"${CMUX_SSH_PTY_SESSION_ID:-}\" --lifecycle-id \"${CMUX_SSH_PTY_LIFECYCLE_ID:-}\"\(lifecycleOnlyFlag) >/dev/null 2>&1 || true;",
"fi",
].joined(separator: " ")
}
private func buildSSHTerminalSessionLaunchingShellCommand() -> String {
let arguments =
"rpc workspace.remote.terminal_session_launching " +
"\"{\\\"workspace_id\\\":\\\"${CMUX_WORKSPACE_ID}\\\"," +
"\\\"surface_id\\\":\\\"${CMUX_SURFACE_ID}\\\"," +
"\\\"terminal_lifecycle_id\\\":\\\"${CMUX_TERMINAL_LIFECYCLE_ID}\\\"," +
"\\\"attempt_id\\\":\\\"${CMUX_SSH_ATTEMPT_ID}\\\"}\""
return [
"if [ -n \"${CMUX_BUNDLED_CLI_PATH:-}\" ]",
"&& [ -x \"${CMUX_BUNDLED_CLI_PATH}\" ]",
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=2 \"${CMUX_BUNDLED_CLI_PATH}\" --socket \"${CMUX_SOCKET_PATH}\" \(arguments) >/dev/null 2>&1;",
"elif command -v cmux >/dev/null 2>&1",
"&& [ -n \"${CMUX_SOCKET_PATH:-}\" ]",
"&& [ -n \"${CMUX_WORKSPACE_ID:-}\" ]",
"&& [ -n \"${CMUX_SURFACE_ID:-}\" ]; then",
"CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC=2 cmux --socket \"${CMUX_SOCKET_PATH}\" \(arguments) >/dev/null 2>&1;",
"else",
"false;",
"fi",
].joined(separator: " ")
}
+744
View File
@@ -0,0 +1,744 @@
import CmuxSimulator
import Foundation
extension CMUXCLI {
private static let simulatorTextLimit = 4_096
private static let simulatorInspectorLimit = 1_024 * 1_024
private static let iosScreenshotBatchLimit = 8
private static let iosScreenshotBatchTimeout: TimeInterval = 600
var simulatorCommandUsageLine: String {
String(
localized: "cli.help.simulator",
defaultValue: "simulator <subcommand> [args] [--surface <id|ref|index>]"
)
}
var iosCommandUsageLine: String {
String(
localized: "cli.help.ios",
defaultValue: "ios <subcommand> [args] [--surface <id|ref|index>]"
)
}
struct SimulatorArguments {
var surface: String?
var readsStandardInput = false
var file: String?
var optionValue: String?
var positionals: [String] = []
}
func simulatorSubcommandUsage() -> String {
let usage = String(
localized: "cli.simulator.usage",
defaultValue: """
Usage: cmux simulator <subcommand> [args] [--surface <id|ref|index>]
Subcommands:
type [text] [--stdin|--file <path>] Type text and wait for transmission completion
tap <x> <y> [x2 y2] Send a correlated one- or two-finger tap
gesture <json> [--stdin|--file] Send 1...256 ordered normalized touch events
multitouch <json> [--stdin|--file] Send ordered two-finger touch events
swipe <x1> <y1> <x2> <y2> [steps] Send a sampled swipe
button <name> Press a Simulator hardware button
rotate <orientation> Rotate to a logical orientation
ca <diagnostic> <on|off> Toggle a Core Animation diagnostic
memory-warning Simulate a memory warning
event-log [limit] Print recent Simulator events
tools <show|hide|toggle> Control the Simulator tools inspector
camera <configure|switch|mirror|status> ...
permissions <list|grant|revoke|reset> ...
ui [status|get|set] [option] [value]
targets Refresh and print Web Inspector targets
attach <target-id> Attach the native Web Inspector session
send [json] [--stdin|--file <path>] Send a raw JSON inspector command
highlight <on|off> Highlight the attached page
release Release the attached page
Each command waits for its correlated Simulator-worker result. `send`
prints the raw response carrying the same JSON request id.
"""
)
let inspection = String(
localized: "cli.simulator.usage.inspection",
defaultValue: """
Additional inspection commands:
accessibility Print the bounded native accessibility tree
foreground Print the foreground application
"""
)
return "\(usage)\n\n\(inspection)"
}
func iosSubcommandUsage() -> String {
String(
localized: "cli.ios.usage",
defaultValue: """
Usage: cmux ios <subcommand> [args] [--surface <ref>]
Every native `cmux simulator` subcommand is accepted unchanged.
Additional subcommands:
list [--workspace <ref>] List Simulator panes and device identifiers
context [--udid] Print one selected Simulator identity
select <device-udid> Bind a pane to an iPhone or iPad Simulator
screenshot [--out <path>] Capture one or up to 8 selected Simulators
Examples:
cmux ios list --json
cmux ios screenshot --surface surface:2 --out phone.png
cmux ios screenshot --all --out screenshots/
cmux ios rotate landscape-left
"""
)
}
func runIOSNamespace(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
guard let subcommand = commandArgs.first?.lowercased() else {
throw CLIError(message: iosSubcommandUsage())
}
switch subcommand {
case "list":
var values = Array(commandArgs.dropFirst())
let workspace = try removeIOSOption("--workspace", from: &values)
guard values.isEmpty else { throw CLIError(message: iosSubcommandUsage()) }
let targets = try iosTargetPayloads(
workspace: workspace, client: client, windowOverride: windowOverride
)
if jsonOutput {
print(jsonString(formatIDs(["targets": targets], mode: idFormat)))
} else {
printIOSTargets(targets)
}
case "context":
var values = Array(commandArgs.dropFirst())
let udidOnly = values.contains("--udid")
values.removeAll { $0 == "--udid" }
let surface = try removeIOSSurfaceOption(from: &values)
guard values.isEmpty else { throw CLIError(message: iosSubcommandUsage()) }
let payload = try iosContextPayload(
surface: surface, client: client, windowOverride: windowOverride
)
if udidOnly {
guard let simulatorID = payload["simulator_id"] as? String else {
throw missingIOSSimulatorIdentifier()
}
print(simulatorID)
} else if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
printIOSContext(payload)
}
case "screenshot":
try runIOSScreenshot(
commandArgs: Array(commandArgs.dropFirst()),
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
default:
try runSimulatorNamespace(
commandArgs: commandArgs, client: client, jsonOutput: jsonOutput,
idFormat: idFormat, windowOverride: windowOverride
)
}
}
private func iosTargetPayloads(
workspace: String?, client: SocketClient, windowOverride: String?
) throws -> [[String: Any]] {
let window = try normalizeWindowHandle(windowOverride, client: client)
let requestedWorkspace = workspace ?? (window == nil
? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"]
: nil)
let workspaceID: String
if let requestedWorkspace,
let normalized = try normalizeWorkspaceHandle(
requestedWorkspace, client: client, windowHandle: window
) {
workspaceID = normalized
} else {
var params: [String: Any] = [:]
if let window { params["window_id"] = window }
let current = try client.sendV2(method: "workspace.current", params: params)
guard let resolved = (current["workspace_id"] as? String)
?? (current["workspace_ref"] as? String) else {
throw CLIError(message: iosSubcommandUsage())
}
workspaceID = resolved
}
var listParams: [String: Any] = ["workspace_id": workspaceID]
if let window { listParams["window_id"] = window }
let listed = try client.sendV2(method: "surface.list", params: listParams)
let surfaces = (listed["surfaces"] as? [[String: Any]] ?? []).filter {
($0["type"] as? String) == "simulator"
}
return try surfaces.map { surface in
guard let handle = (surface["id"] as? String) ?? (surface["ref"] as? String) else {
throw CLIError(message: iosSubcommandUsage())
}
var target: [String: Any] = [
"surface_id": surface["id"] ?? NSNull(),
"surface_ref": surface["ref"] ?? handle,
"simulator_id": surface["simulator_id"] ?? NSNull(),
"runtime_id": surface["runtime_id"] ?? NSNull(),
"device_type_id": surface["device_type_id"] ?? NSNull(),
"device_name": surface["device_name"] ?? NSNull(),
"state": surface["state"] ?? NSNull(),
]
target["workspace_id"] = listed["workspace_id"] ?? workspaceID
target["workspace_ref"] = listed["workspace_ref"] ?? NSNull()
return target
}
}
private func runIOSScreenshot(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
var values = commandArgs
let surfaces = try removeIOSOptions("--surface", from: &values)
let workspace = try removeIOSOption("--workspace", from: &values)
let output = try removeIOSOption("--out", from: &values)
let all = values.contains("--all")
values.removeAll { $0 == "--all" }
guard values.isEmpty, all || surfaces.count <= 1 else {
throw CLIError(message: iosSubcommandUsage())
}
var targets: [[String: Any]]
let targetsRequireContextResolution: Bool
if all {
guard surfaces.isEmpty else { throw CLIError(message: iosSubcommandUsage()) }
targets = try iosTargetPayloads(
workspace: workspace, client: client, windowOverride: windowOverride
)
targetsRequireContextResolution = true
} else if let surface = surfaces.first {
guard workspace == nil else { throw CLIError(message: iosSubcommandUsage()) }
targets = [try iosScreenshotContextPayload(
surface: surface, client: client, windowOverride: windowOverride
)]
targetsRequireContextResolution = false
} else if workspace == nil {
targets = [try iosScreenshotContextPayload(
surface: nil, client: client, windowOverride: windowOverride
)]
targetsRequireContextResolution = false
} else {
let candidates = try iosTargetPayloads(
workspace: workspace, client: client, windowOverride: windowOverride
)
guard candidates.count == 1 else {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.ios.error.ambiguousTargets",
defaultValue: "Found %lld iOS Simulator panes; pass --surface <ref> or --all"
), candidates.count
))
}
targets = candidates
targetsRequireContextResolution = true
}
guard !targets.isEmpty else {
throw CLIError(message: String(
localized: "cli.ios.error.noTargets",
defaultValue: "No matching iOS Simulator panes were found"
))
}
if all, targets.count > Self.iosScreenshotBatchLimit {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.ios.error.screenshotBatchLimit",
defaultValue: "Found %lld iOS Simulator panes; screenshot --all supports at most 8"
),
targets.count
))
}
let batchDeadline = all
? ProcessInfo.processInfo.systemUptime + Self.iosScreenshotBatchTimeout
: nil
if targetsRequireContextResolution {
var resolvedTargets: [[String: Any]] = []
resolvedTargets.reserveCapacity(targets.count)
for target in targets {
guard let surfaceRef = target["surface_ref"] as? String else {
throw missingIOSSurfaceReference()
}
if let batchDeadline,
ProcessInfo.processInfo.systemUptime >= batchDeadline {
var failedTarget = target
failedTarget["error"] = iosScreenshotBatchTimeoutMessage()
resolvedTargets.append(failedTarget)
continue
}
do {
resolvedTargets.append(try iosScreenshotContextPayload(
surface: surfaceRef,
client: client,
windowOverride: windowOverride,
responseTimeout: batchDeadline.map {
max(1, $0 - ProcessInfo.processInfo.systemUptime)
}
))
} catch {
guard all else { throw error }
var failedTarget = target
failedTarget["error"] = (error as? CLIError)?.message ?? error.localizedDescription
resolvedTargets.append(failedTarget)
}
}
targets = resolvedTargets
}
let outputURL = output.map { URL(fileURLWithPath: $0).standardizedFileURL }
if let outputURL {
var isDirectory: ObjCBool = false
let outputExists = FileManager.default.fileExists(
atPath: outputURL.path,
isDirectory: &isDirectory
)
if all {
guard outputExists, isDirectory.boolValue else {
throw CLIError(message: String(
localized: "cli.ios.error.outputDirectoryRequired",
defaultValue: "--out must name an existing directory when capturing multiple Simulators"
))
}
} else if targets.count == 1, outputExists, isDirectory.boolValue {
throw CLIError(message: String(
localized: "cli.ios.error.outputFileRequired",
defaultValue: "--out must name a file when capturing one Simulator"
))
} else if targets.count != 1, !(outputExists && isDirectory.boolValue) {
throw CLIError(message: String(
localized: "cli.ios.error.outputDirectoryRequired",
defaultValue: "--out must name an existing directory when capturing multiple Simulators"
))
}
}
let captures = try targets.map { target -> [String: Any] in
if all, let error = target["error"] as? String {
return [
"surface_ref": target["surface_ref"] ?? NSNull(),
"error": error,
]
}
guard let simulatorID = target["simulator_id"] as? String else {
if all {
return [
"surface_ref": target["surface_ref"] ?? NSNull(),
"error": target["error"] ?? missingIOSSimulatorIdentifier().message,
]
}
throw missingIOSSimulatorIdentifier()
}
guard let surfaceRef = target["surface_ref"] as? String else {
if all {
return [
"surface_ref": NSNull(),
"error": missingIOSSurfaceReference().message,
]
}
throw missingIOSSurfaceReference()
}
let destination: URL
if let outputURL, !all, targets.count == 1 {
destination = outputURL
} else {
let directory = outputURL ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let safeRef = surfaceRef.replacingOccurrences(of: ":", with: "-")
destination = directory.appendingPathComponent("ios-\(safeRef).png")
}
if let batchDeadline,
ProcessInfo.processInfo.systemUptime >= batchDeadline {
return [
"simulator_id": simulatorID,
"surface_ref": surfaceRef,
"error": iosScreenshotBatchTimeoutMessage(),
]
}
let result = runSimulatorOwnedCommandSynchronously(
executable: "/usr/bin/xcrun",
arguments: ["simctl", "io", simulatorID, "screenshot", destination.path],
currentDirectory: FileManager.default.currentDirectoryPath,
timeout: batchDeadline.map {
max(1, min(30, $0 - ProcessInfo.processInfo.systemUptime))
} ?? 30
)
guard result.status == 0 else {
if all {
return [
"simulator_id": simulatorID,
"surface_ref": surfaceRef,
"error": result.standardError.trimmingCharacters(in: .whitespacesAndNewlines),
]
}
throw CLIError(message: result.standardError.trimmingCharacters(in: .whitespacesAndNewlines))
}
return [
"path": destination.path,
"simulator_id": simulatorID,
"surface_ref": surfaceRef,
]
}
if jsonOutput {
print(jsonString(formatIDs(["captures": captures], mode: idFormat)))
} else {
captures.forEach { capture in
if let path = capture["path"] as? String {
print(simulatorTerminalText(path))
} else if let error = capture["error"] as? String {
let surface = simulatorTerminalText(capture["surface_ref"] as? String ?? "?")
cliWriteStderr("\(surface): \(simulatorTerminalText(error))\n")
}
}
}
if captures.contains(where: { $0["error"] != nil }) {
throw CLIError(message: String(
localized: "cli.ios.error.screenshotFailures",
defaultValue: "One or more iOS Simulator screenshots failed"
))
}
}
private func runSimulatorOwnedCommandSynchronously(
executable: String,
arguments: [String],
currentDirectory: String,
timeout: TimeInterval,
outputLimit: Int = 64 * 1_024
) -> SimulatorOwnedCommandResult {
let resultBox = IOSScreenshotCommandResultBox()
let finished = DispatchSemaphore(value: 0)
let runner = simulatorOwnedCommandRunner
let task = Task.detached {
let result = await runner.run(
executable: executable,
arguments: arguments,
currentDirectory: currentDirectory,
timeout: timeout,
outputLimit: outputLimit
)
resultBox.set(result)
finished.signal()
}
guard finished.wait(timeout: .now() + max(0, timeout) + 2) == .success,
let result = resultBox.get() else {
task.cancel()
_ = finished.wait(timeout: .now() + 1)
return SimulatorOwnedCommandResult(
status: 124,
standardError: String(
localized: "simulator.failure.commandTimedOut",
defaultValue: "The Simulator command timed out."
),
timedOut: true
)
}
return result
}
private func iosContextPayload(
surface: String?,
client: SocketClient,
windowOverride: String?,
responseTimeout: TimeInterval? = nil
) throws -> [String: Any] {
var params = try simulatorRoutingParams(
surface: surface,
client: client,
windowOverride: windowOverride
)
if let responseTimeout {
params["operation_timeout_seconds"] = min(550, max(0.1, responseTimeout - 6))
}
return try client.sendV2(
method: "simulator.context",
params: params,
responseTimeout: responseTimeout ?? simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.selectDevice
)
)
}
private func iosScreenshotContextPayload(
surface: String?,
client: SocketClient,
windowOverride: String?,
responseTimeout: TimeInterval? = nil
) throws -> [String: Any] {
let params = try simulatorRoutingParams(
surface: surface,
client: client,
windowOverride: windowOverride
)
return try client.sendV2(
method: "simulator.prepare_screenshot",
params: params,
responseTimeout: responseTimeout ?? simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.selectDevice
)
)
}
private func iosScreenshotBatchTimeoutMessage() -> String {
String(
localized: "cli.ios.error.screenshotBatchTimeout",
defaultValue: "The iOS Simulator screenshot batch exceeded its 10-minute deadline"
)
}
private func removeIOSSurfaceOption(from arguments: inout [String]) throws -> String? {
try removeIOSOption("--surface", from: &arguments)
}
private func removeIOSOption(_ name: String, from arguments: inout [String]) throws -> String? {
let values = try removeIOSOptions(name, from: &arguments)
guard values.count <= 1 else { throw CLIError(message: iosSubcommandUsage()) }
return values.first
}
private func removeIOSOptions(_ name: String, from arguments: inout [String]) throws -> [String] {
var values: [String] = []
while let index = arguments.firstIndex(of: name) {
guard index + 1 < arguments.count else { throw CLIError(message: iosSubcommandUsage()) }
values.append(arguments[index + 1])
arguments.removeSubrange(index...(index + 1))
}
return values
}
private func missingIOSSimulatorIdentifier() -> CLIError {
CLIError(message: String(
localized: "cli.ios.error.missingSimulatorID",
defaultValue: "The selected iOS pane has no Simulator identifier"
))
}
private func missingIOSSurfaceReference() -> CLIError {
CLIError(message: String(
localized: "cli.ios.error.missingSurfaceReference",
defaultValue: "The selected iOS pane has no surface reference"
))
}
private func printIOSContext(_ payload: [String: Any]) {
for key in [
"simulator_id", "device_name", "runtime_id", "state", "orientation", "surface_ref",
] {
if let value = payload[key], !(value is NSNull) {
print("\(key)=\(simulatorTerminalText(String(describing: value)))")
}
}
}
private func printIOSTargets(_ targets: [[String: Any]]) {
guard !targets.isEmpty else {
print(String(localized: "cli.ios.output.noTargets", defaultValue: "No iOS Simulator panes"))
return
}
for target in targets {
print([
simulatorTerminalText(target["surface_ref"] as? String ?? "?"),
simulatorTerminalText(target["device_name"] as? String ?? "?"),
simulatorTerminalText(target["simulator_id"] as? String ?? "?"),
simulatorTerminalText(target["state"] as? String ?? "?"),
].joined(separator: "\t"))
}
}
func runSimulatorNamespace(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
guard let subcommand = commandArgs.first?.lowercased() else {
throw CLIError(message: simulatorSubcommandUsage())
}
let parsed = try parseSimulatorArguments(Array(commandArgs.dropFirst()))
var params = try simulatorRoutingParams(
surface: parsed.surface,
client: client,
windowOverride: windowOverride
)
if let request = try simulatorAgentRequest(subcommand: subcommand, arguments: parsed) {
params.merge(request.params, uniquingKeysWith: { _, new in new })
let payload = try client.sendV2(
method: request.method,
params: params,
responseTimeout: request.timeout
)
printSimulatorAgentResult(payload, output: request.output, jsonOutput: jsonOutput,
idFormat: idFormat)
return
}
let method: String
let responseTimeout: TimeInterval?
switch subcommand {
case "type":
let text = try simulatorSourceValue(
parsed,
maximumBytes: Self.simulatorTextLimit
)
let deliveryTimeout = (try? SimulatorUSKeyboardTextEncoder().encode(text))?
.completionTimeoutSeconds ?? 120
params["text"] = text
method = "simulator.type"
responseTimeout = simulatorOperationDeadlines.clientTimeout(
for: deliveryTimeout
+ simulatorOperationDeadlines.textInputReadiness
)
case "targets":
try requireNoSimulatorSource(parsed, subcommand: subcommand)
method = "simulator.web_inspector.targets"
responseTimeout = simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.webInspectorReadiness + 15
)
case "attach":
guard parsed.positionals.count == 1,
!parsed.positionals[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!parsed.readsStandardInput,
parsed.file == nil else {
throw CLIError(message: simulatorSubcommandUsage())
}
params["target_id"] = parsed.positionals[0]
method = "simulator.web_inspector.attach"
responseTimeout = simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.webInspectorReadiness + 15
)
case "send":
params["json"] = try simulatorSourceValue(
parsed,
maximumBytes: Self.simulatorInspectorLimit
)
method = "simulator.web_inspector.send"
responseTimeout = simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.webInspectorReadiness + 20
)
case "highlight":
guard parsed.positionals.count == 1,
!parsed.readsStandardInput,
parsed.file == nil else {
throw CLIError(message: simulatorSubcommandUsage())
}
switch parsed.positionals[0].lowercased() {
case "on", "true", "1": params["enabled"] = true
case "off", "false", "0": params["enabled"] = false
default:
throw CLIError(message: String(
localized: "cli.simulator.error.invalidHighlight",
defaultValue: "simulator highlight requires on or off"
))
}
method = "simulator.web_inspector.highlight"
responseTimeout = simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.webInspectorReadiness + 15
)
case "release":
try requireNoSimulatorSource(parsed, subcommand: subcommand)
method = "simulator.web_inspector.release"
responseTimeout = simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.webInspectorReadiness + 15
)
default:
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.simulator.error.unknownSubcommand",
defaultValue: "Unknown simulator subcommand: %@"
),
subcommand
))
}
let payload = try client.sendV2(
method: method,
params: params,
responseTimeout: responseTimeout
)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else if subcommand == "targets" {
printSimulatorTargets(payload)
} else if subcommand == "type" {
let count = (payload["character_count"] as? Int) ?? 0
print(String.localizedStringWithFormat(
String(
localized: "cli.simulator.output.typed",
defaultValue: "Typed %lld character(s)"
),
count
))
} else if subcommand == "send" {
print(payload["response_json"] as? String ?? "")
} else {
print(String(
localized: "cli.simulator.output.accepted",
defaultValue: "Completed"
))
}
}
private func simulatorRoutingParams(
surface: String?,
client: SocketClient,
windowOverride: String?
) throws -> [String: Any] {
let window = try normalizeWindowHandle(windowOverride, client: client)
let callerWorkspace = Self.callerWorkspaceForSurfaceHandle(
surface,
windowRaw: window
)
let workspace = try normalizeWorkspaceHandle(
callerWorkspace,
client: client,
windowHandle: window
)
let normalizedSurface = try normalizeSurfaceHandle(
surface,
client: client,
workspaceHandle: workspace,
windowHandle: window
)
return try simulatorRoutingParams(
normalizedSurface: normalizedSurface,
window: window,
workspace: workspace
)
}
private func simulatorRoutingParams(
normalizedSurface: String?,
window: String?,
workspace: String?
) throws -> [String: Any] {
if window != nil || workspace != nil || normalizedSurface != nil {
var params: [String: Any] = [:]
if let window { params["window_id"] = window }
if let workspace { params["workspace_id"] = workspace }
if let normalizedSurface { params["surface_id"] = normalizedSurface }
return params
}
let environment = ProcessInfo.processInfo.environment
guard let workspaceID = environment["CMUX_WORKSPACE_ID"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!workspaceID.isEmpty else { return [:] }
return ["workspace_id": workspaceID]
}
}
+228
View File
@@ -0,0 +1,228 @@
import CmuxSimulator
import Foundation
extension CMUXCLI {
func simulatorAgentRequest(
subcommand: String,
arguments: SimulatorArguments
) throws -> SimulatorAgentRequest? {
let values = arguments.positionals
if subcommand != "permissions", arguments.optionValue != nil {
throw simulatorArgumentsError(subcommand)
}
switch subcommand {
case "select", "select-device":
guard let value = oneSimulatorValue(arguments) else {
throw simulatorArgumentsError(subcommand)
}
return request(
"simulator.select_device",
["device_id": value],
timeout: simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.selectDevice
)
)
case "tap":
guard !arguments.readsStandardInput, arguments.file == nil,
values.count == 2 || values.count == 4 else { throw simulatorArgumentsError(subcommand) }
let point = try simulatorPoint(values[0], values[1])
var params: [String: Any] = ["x": point.x, "y": point.y]
if values.count == 4 {
let second = try simulatorPoint(values[2], values[3])
params["x2"] = second.x
params["y2"] = second.y
}
return request("simulator.tap", params)
case "gesture", "multitouch", "multi-touch":
let source = try simulatorSourceValue(arguments, maximumBytes: 64 * 1_024)
guard let data = source.data(using: .utf8),
let decoded = try? JSONSerialization.jsonObject(with: data) else {
throw CLIError(message: String(
localized: "cli.simulator.error.invalidGestureJSON",
defaultValue: "simulator gesture requires a JSON touch object or array"
))
}
let events = decoded as? [Any] ?? [decoded]
guard !events.isEmpty, events.count <= 256,
events.allSatisfy({ $0 is [String: Any] }) else {
throw CLIError(message: String(
localized: "cli.simulator.error.invalidGestureJSON",
defaultValue: "simulator gesture requires a JSON touch object or array"
))
}
return request(subcommand == "gesture" ? "simulator.gesture" : "simulator.multi_touch",
["events": events])
case "swipe":
guard !arguments.readsStandardInput, arguments.file == nil,
[4, 5, 8, 9].contains(values.count) else { throw simulatorArgumentsError(subcommand) }
let from = try simulatorPoint(values[0], values[1])
let to = try simulatorPoint(values[2], values[3])
var params: [String: Any] = [
"from_x": from.x, "from_y": from.y,
"to_x": to.x, "to_y": to.y,
]
if values.count >= 8 {
let secondFrom = try simulatorPoint(values[4], values[5])
let secondTo = try simulatorPoint(values[6], values[7])
params["from_x2"] = secondFrom.x
params["from_y2"] = secondFrom.y
params["to_x2"] = secondTo.x
params["to_y2"] = secondTo.y
}
if values.count == 5 || values.count == 9 {
let stepIndex = values.count == 5 ? 4 : 8
guard let steps = Int(values[stepIndex]), (2...64).contains(steps) else {
throw simulatorArgumentsError(subcommand)
}
params["steps"] = steps
}
return request("simulator.swipe", params)
case "button":
guard let value = oneSimulatorValue(arguments) else { throw simulatorArgumentsError(subcommand) }
return request("simulator.button", ["button": simulatorButtonName(value)])
case "rotate":
guard let value = oneSimulatorValue(arguments) else { throw simulatorArgumentsError(subcommand) }
return request("simulator.rotate", ["orientation": value.replacingOccurrences(of: "-", with: "_")])
case "ca":
guard !arguments.readsStandardInput, arguments.file == nil, values.count == 2,
let enabled = simulatorOnOff(values[1]) else { throw simulatorArgumentsError(subcommand) }
return request("simulator.core_animation", [
"diagnostic": simulatorCADiagnosticName(values[0]), "enabled": enabled,
])
case "memory-warning", "memory_warning":
try requireNoSimulatorSource(arguments, subcommand: subcommand)
return request("simulator.memory_warning", [:])
case "event-log", "events":
guard !arguments.readsStandardInput, arguments.file == nil, values.count <= 1 else {
throw simulatorArgumentsError(subcommand)
}
var params: [String: Any] = [:]
if let raw = values.first {
guard let limit = Int(raw), (1...500).contains(limit) else {
throw simulatorArgumentsError(subcommand)
}
params["limit"] = limit
}
return request("simulator.event_log", params, output: .eventLog)
case "tools":
guard let action = oneSimulatorValue(arguments)?.lowercased(),
["show", "hide", "toggle"].contains(action) else {
throw simulatorArgumentsError(subcommand)
}
return request("simulator.tools", ["action": action])
case "camera":
return try simulatorCameraRequest(arguments)
case "permissions":
return try simulatorPermissionsRequest(arguments)
case "ui":
return try simulatorInterfaceRequest(arguments)
case "accessibility", "ax":
try requireNoSimulatorSource(arguments, subcommand: subcommand)
return request(
"simulator.accessibility",
[:],
output: .accessibility
)
case "foreground":
try requireNoSimulatorSource(arguments, subcommand: subcommand)
return request(
"simulator.foreground",
[:],
timeout: simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.inspectionRead
),
output: .foregroundApplication
)
default:
return nil
}
}
func simulatorCameraRequest(_ arguments: SimulatorArguments) throws -> SimulatorAgentRequest {
guard !arguments.readsStandardInput, arguments.file == nil,
let action = arguments.positionals.first?.lowercased() else {
throw simulatorArgumentsError("camera")
}
let values = Array(arguments.positionals.dropFirst())
switch action {
case "configure":
guard !values.isEmpty else { throw simulatorArgumentsError("camera configure") }
let source = values.count >= 2 ? values[1] : "placeholder"
let sourceArguments = values.count >= 2 ? Array(values.dropFirst(2)) : []
var params = try simulatorCameraSourceParams(sourceArguments, source: source)
params["bundle_id"] = values[0]
return request(
"simulator.camera.configure",
params,
timeout: simulatorOperationDeadlines.clientTimeout(for: 160),
output: .cameraStatus
)
case "switch":
guard let source = values.first,
!["off", "disabled"].contains(source.lowercased()) else {
throw simulatorArgumentsError("camera switch")
}
return request(
"simulator.camera.switch",
try simulatorCameraSourceParams(Array(values.dropFirst()), source: source),
timeout: simulatorOperationDeadlines.clientTimeout(for: 160),
output: .cameraStatus
)
case "mirror":
guard values.count == 1, ["auto", "on", "off"].contains(values[0]) else {
throw simulatorArgumentsError("camera mirror")
}
return request("simulator.camera.mirror", ["mode": values[0]], output: .cameraStatus)
case "status", "webcams":
guard values.isEmpty else { throw simulatorArgumentsError("camera \(action)") }
return request("simulator.camera.status", [:], output: .cameraStatus)
case "stop":
guard values.isEmpty else { throw simulatorArgumentsError("camera stop") }
return request(
"simulator.camera.configure",
["source": "off"],
timeout: simulatorOperationDeadlines.clientTimeout(for: 160),
output: .cameraStatus
)
default:
throw simulatorArgumentsError("camera")
}
}
func simulatorCameraSourceParams(
_ arguments: [String], source rawSource: String
) throws -> [String: Any] {
let source = rawSource.lowercased()
guard ["off", "placeholder", "image", "file", "video", "host", "webcam"].contains(source) else {
throw simulatorArgumentsError("camera")
}
var values = arguments
var params: [String: Any] = ["source": source]
if ["image", "file", "video"].contains(source) {
guard !values.isEmpty else { throw simulatorArgumentsError("camera") }
let rawPath = values.removeFirst()
params["path"] = URL(
fileURLWithPath: rawPath,
relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
).standardizedFileURL.path
if ["file", "video"].contains(source) { params["loops"] = true }
} else if ["host", "webcam"].contains(source), !values.isEmpty {
params["device_id"] = values.removeFirst()
}
if values.first?.lowercased() == "loop" {
params["loops"] = true
values.removeFirst()
}
guard values.isEmpty else { throw simulatorArgumentsError("camera") }
return params
}
func request(
_ method: String,
_ params: [String: Any],
timeout: TimeInterval? = simulatorOperationDeadlines.clientTimeout(for: 35),
output: SimulatorAgentOutput = .completed
) -> SimulatorAgentRequest {
SimulatorAgentRequest(method: method, params: params, timeout: timeout, output: output)
}
}
+223
View File
@@ -0,0 +1,223 @@
import Foundation
extension CMUXCLI {
func printSimulatorAgentResult(
_ payload: [String: Any],
output: SimulatorAgentOutput,
jsonOutput: Bool,
idFormat: CLIIDFormat
) {
if jsonOutput || output == .cameraStatus {
print(jsonString(formatIDs(payload, mode: idFormat)))
return
}
switch output {
case .completed:
print(String(localized: "cli.simulator.output.accepted", defaultValue: "Completed"))
case .eventLog:
for event in payload["events"] as? [[String: Any]] ?? [] {
let timestamp = simulatorTerminalText(event["timestamp"] as? String ?? "")
let action = simulatorTerminalText(event["action"] as? String ?? "")
let summary = simulatorTerminalText(event["summary"] as? String ?? "")
print("\(timestamp)\t\(action)\t\(summary)")
}
case .cameraStatus:
break
case .permissionsList:
printSimulatorPermissions(payload)
case let .permissionsUpdated(action, service, bundleIdentifier):
print(String.localizedStringWithFormat(
String(
localized: "cli.simulator.output.permissionUpdated",
defaultValue: "%@ %@ for %@"
),
simulatorTerminalText(action),
simulatorTerminalText(service),
simulatorTerminalText(bundleIdentifier)
))
case .interfaceStatus:
printSimulatorInterfaceSettings(payload)
case let .interfaceValue(option):
let settings = payload["settings"] as? [String: Any]
print(simulatorTerminalText(settings?[option] as? String ?? ""))
case let .interfaceUpdated(option):
let settings = payload["settings"] as? [String: Any]
let value = settings?[option] as? String ?? ""
print(String.localizedStringWithFormat(
String(
localized: "cli.simulator.output.interfaceUpdated",
defaultValue: "Set %@ to %@"
),
simulatorTerminalText(option),
simulatorTerminalText(value)
))
case .accessibility:
printSimulatorAccessibility(payload)
case .foregroundApplication:
printSimulatorForegroundApplication(payload)
}
}
func printSimulatorPermissions(_ payload: [String: Any]) {
if let applications = payload["applications"] as? [[String: Any]] {
guard !applications.isEmpty else {
print(String(
localized: "cli.simulator.output.noPermissions",
defaultValue: "No permission values"
))
return
}
for application in applications.sorted(by: {
($0["bundle_id"] as? String ?? "") < ($1["bundle_id"] as? String ?? "")
}) {
let bundleIdentifier = simulatorTerminalText(application["bundle_id"] as? String ?? "?")
let permissions = application["permissions"] as? [String: Any] ?? [:]
for key in permissions.keys.sorted() {
print("\(bundleIdentifier)\t\(simulatorTerminalText(key))\t"
+ simulatorTerminalText(permissions[key] as? String ?? "unknown"))
}
}
if payload["truncated"] as? Bool == true {
print(String(
localized: "cli.simulator.output.permissionsTruncated",
defaultValue: "Permission results were truncated at 256 applications"
))
}
return
}
let permissions = payload["permissions"] as? [String: Any] ?? [:]
guard !permissions.isEmpty else {
print(String(
localized: "cli.simulator.output.noPermissions",
defaultValue: "No permission values"
))
return
}
for key in permissions.keys.sorted() {
print("\(simulatorTerminalText(key))\t"
+ simulatorTerminalText(permissions[key] as? String ?? "unknown"))
}
}
func printSimulatorInterfaceSettings(_ payload: [String: Any]) {
let settings = payload["settings"] as? [String: Any] ?? [:]
guard !settings.isEmpty else {
print(String(
localized: "cli.simulator.output.noInterfaceSettings",
defaultValue: "No interface settings"
))
return
}
for key in settings.keys.sorted() {
print("\(simulatorTerminalText(key))\t"
+ simulatorTerminalText(settings[key] as? String ?? "unsupported"))
}
}
func simulatorPoint(_ x: String, _ y: String) throws -> (x: Double, y: Double) {
guard let x = Double(x), let y = Double(y), x.isFinite, y.isFinite,
(0...1).contains(x), (0...1).contains(y) else {
throw CLIError(message: String(
localized: "cli.simulator.error.invalidCoordinate",
defaultValue: "Simulator coordinates must be numbers from 0 through 1"
))
}
return (x, y)
}
func oneSimulatorValue(_ arguments: SimulatorArguments) -> String? {
guard !arguments.readsStandardInput, arguments.file == nil,
arguments.positionals.count == 1 else { return nil }
return arguments.positionals[0]
}
func simulatorOnOff(_ raw: String) -> Bool? {
switch raw.lowercased() {
case "on", "true", "1": true
case "off", "false", "0": false
default: nil
}
}
func simulatorButtonName(_ raw: String) -> String {
let normalized = raw.lowercased()
return switch normalized {
case "swipe-home", "swipe_home", "swipehome": "swipeHome"
case "app-switcher", "app_switcher", "appswitcher": "appSwitcher"
case "side-button", "side_button", "sidebutton": "sideButton"
case "volume-up", "volume_up", "volumeup": "volumeUp"
case "volume-down", "volume_down", "volumedown": "volumeDown"
case "watch-side-button", "watch_side_button", "watchsidebutton": "watchSideButton"
default: normalized
}
}
func simulatorCADiagnosticName(_ raw: String) -> String {
let normalized = raw.lowercased()
return switch normalized {
case "slow-animations", "slow_animations", "slowanimations": "slowAnimations"
default: normalized
}
}
func simulatorArgumentsError(_ command: String) -> CLIError {
CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.simulator.error.invalidArguments",
defaultValue: "Invalid arguments for simulator %@"
),
command
))
}
func printSimulatorAccessibility(_ payload: [String: Any]) {
struct PendingNode {
let value: [String: Any]
let depth: Int
}
let roots = payload["roots"] as? [[String: Any]] ?? []
var pending = roots.reversed().map { PendingNode(value: $0, depth: 0) }
var emitted = 0
while let current = pending.popLast(), emitted < 500 {
emitted += 1
let role = simulatorTerminalText(current.value["type"] as? String ?? "?")
let label = simulatorTerminalText(current.value["AXLabel"] as? String ?? "")
let value = simulatorTerminalText(current.value["AXValue"] as? String ?? "")
let identifier = simulatorTerminalText(current.value["AXUniqueId"] as? String ?? "")
let indentation = String(repeating: " ", count: min(current.depth, 16))
print("\(indentation)\(role)\t\(label)\t\(value)\t\(identifier)")
let children = current.value["children"] as? [[String: Any]] ?? []
for child in children.reversed() {
pending.append(PendingNode(value: child, depth: current.depth + 1))
}
}
if payload["truncated"] as? Bool == true || !pending.isEmpty {
print(String(
localized: "cli.simulator.output.accessibilityTruncated",
defaultValue: "Accessibility results reached the 500-element limit"
))
}
}
func printSimulatorForegroundApplication(_ payload: [String: Any]) {
guard let application = payload["application"] as? [String: Any] else {
print(String(
localized: "cli.simulator.output.noForegroundApplication",
defaultValue: "No foreground application"
))
return
}
let bundleIdentifier = simulatorTerminalText(application["bundle_id"] as? String ?? "?")
let name = simulatorTerminalText(application["name"] as? String ?? bundleIdentifier)
let processIdentifier = simulatorTerminalText(
application["pid"].map { String(describing: $0) } ?? ""
)
let executable = simulatorTerminalText(application["executable"] as? String ?? "")
let bundlePath = simulatorTerminalText(application["bundle_path"] as? String ?? "")
print("\(name)\t\(bundleIdentifier)\t\(processIdentifier)\t\(executable)\t\(bundlePath)")
}
func simulatorTerminalText(_ value: String) -> String {
Self.sanitizeForTerminal(value)
}
}
+14
View File
@@ -0,0 +1,14 @@
extension CMUXCLI {
enum SimulatorAgentOutput: Equatable {
case completed
case eventLog
case cameraStatus
case permissionsList
case permissionsUpdated(action: String, service: String, bundleIdentifier: String)
case interfaceStatus
case interfaceValue(option: String)
case interfaceUpdated(option: String)
case accessibility
case foregroundApplication
}
}
+10
View File
@@ -0,0 +1,10 @@
import Foundation
extension CMUXCLI {
struct SimulatorAgentRequest {
let method: String
let params: [String: Any]
let timeout: TimeInterval?
let output: SimulatorAgentOutput
}
}
+154
View File
@@ -0,0 +1,154 @@
import Foundation
extension CMUXCLI {
func parseSimulatorArguments(_ args: [String]) throws -> SimulatorArguments {
var result = SimulatorArguments()
var index = 0
var readsPositionalsOnly = false
while index < args.count {
let argument = args[index]
if readsPositionalsOnly { result.positionals.append(argument) }
else if argument == "--" { readsPositionalsOnly = true }
else if argument == "--stdin" { result.readsStandardInput = true }
else if argument == "--surface" || argument == "--file" || argument == "--value" {
guard index + 1 < args.count else {
throw CLIError(message: String.localizedStringWithFormat(
String(localized: "cli.simulator.error.missingOptionValue",
defaultValue: "simulator: %@ requires a value"), argument
))
}
index += 1
if argument == "--surface" { result.surface = args[index] }
else if argument == "--file" { result.file = args[index] }
else { result.optionValue = args[index] }
} else if argument.hasPrefix("--value=") {
let value = String(argument.dropFirst("--value=".count))
guard !value.isEmpty else {
throw CLIError(message: String.localizedStringWithFormat(
String(localized: "cli.simulator.error.missingOptionValue",
defaultValue: "simulator: %@ requires a value"), "--value"
))
}
result.optionValue = value
} else if argument.hasPrefix("--") {
throw CLIError(message: String.localizedStringWithFormat(
String(localized: "cli.simulator.error.unknownFlag",
defaultValue: "simulator: unknown flag '%@'"), argument
))
} else { result.positionals.append(argument) }
index += 1
}
return result
}
func simulatorSourceValue(
_ arguments: SimulatorArguments,
maximumBytes: Int
) throws -> String {
guard arguments.optionValue == nil else {
throw simulatorArgumentsError("input")
}
guard arguments.positionals.count <= 1 else {
throw CLIError(message: String(
localized: "cli.simulator.error.unexpectedArgument",
defaultValue: "simulator input accepts one quoted positional value"
))
}
let sourceCount = (arguments.positionals.isEmpty ? 0 : 1)
+ (arguments.readsStandardInput ? 1 : 0) + (arguments.file == nil ? 0 : 1)
guard sourceCount > 0 else {
throw CLIError(message: String(
localized: "cli.simulator.error.sourceRequired",
defaultValue: "simulator input requires a positional value, --stdin, or --file"
))
}
guard sourceCount == 1 else {
throw CLIError(message: String(
localized: "cli.simulator.error.sourcesExclusive",
defaultValue: "simulator input sources are mutually exclusive"
))
}
if let value = arguments.positionals.first {
try validateSimulatorInput(value, maximumBytes: maximumBytes)
return value
}
let data: Data
if arguments.readsStandardInput {
data = try readBoundedSimulatorInput(
FileHandle.standardInput, maximumBytes: maximumBytes, closesHandle: false
)
} else if let path = arguments.file {
data = try readBoundedSimulatorInput(
FileHandle(forReadingFrom: URL(fileURLWithPath: path)),
maximumBytes: maximumBytes,
closesHandle: true
)
} else { data = Data() }
guard let value = String(data: data, encoding: .utf8) else {
throw CLIError(message: String(
localized: "cli.simulator.error.invalidUTF8",
defaultValue: "simulator input must be valid UTF-8"
))
}
return value
}
func requireNoSimulatorSource(
_ arguments: SimulatorArguments,
subcommand: String
) throws {
guard arguments.positionals.isEmpty,
!arguments.readsStandardInput,
arguments.file == nil,
arguments.optionValue == nil else {
throw CLIError(message: String.localizedStringWithFormat(
String(localized: "cli.simulator.error.unexpectedArgumentForCommand",
defaultValue: "simulator %@ does not accept input"), subcommand
))
}
}
func printSimulatorTargets(_ payload: [String: Any]) {
let targets = payload["targets"] as? [[String: Any]] ?? []
guard !targets.isEmpty else {
print(String(localized: "cli.simulator.output.noTargets",
defaultValue: "No Web Inspector targets"))
return
}
for target in targets {
print([
simulatorTerminalText(target["id"] as? String ?? "?"),
simulatorTerminalText(target["application_name"] as? String ?? "?"),
simulatorTerminalText(target["title"] as? String ?? ""),
simulatorTerminalText(target["url"] as? String ?? ""),
].joined(separator: "\t"))
}
}
private func readBoundedSimulatorInput(
_ handle: FileHandle, maximumBytes: Int, closesHandle: Bool
) throws -> Data {
defer { if closesHandle { try? handle.close() } }
var data = Data()
while data.count <= maximumBytes {
let remaining = maximumBytes + 1 - data.count
guard remaining > 0,
let chunk = try handle.read(upToCount: min(64 * 1_024, remaining)),
!chunk.isEmpty else { break }
data.append(chunk)
}
guard data.count <= maximumBytes else { throw simulatorInputTooLarge(maximumBytes) }
return data
}
private func validateSimulatorInput(_ value: String, maximumBytes: Int) throws {
guard value.utf8.count <= maximumBytes else { throw simulatorInputTooLarge(maximumBytes) }
}
private func simulatorInputTooLarge(_ maximumBytes: Int) -> CLIError {
CLIError(message: String.localizedStringWithFormat(
String(localized: "cli.simulator.error.inputTooLarge",
defaultValue: "simulator input exceeds the %lld-byte UTF-8 limit"), maximumBytes
))
}
}
+316
View File
@@ -0,0 +1,316 @@
import CmuxSimulator
import Foundation
extension CMUXCLI {
func simulatorPermissionsRequest(
_ arguments: SimulatorArguments
) throws -> SimulatorAgentRequest {
guard !arguments.readsStandardInput, arguments.file == nil,
let verb = arguments.positionals.first?.lowercased() else {
throw simulatorPermissionsUsageError()
}
let values = Array(arguments.positionals.dropFirst())
if verb == "list" || verb == "read" {
guard values.count <= 1, arguments.optionValue == nil else {
throw simulatorPermissionsUsageError()
}
var params: [String: Any] = [:]
if let bundleIdentifier = values.first {
try validateSimulatorBundleIdentifier(bundleIdentifier)
params["bundle_id"] = bundleIdentifier
}
return request(
"simulator.permissions.read",
params,
output: .permissionsList
)
}
let action: String
switch verb {
case "grant": action = "grant"
case "revoke", "deny": action = "revoke"
case "reset": action = "reset"
default: throw simulatorPermissionsUsageError()
}
guard values.count == 2 || values.count == 3 else {
throw simulatorPermissionsUsageError()
}
let rawPermission = values[0].lowercased()
let bundleIdentifier = values[1]
try validateSimulatorBundleIdentifier(bundleIdentifier)
guard !(arguments.optionValue != nil && values.count == 3) else {
throw simulatorPermissionsUsageError()
}
let value = (arguments.optionValue ?? (values.count == 3 ? values[2] : nil))?.lowercased()
let normalized = try normalizeSimulatorPermission(
action: action,
permission: rawPermission,
value: value
)
return request(
"simulator.permissions.set",
[
"action": normalized.action,
"service": normalized.service,
"bundle_id": bundleIdentifier,
],
timeout: simulatorOperationDeadlines.clientTimeout(
for: normalized.service == "all"
? simulatorOperationDeadlines.permissionResetAll
: simulatorOperationDeadlines.permissionMutation
),
output: .permissionsUpdated(
action: normalized.action,
service: normalized.service,
bundleIdentifier: bundleIdentifier
)
)
}
func simulatorInterfaceRequest(
_ arguments: SimulatorArguments
) throws -> SimulatorAgentRequest {
guard !arguments.readsStandardInput, arguments.file == nil,
arguments.optionValue == nil else {
throw simulatorInterfaceUsageError()
}
let values = arguments.positionals
if values.isEmpty || values == ["status"] {
return request(
"simulator.ui.status",
[:],
timeout: simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.interfaceRead
),
output: .interfaceStatus
)
}
let option: String
let rawValue: String?
if values.first?.lowercased() == "get", values.count == 2 {
option = try normalizeSimulatorInterfaceOption(values[1])
rawValue = nil
} else if values.first?.lowercased() == "set", values.count == 3 {
option = try normalizeSimulatorInterfaceOption(values[1])
rawValue = values[2]
} else if values.count == 1 {
option = try normalizeSimulatorInterfaceOption(values[0])
rawValue = nil
} else if values.count == 2 {
option = try normalizeSimulatorInterfaceOption(values[0])
rawValue = values[1]
} else {
throw simulatorInterfaceUsageError()
}
guard let rawValue else {
return request(
"simulator.ui.status",
[:],
timeout: simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.interfaceRead
),
output: .interfaceValue(option: option)
)
}
let value = try normalizeSimulatorInterfaceValue(rawValue, option: option)
return request(
"simulator.ui.set",
["option": option, "value": value],
timeout: simulatorOperationDeadlines.clientTimeout(
for: simulatorOperationDeadlines.interfaceMutation
),
output: .interfaceUpdated(option: option)
)
}
func normalizeSimulatorPermission(
action: String,
permission rawPermission: String,
value: String?
) throws -> (action: String, service: String) {
let alias: (permission: String, value: String?) = switch rawPermission {
case "push", "notification": ("notifications", nil)
case "photo-library", "photo": ("photos", nil)
case "location-always": ("location", "always")
case "location-in-use", "location_in_use", "location-inuse": ("location", "inuse")
case "mic": ("microphone", nil)
case "critical-notifications": ("notifications-critical", nil)
case "face-id": ("faceid", nil)
case "home-kit": ("homekit", nil)
default: (rawPermission.replacingOccurrences(of: "_", with: "-"), nil)
}
let permission = alias.permission
let value = value ?? alias.value
let supported = [
"all", "calendar", "contacts-limited", "contacts", "location",
"location-always", "location-inuse", "photos-add", "photos",
"photos-limited", "media-library", "microphone", "motion",
"reminders", "siri", "camera", "notifications",
"notifications-critical", "speech", "faceid", "user-tracking", "homekit",
]
guard supported.contains(permission), permission != "all" || action == "reset" else {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.simulator.error.unknownPermission",
defaultValue: "Unknown or unsupported Simulator permission: %@"
),
rawPermission
))
}
guard permission != "all" || value == nil else {
throw simulatorInvalidPermissionValue(value ?? "", permission: permission)
}
guard let value else { return (action, permission) }
switch (permission, value) {
case ("photos", "limited"):
return (action, "photos-limited")
case ("notifications", "critical"):
return (action, "notifications-critical")
case ("location", "always"):
return (action, "location-always")
case ("location", "inuse"), ("location", "in-use"):
return (action, "location-inuse")
case ("location", "never"):
return ("revoke", "location")
default:
throw simulatorInvalidPermissionValue(value, permission: permission)
}
}
func normalizeSimulatorInterfaceOption(_ raw: String) throws -> String {
let option: String = switch raw.lowercased().replacingOccurrences(of: "_", with: "-") {
case "content-size": "text-size"
case "button-shapes": "show-borders"
case "voice-over": "voiceover"
case let value: value
}
guard [
"appearance", "liquid-glass", "color-filter", "text-size",
"reduce-motion", "increase-contrast", "show-borders",
"reduce-transparency", "voiceover",
].contains(option) else {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.simulator.error.unknownUIOption",
defaultValue: "Unknown Simulator interface option: %@"
),
raw
))
}
return option
}
func normalizeSimulatorInterfaceValue(
_ raw: String,
option: String
) throws -> String {
let value = raw.lowercased().replacingOccurrences(of: "_", with: "-")
let normalized: String? = switch option {
case "appearance": ["light", "dark"].contains(value) ? value : nil
case "liquid-glass": ["clear", "tinted"].contains(value) ? value : nil
case "color-filter": switch value {
case "protanopia": "red-green"
case "deuteranopia": "green-red"
case "tritanopia": "blue-yellow"
case "none", "grayscale", "red-green", "green-red", "blue-yellow": value
default: nil
}
case "text-size": [
"extra-small", "small", "medium", "large", "extra-large",
"extra-extra-large", "extra-extra-extra-large", "accessibility-medium",
"accessibility-large", "accessibility-extra-large",
"accessibility-extra-extra-large", "accessibility-extra-extra-extra-large",
"increment", "decrement",
].contains(value) ? value : nil
case "reduce-motion", "increase-contrast", "show-borders",
"reduce-transparency", "voiceover": simulatorToggleValue(value)
default: nil
}
guard let normalized else {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.simulator.error.invalidUIValue",
defaultValue: "Invalid value '%@' for Simulator interface option %@"
),
raw,
option
))
}
return normalized
}
func validateSimulatorBundleIdentifier(_ value: String) throws {
let bytes = Array(value.utf8)
guard !bytes.isEmpty, bytes.count <= 255,
simulatorASCIIAlphaNumeric(bytes[0]),
bytes.allSatisfy({
simulatorASCIIAlphaNumeric($0) || $0 == 0x2D || $0 == 0x2E
}) else {
throw CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.simulator.error.invalidBundleIdentifier",
defaultValue: "Invalid Simulator application bundle identifier: %@"
),
value
))
}
}
func simulatorToggleValue(_ value: String) -> String? {
switch value {
case "on", "true", "enabled", "1", "yes": "on"
case "off", "false", "disabled", "0", "no": "off"
default: nil
}
}
func simulatorASCIIAlphaNumeric(_ value: UInt8) -> Bool {
(0x30...0x39).contains(value)
|| (0x41...0x5A).contains(value)
|| (0x61...0x7A).contains(value)
}
func simulatorInvalidPermissionValue(_ value: String, permission: String) -> CLIError {
CLIError(message: String.localizedStringWithFormat(
String(
localized: "cli.simulator.error.invalidPermissionValue",
defaultValue: "Invalid value '%@' for Simulator permission %@"
),
value,
permission
))
}
func simulatorPermissionsUsageError() -> CLIError {
CLIError(message: String(
localized: "cli.simulator.permissions.usage",
defaultValue: """
Usage:
cmux simulator permissions list [bundle-id] [--surface <id|ref|index>]
cmux simulator permissions grant <permission> <bundle-id> [--value <value>] [--surface <id|ref|index>]
cmux simulator permissions revoke <permission> <bundle-id> [--surface <id|ref|index>]
cmux simulator permissions reset <permission|all> <bundle-id> [--surface <id|ref|index>]
Values: photos limited; notifications critical; location always, inuse, or never.
"""
))
}
func simulatorInterfaceUsageError() -> CLIError {
CLIError(message: String(
localized: "cli.simulator.ui.usage",
defaultValue: """
Usage:
cmux simulator ui [status] [--surface <id|ref|index>]
cmux simulator ui [get] <option> [--surface <id|ref|index>]
cmux simulator ui [set] <option> <value> [--surface <id|ref|index>]
Options: appearance, liquid-glass, color-filter, text-size, reduce-motion, increase-contrast, show-borders, reduce-transparency, voiceover.
"""
))
}
}
+341
View File
@@ -0,0 +1,341 @@
import Darwin
import Foundation
extension CMUXCLI {
struct TmuxCompatLaunchContext {
let socketPath: String
let workspaceId: String
let windowId: String?
let paneHandle: String
let paneId: String?
let surfaceId: String?
}
func tmuxCompatResolvedSocketPath(processEnvironment: [String: String]) throws -> String {
let envSocketPath = try CLISocketEnvironment.socketPath(in: processEnvironment)
let bundleIdentifier = CLISocketPathResolver.currentAppBundleIdentifier()
let requestedSocketPath = envSocketPath ?? CLISocketPathResolver.defaultSocketPath(
bundleIdentifier: bundleIdentifier,
environment: processEnvironment
)
let source: CLISocketPathSource
if let envSocketPath {
source = CLISocketPathResolver.isImplicitDefaultPath(
envSocketPath,
bundleIdentifier: bundleIdentifier,
environment: processEnvironment
) ? .implicitDefault : .environment
} else {
source = .implicitDefault
}
return CLISocketPathResolver.resolve(
requestedPath: requestedSocketPath,
source: source,
environment: processEnvironment,
bundleIdentifier: bundleIdentifier
)
}
func tmuxCompatLaunchContext(
processEnvironment: [String: String],
explicitPassword: String?
) throws -> TmuxCompatLaunchContext? {
// A managed launcher is anchored to the immutable identity injected into its
// terminal. Without an inherited surface there is no caller to validate, so fail
// closed before opening the socket. In particular, never borrow system-wide focus
// from `system.identify`: a command started in Terminal.app must not target an
// unrelated cmux surface merely because that surface happens to be focused.
let ownWorkspace = normalizedTmuxTarget(processEnvironment["CMUX_WORKSPACE_ID"])
let ownSurface = normalizedTmuxTarget(processEnvironment["CMUX_SURFACE_ID"])
guard let ownSurface else { return nil }
let socketPath = try tmuxCompatResolvedSocketPath(processEnvironment: processEnvironment)
let client = SocketClient(path: socketPath)
do {
try client.connect()
try authenticateClientIfNeeded(
client,
explicitPassword: explicitPassword,
socketPath: socketPath
)
defer { client.close() }
func contextFromSurface(
workspaceHandle: String,
surfaceHandle: String,
windowHandle: String?
) throws -> TmuxCompatLaunchContext {
let workspaceId = try resolveWorkspaceId(workspaceHandle, client: client)
let surfaceToken = tmuxTrimIdSigil(surfaceHandle)
let surfaceId = isUUID(surfaceToken)
? surfaceToken
: try tmuxCanonicalSurfaceId(surfaceHandle, workspaceId: workspaceId, client: client)
let payload = try client.sendV2(
method: "surface.list",
params: ["workspace_id": workspaceId]
)
return try contextFromSurfacePayload(
payload,
workspaceId: workspaceId,
surfaceId: surfaceId,
surfaceHandle: surfaceHandle,
windowHandle: windowHandle
)
}
func contextFromSurfacePayload(
_ payload: [String: Any],
workspaceId: String,
surfaceId: String,
surfaceHandle: String,
windowHandle: String?
) throws -> TmuxCompatLaunchContext {
let surfaces = payload["surfaces"] as? [[String: Any]] ?? []
let normalizedSurfaceHandle = tmuxTrimIdSigil(surfaceHandle)
guard let surface = surfaces.first(where: {
($0["id"] as? String) == surfaceId
|| ($0["ref"] as? String) == normalizedSurfaceHandle
}),
let rawPaneHandle = (surface["pane_id"] as? String) ?? (surface["pane_ref"] as? String) else {
throw TmuxCompatLaunchContextError.launchSurfaceHasNoPane
}
let paneHandle = rawPaneHandle.trimmingCharacters(in: .whitespacesAndNewlines)
guard !paneHandle.isEmpty else {
throw TmuxCompatLaunchContextError.launchSurfaceHasNoPane
}
let paneId = try? tmuxCanonicalPaneId(
paneHandle,
workspaceId: workspaceId,
client: client
)
let windowId = (payload["window_id"] as? String)
?? (payload["window_ref"] as? String)
?? windowHandle
return TmuxCompatLaunchContext(
socketPath: socketPath,
workspaceId: workspaceId,
windowId: windowId,
paneHandle: paneHandle,
paneId: paneId,
surfaceId: surfaceId
)
}
// A launcher running inside a cmux terminal inherits that surface's immutable
// workspace/surface pair. Resolve and validate it without consulting global focus, so
// switching or closing the operator's focused pane cannot retarget a running team.
// Once either component is present, the inherited pair is authoritative: an incomplete
// or stale pair fails closed instead of silently changing identity to the focused pane.
if let ownWorkspace {
if let context = try? contextFromSurface(
workspaceHandle: ownWorkspace,
surfaceHandle: ownSurface,
windowHandle: nil
) {
return context
}
}
// A surface's stable UUID survives moves between workspaces. The inherited
// workspace can therefore be stale while the launch surface is still live.
// Relocate that UUID from structured socket state without consulting global
// focus; an inherited identity must never silently retarget to another surface.
let surfaceId = tmuxTrimIdSigil(ownSurface)
guard isUUID(surfaceId) else { return nil }
let payload = try client.sendV2(
method: "surface.list",
params: ["surface_id": surfaceId]
)
guard let currentWorkspaceId = payload["workspace_id"] as? String,
isUUID(currentWorkspaceId) else {
return nil
}
return try? contextFromSurfacePayload(
payload,
workspaceId: currentWorkspaceId,
surfaceId: surfaceId,
surfaceHandle: surfaceId,
windowHandle: nil
)
} catch {
client.close()
return nil
}
}
/// Replaces inherited routing aliases with one socket-validated launch identity.
func canonicalizedTmuxCompatLaunchEnvironment(
_ processEnvironment: [String: String],
launchContext: TmuxCompatLaunchContext?
) -> [String: String] {
var environment = processEnvironment
if let launchContext {
environment["CMUX_WORKSPACE_ID"] = launchContext.workspaceId
environment["CMUX_TAB_ID"] = launchContext.workspaceId
if let surfaceId = launchContext.surfaceId {
environment["CMUX_SURFACE_ID"] = surfaceId
environment["CMUX_PANEL_ID"] = surfaceId
} else {
environment.removeValue(forKey: "CMUX_SURFACE_ID")
environment.removeValue(forKey: "CMUX_PANEL_ID")
}
if let paneId = launchContext.paneId {
environment["CMUX_PANE_ID"] = paneId
} else {
environment.removeValue(forKey: "CMUX_PANE_ID")
}
} else {
for key in [
"CMUX_WORKSPACE_ID",
"CMUX_SURFACE_ID",
"CMUX_PANEL_ID",
"CMUX_TAB_ID",
"CMUX_PANE_ID",
] {
environment.removeValue(forKey: key)
}
}
return environment
}
func createClaudeTeamsShimDirectory(
processEnvironment: [String: String],
commandArgs: [String],
launchContext: TmuxCompatLaunchContext?
) throws -> URL {
let downstreamTmuxMissing = String(
localized: "cli.tmux-compat.error.downstreamTmuxMissing",
defaultValue: "cmux tmux shim: no downstream tmux executable found"
)
let script = """
#!/usr/bin/env bash
set -euo pipefail
if [[ -n "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-}" ]]; then
exec "$CMUX_CLAUDE_TEAMS_CMUX_BIN" __tmux-compat "$@"
fi
# This shim lives in a persistent per-surface PATH directory. Outside the
# claude-teams launch it must behave transparently, even when several cmux
# shim directories are present. Remove every PATH spelling of this shim's
# directory before resolving tmux; the next shim repeats the narrowing.
shim_dir="$(cd -P -- "$(dirname -- "$0")" && pwd)"
IFS=: read -r -a path_entries <<< "${PATH-}"
filtered_path=""
has_filtered_entry=0
for path_entry in "${path_entries[@]}"; do
resolved_dir="$(cd -P -- "${path_entry:-.}" 2>/dev/null && pwd)" || resolved_dir=""
if [[ "$resolved_dir" == "$shim_dir" ]]; then
continue
fi
if (( has_filtered_entry )); then
filtered_path+=":$path_entry"
else
filtered_path="$path_entry"
has_filtered_entry=1
fi
done
PATH="$filtered_path"
export PATH
next_tmux="$(type -P tmux || true)"
if [[ -z "$next_tmux" ]]; then
echo \(tmuxShellQuote(downstreamTmuxMissing)) >&2
exit 127
fi
exec "$next_tmux" "$@"
"""
// Claude Code can replace PATH with a shell snapshot after launch. Its snapshot keeps
// cmux's managed per-surface command-shim directory, so install tmux beside the existing
// claude shim instead of relying on a separate launcher-only PATH entry. The environment
// only identifies the candidate: the write remains bound to cmux's canonical temporary
// root, and neither managed directory component may be a symlink.
if let managedRoot = claudeTeamsManagedShimRoot(
processEnvironment: processEnvironment,
launchContext: launchContext
) {
do {
try writeShimIfChanged(
script,
to: managedRoot.appendingPathComponent("tmux", isDirectory: false)
)
return managedRoot
} catch {
// Informational launches do not create teammates, so they may use the
// launcher-only compatibility directory below. Real Teams sessions must
// keep tmux in Claude's managed snapshot PATH.
}
}
guard claudeTeamsIsNonLaunchInvocation(commandArgs: commandArgs) else {
throw CLIError(message: managedTerminalRequiredMessage(displayName: "Claude Teams"))
}
return try createTmuxCompatShimDirectory(
directoryName: "claude-teams-bin",
tmuxShimScript: script
)
}
private func claudeTeamsManagedShimRoot(
processEnvironment: [String: String],
launchContext: TmuxCompatLaunchContext?,
fileManager: FileManager = .default
) -> URL? {
guard let surfaceId = normalizedTmuxTarget(launchContext?.surfaceId),
isUUID(surfaceId),
let rawRoot = normalizedTmuxTarget(processEnvironment["CMUX_CLAUDE_WRAPPER_SHIM_ROOT"]),
let rawClaudeShim = normalizedTmuxTarget(processEnvironment["CMUX_CLAUDE_WRAPPER_SHIM"]) else {
return nil
}
let managedRoot = URL(fileURLWithPath: rawRoot, isDirectory: true).standardizedFileURL
let claudeShim = URL(fileURLWithPath: rawClaudeShim, isDirectory: false).standardizedFileURL
// The app installs this per-surface root before shell startup. Shell profiles
// are allowed to change TMPDIR, so re-deriving the root here would reject the
// app-installed directory even though its socket-validated surface identity is
// still current. Treat that injected root as canonical, then validate its full
// shape, ownership, permissions, and file relationships before writing to it.
let trustedParent = managedRoot.deletingLastPathComponent().standardizedFileURL
guard trustedParent.lastPathComponent == "cmux-cli-shims",
managedRoot.lastPathComponent == surfaceId,
isOwnedNonSymlinkDirectory(trustedParent, fileManager: fileManager),
isOwnedNonSymlinkDirectory(managedRoot, fileManager: fileManager),
claudeShim == managedRoot.appendingPathComponent("claude", isDirectory: false),
isNonSymlinkExecutableFile(claudeShim, fileManager: fileManager) else {
return nil
}
let tmuxShim = managedRoot.appendingPathComponent("tmux", isDirectory: false)
if let attributes = try? fileManager.attributesOfItem(atPath: tmuxShim.path) {
guard attributes[.type] as? FileAttributeType == .typeRegular,
((attributes[.referenceCount] as? NSNumber)?.intValue ?? 1) <= 1 else {
return nil
}
}
return managedRoot
}
private func isOwnedNonSymlinkDirectory(_ url: URL, fileManager: FileManager) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
return false
}
guard values.isDirectory == true,
values.isSymbolicLink != true,
let attributes = try? fileManager.attributesOfItem(atPath: url.path),
(attributes[.ownerAccountID] as? NSNumber)?.uint32Value == geteuid() else {
return false
}
let permissions = (attributes[.posixPermissions] as? NSNumber)?.uint16Value ?? 0o777
return permissions & 0o022 == 0
}
private func isNonSymlinkExecutableFile(_ url: URL, fileManager: FileManager) -> Bool {
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
return false
}
return values.isRegularFile == true
&& values.isSymbolicLink != true
&& fileManager.isExecutableFile(atPath: url.path)
}
}
-8
View File
@@ -415,12 +415,4 @@ extension CMUXCLI {
return ordered.joined(separator: ":")
}
struct TmuxCompatFocusedContext {
let socketPath: String
let workspaceId: String
let windowId: String?
let paneHandle: String
let paneId: String?
let surfaceId: String?
}
}
+10 -1
View File
@@ -334,6 +334,10 @@ extension CMUXCLI {
`set auto` clears the pin immediately. `cycle` advances the manual override
one lane forward (todo → working → needs-attention → review → done → todo).
Note for coding agents: manual status pins belong to the user; the lane
already tracks your activity automatically. Do not `set` or `cycle` the
status unless the user explicitly asks you to.
Lanes: todo, working, needs-attention, review, done
Examples:
@@ -346,9 +350,14 @@ extension CMUXCLI {
static let todoUsage = String(localized: "cli.todo.usage", defaultValue: """
Usage: cmux todo <subcommand> [--workspace <id|ref|index>] [--window <id|ref|index>] [--json]
Per-workspace checklist, writable by you and by agents. Targets the
Per-workspace checklist shown in the sidebar and todo pane. Targets the
caller's workspace by default. Items are capped at 50 per workspace.
Note for coding agents: this checklist belongs to the user. Do not add,
edit, complete, remove, or replace items on your own initiative — only
manage it when the user explicitly asks you to. Use your own internal
task tracking for your plans.
Subcommands:
add "text" [--state <pending|in-progress|completed>] [--origin <user|agent>]
list Print items (1-based indexes) and progress
@@ -0,0 +1,234 @@
import Darwin
import Foundation
extension ClaudeHookSessionStore {
private static let maxSupersededCleanupBatchSize = 4
private static let maxPendingSupersededCleanupRecords = 128
private static let maxSupersededCleanupAttempts = 8
func supersededSessionCleanupCandidates(
in state: inout ClaudeHookSessionStoreFile,
keepingSessionId: String,
owner: ClaudeHookSessionRecord
) -> [ClaudeHookSessionRecord] {
state.pendingSupersededSessionCleanup.removeValue(forKey: keepingSessionId)
guard let pid = owner.pid,
let startSeconds = owner.pidStartSeconds,
let startMicroseconds = owner.pidStartMicroseconds else {
return []
}
// Demote every superseded claimant in the locked store transaction;
// only the external socket cleanup is deliberately batch-limited.
let superseded = state.sessions.values.filter {
$0.sessionId != keepingSessionId
&& $0.pid == pid
&& $0.pidStartSeconds == startSeconds
&& $0.pidStartMicroseconds == startMicroseconds
}
let supersededIDs = Set(superseded.map(\.sessionId))
let enqueuedAt = Date().timeIntervalSince1970
for var record in superseded {
state.sessions.removeValue(forKey: record.sessionId)
record.supersededCleanupEnqueuedAt = enqueuedAt
record.supersededCleanupLastAttemptAt = nil
record.supersededCleanupAttemptCount = 0
state.pendingSupersededSessionCleanup[record.sessionId] = record
}
if !supersededIDs.isEmpty {
state.activeSessionsByWorkspace = state.activeSessionsByWorkspace.filter {
!supersededIDs.contains($0.value.sessionId)
}
state.activeSessionsBySurface = state.activeSessionsBySurface.filter {
!supersededIDs.contains($0.value.sessionId)
}
}
trimPendingSupersededSessionCleanup(in: &state)
return claimPendingSupersededSessionCleanupCandidates(in: &state, owner: owner)
}
func pendingSupersededSessionCleanupCandidates(
for owner: ClaudeHookSessionRecord
) throws -> [ClaudeHookSessionRecord] {
try withLockedState { state in
claimPendingSupersededSessionCleanupCandidates(in: &state, owner: owner)
}
}
private func claimPendingSupersededSessionCleanupCandidates(
in state: inout ClaudeHookSessionStoreFile,
owner: ClaudeHookSessionRecord
) -> [ClaudeHookSessionRecord] {
normalizePendingSupersededSessionCleanupMetadata(in: &state)
trimPendingSupersededSessionCleanup(in: &state)
let orderedRecords = state.pendingSupersededSessionCleanup.values.sorted {
switch ($0.supersededCleanupLastAttemptAt, $1.supersededCleanupLastAttemptAt) {
case (nil, .some):
return true
case (.some, nil):
return false
case let (.some(lhs), .some(rhs)) where lhs != rhs:
return lhs < rhs
default:
break
}
let lhsEnqueuedAt = $0.supersededCleanupEnqueuedAt ?? $0.updatedAt
let rhsEnqueuedAt = $1.supersededCleanupEnqueuedAt ?? $1.updatedAt
if lhsEnqueuedAt != rhsEnqueuedAt {
return lhsEnqueuedAt < rhsEnqueuedAt
}
if $0.startedAt != $1.startedAt {
return $0.startedAt < $1.startedAt
}
return $0.sessionId < $1.sessionId
}
var candidates: [ClaudeHookSessionRecord] = []
for record in orderedRecords {
guard Self.sameProcessGeneration(record, owner)
|| Self.processGenerationIsConfirmedDead(record) else {
continue
}
candidates.append(record)
if candidates.count == Self.maxSupersededCleanupBatchSize {
break
}
}
guard !candidates.isEmpty else { return [] }
// Claiming a batch advances its durable retry order before external
// socket work begins. Failed records therefore rotate behind records
// that have not been tried yet, and concurrent hooks do not repeatedly
// select the same oldest four.
var claimed: [ClaudeHookSessionRecord] = []
var attemptedAt = Date().timeIntervalSince1970
for candidate in candidates {
guard var current = state.pendingSupersededSessionCleanup[candidate.sessionId],
current.pid == candidate.pid,
current.pidStartSeconds == candidate.pidStartSeconds,
current.pidStartMicroseconds == candidate.pidStartMicroseconds,
current.workspaceId == candidate.workspaceId,
current.surfaceId == candidate.surfaceId,
current.updatedAt == candidate.updatedAt,
current.supersededCleanupEnqueuedAt == candidate.supersededCleanupEnqueuedAt,
current.supersededCleanupLastAttemptAt == candidate.supersededCleanupLastAttemptAt,
current.supersededCleanupAttemptCount == candidate.supersededCleanupAttemptCount else {
continue
}
current.supersededCleanupLastAttemptAt = attemptedAt
current.supersededCleanupAttemptCount = (current.supersededCleanupAttemptCount ?? 0) + 1
attemptedAt = attemptedAt.nextUp
state.pendingSupersededSessionCleanup[candidate.sessionId] = current
claimed.append(current)
}
return claimed
}
private func normalizePendingSupersededSessionCleanupMetadata(
in state: inout ClaudeHookSessionStoreFile
) {
for sessionId in Array(state.pendingSupersededSessionCleanup.keys) {
guard var record = state.pendingSupersededSessionCleanup[sessionId] else { continue }
if record.supersededCleanupEnqueuedAt == nil {
record.supersededCleanupEnqueuedAt = record.updatedAt
}
if record.supersededCleanupAttemptCount == nil {
record.supersededCleanupAttemptCount = 0
}
state.pendingSupersededSessionCleanup[sessionId] = record
}
}
private func trimPendingSupersededSessionCleanup(
in state: inout ClaudeHookSessionStoreFile
) {
state.pendingSupersededSessionCleanup = state.pendingSupersededSessionCleanup.filter { _, record in
(record.supersededCleanupAttemptCount ?? 0) < Self.maxSupersededCleanupAttempts
}
guard state.pendingSupersededSessionCleanup.count > Self.maxPendingSupersededCleanupRecords else {
return
}
let keptSessionIDs = Set(
state.pendingSupersededSessionCleanup.values
.sorted {
let lhsEnqueuedAt = $0.supersededCleanupEnqueuedAt ?? $0.updatedAt
let rhsEnqueuedAt = $1.supersededCleanupEnqueuedAt ?? $1.updatedAt
if lhsEnqueuedAt != rhsEnqueuedAt {
return lhsEnqueuedAt > rhsEnqueuedAt
}
return $0.sessionId > $1.sessionId
}
.prefix(Self.maxPendingSupersededCleanupRecords)
.map(\.sessionId)
)
state.pendingSupersededSessionCleanup = state.pendingSupersededSessionCleanup.filter {
keptSessionIDs.contains($0.key)
}
}
private static func sameProcessGeneration(
_ lhs: ClaudeHookSessionRecord,
_ rhs: ClaudeHookSessionRecord
) -> Bool {
guard let pid = lhs.pid,
let startSeconds = lhs.pidStartSeconds,
let startMicroseconds = lhs.pidStartMicroseconds else {
return false
}
return rhs.pid == pid
&& rhs.pidStartSeconds == startSeconds
&& rhs.pidStartMicroseconds == startMicroseconds
}
private static func processGenerationIsConfirmedDead(_ record: ClaudeHookSessionRecord) -> Bool {
guard let pid = record.pid,
pid > 0,
pid <= Int(Int32.max),
let startSeconds = record.pidStartSeconds,
let startMicroseconds = record.pidStartMicroseconds else {
return false
}
var info = proc_bsdinfo()
let expectedSize = MemoryLayout<proc_bsdinfo>.stride
let size = proc_pidinfo(pid_t(pid), PROC_PIDTBSDINFO, 0, &info, Int32(expectedSize))
if size == expectedSize {
return Int64(info.pbi_start_tvsec) != startSeconds
|| Int64(info.pbi_start_tvusec) != startMicroseconds
}
if Darwin.kill(pid_t(pid), 0) == 0 || errno == EPERM {
return false
}
return errno == ESRCH
}
func acknowledgeSupersededSessionCleanup(_ candidates: [ClaudeHookSessionRecord]) throws {
guard !candidates.isEmpty else { return }
let candidatesByID = Dictionary(uniqueKeysWithValues: candidates.map { ($0.sessionId, $0) })
try withLockedState { state in
var acknowledgedIDs: Set<String> = []
for (sessionId, candidate) in candidatesByID {
guard let current = state.pendingSupersededSessionCleanup[sessionId],
current.pid == candidate.pid,
current.pidStartSeconds == candidate.pidStartSeconds,
current.pidStartMicroseconds == candidate.pidStartMicroseconds,
current.workspaceId == candidate.workspaceId,
current.surfaceId == candidate.surfaceId,
current.updatedAt == candidate.updatedAt,
current.supersededCleanupEnqueuedAt == candidate.supersededCleanupEnqueuedAt,
current.supersededCleanupLastAttemptAt == candidate.supersededCleanupLastAttemptAt,
current.supersededCleanupAttemptCount == candidate.supersededCleanupAttemptCount else {
continue
}
state.pendingSupersededSessionCleanup.removeValue(forKey: sessionId)
acknowledgedIDs.insert(sessionId)
}
guard !acknowledgedIDs.isEmpty else { return }
state.activeSessionsByWorkspace = state.activeSessionsByWorkspace.filter {
!acknowledgedIDs.contains($0.value.sessionId)
}
state.activeSessionsBySurface = state.activeSessionsBySurface.filter {
!acknowledgedIDs.contains($0.value.sessionId)
}
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
struct ClaudeHookSessionStoreFile: Codable {
var version: Int = 1
var sessions: [String: ClaudeHookSessionRecord] = [:]
// Superseded records stay durable for retry without remaining visible to
// store consumers as simultaneously live session claimants.
var pendingSupersededSessionCleanup: [String: ClaudeHookSessionRecord] = [:]
var activeSessionsByWorkspace: [String: ClaudeHookActiveSessionRecord] = [:]
// The pane-scoped active boundary. The workspace slot only remembers ONE
// active session, so once another pane promotes (e.g. a forked conversation
// in a split), it can no longer prove that a late hook from a superseded
// session in this pane is stale. Keyed by surface id.
// https://github.com/manaflow-ai/cmux/issues/5908
var activeSessionsBySurface: [String: ClaudeHookActiveSessionRecord] = [:]
var agentHookFailureReportTimestamps: [String: TimeInterval] = [:]
enum CodingKeys: String, CodingKey {
case version
case sessions
case pendingSupersededSessionCleanup
case activeSessionsByWorkspace
case activeSessionsBySurface
case agentHookFailureReportTimestamps
}
init() {}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
sessions = try container.decodeIfPresent([String: ClaudeHookSessionRecord].self, forKey: .sessions) ?? [:]
pendingSupersededSessionCleanup = try container.decodeIfPresent(
[String: ClaudeHookSessionRecord].self,
forKey: .pendingSupersededSessionCleanup
) ?? [:]
activeSessionsByWorkspace = try container.decodeIfPresent(
[String: ClaudeHookActiveSessionRecord].self,
forKey: .activeSessionsByWorkspace
) ?? [:]
activeSessionsBySurface = try container.decodeIfPresent(
[String: ClaudeHookActiveSessionRecord].self,
forKey: .activeSessionsBySurface
) ?? [:]
agentHookFailureReportTimestamps = try container.decodeIfPresent(
[String: TimeInterval].self,
forKey: .agentHookFailureReportTimestamps
) ?? [:]
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(version, forKey: .version)
try container.encode(sessions, forKey: .sessions)
if !pendingSupersededSessionCleanup.isEmpty {
try container.encode(pendingSupersededSessionCleanup, forKey: .pendingSupersededSessionCleanup)
}
if !activeSessionsByWorkspace.isEmpty {
try container.encode(activeSessionsByWorkspace, forKey: .activeSessionsByWorkspace)
}
if !activeSessionsBySurface.isEmpty {
try container.encode(activeSessionsBySurface, forKey: .activeSessionsBySurface)
}
if !agentHookFailureReportTimestamps.isEmpty {
try container.encode(agentHookFailureReportTimestamps, forKey: .agentHookFailureReportTimestamps)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
struct CodexHookFailureCandidate {
let message: String
let codexErrorInfo: String?
let additionalDetails: String?
let isStreamError: Bool
}
+5
View File
@@ -0,0 +1,5 @@
struct CodexHookFailureSummary {
let statusValue: String
let subtitle: String
let body: String
}
+4
View File
@@ -0,0 +1,4 @@
struct CodexHookUserInputCandidate {
let callId: String
let question: String?
}

Some files were not shown because too many files have changed in this diff Show More