The status endpoint now reports, per account, how many sessions are
bound with traffic inside the routing load window, plus the account's
cooldown deadline. The coderouter CLI uses both to make sticky routing
and rate-limit cooldowns visible. The session-count read is display
only and fails to zero so it can never take the status endpoint down.
Pricing change: a team may connect and route up to 3 provider
accounts (subscriptions) for free. More than 3 requires an active
cmux Pro or Team subscription.
- New services/coderouter/entitlement.ts: free tier checked first
(one indexed count read); the Stripe read runs only for teams over
the limit. Both checks fail closed.
- Session issuance: replaces the flat Pro gate. Over-limit teams
without a subscription get 402 with the count in the message.
- Account add: connecting an account beyond the limit without a
subscription returns 402 before anything is stored. Re-importing
an account the team already has is always allowed, so a broken
account stays repairable on the free tier. The accounts POST
handler is now factory-built for dependency-injected tests.
- Billing-lapse token revocation stays unchanged: after a lapse the
CLI renews and the free tier re-qualifies teams with <= 3 accounts.
- New analytics: entitlement_basis on route_session_issued and a
coderouter_account_limit_reached event, both schema-whitelisted.
- CODEROUTER_HOSTED_PRO_REQUIRED=0 still disables all gating.
Boundary: exactly 3 accounts is free; the 4th needs Pro/Team.
A bound account in state 'refreshing' is healthy; its refresh lease
resolves in seconds. Treat it as usable for sticky reuse, and give a
sticky session a short bounded wait (4 x 500ms) when the refresh lease
is busy, instead of instantly moving the session and discarding its
prompt cache. Non-sticky requests keep the fail-fast move.
If the code deploys before the additive migration is applied, the
session-stickiness statements hit undefined_table (42P01). Treat that
as: no binding found, claim without the session-load ordering term, and
skip the pin. Routing behaves exactly like the pre-change code until
the migration lands, instead of erroring every Codex request. Covered
by a DB behavior test that renames the table away and back.
Port of subrouter PR #228 to the coderouter TypeScript data plane.
Problems in the current routing:
- selectAccountForRequest picks an account per request with three
independent statements (sweep, select, update). Nothing spans the
read-pick-write sequence, so parallel session starts read the same
snapshot and herd onto one account. That account burns quota first,
then every session reroutes to the next account at once.
- No session stickiness exists. Each request rotates to the
least-recently-used account, so the provider prompt cache is
discarded on nearly every turn and the whole prefix is re-billed
as uncached input across all accounts.
Fix:
- New coderouter_session_accounts table pins one agent session (the
Codex CLI session_id header) to one account.
- selectAccountForSession honors a usable binding first (sticky), and
moves a session only when its account is broken, cooling down,
removed, or already attempted in this request.
- New placements claim an account atomically in one statement with
FOR UPDATE SKIP LOCKED, ordered by fewest recently-active bound
sessions, then least-recently-used. Concurrent claims take
different accounts instead of the same snapshot argmax. When every
candidate row is locked, a blocking fallback claim accepts a
collision instead of failing the request.
- selectAccountForRequest (models, opencode) now uses the same atomic
claim, which closes its read-pick-write race too.
Tests: unit tests for the selector and the responses proxy, and
CMUX_DB_TEST-gated behavior tests that prove sequential spread,
concurrent non-herding, stickiness, move-on-cooldown, exclusion,
and last-write-wins binding against real Postgres.
The lane is fleet infrastructure we operate, not contributor guidance for this
codebase, so the prose now lives in cmuxterm-hq at
skills/infra/blacksmith-testbox/ beside macfleet and cloud-vm-ops. See
manaflow-ai/cmuxterm-hq#306.
Everything the box actually executes stays here: both workflows, the five
scripts/blacksmith-*.sh helpers, and the two guards that validate them. Only
tests/test_testbox_doc_blocks.sh follows the skill, because it validates that
prose, and hq gained a workflow so it keeps gating rather than quietly stopping.
CLAUDE.md keeps a pointer rather than dropping the subject, because an agent
starting cmux-tui Rust work reads this file and must still learn not to compile
on the Mac. Worth stating plainly: all nine trial agents found this skill from a
local file in this repo, and a pointer to another checkout is weaker
discoverability, particularly from a plain cmux clone.
Review follow-up: replace the 0/1 ternaries with an exhaustive switch into
DiagnosticConnectionMethod so a future third method becomes a compile error
instead of silently reporting as Tailscale.
Co-Authored-By: Claude Fable 5 <[email protected]>
Review follow-up: inject a fixed date instead of wall-clock Date() so
route-selection behavior in these tests is deterministic.
Co-Authored-By: Claude Fable 5 <[email protected]>
The bundle-isolation rollout (#9183) records iOS builds without the
X-Cmux-App-Namespace header as legacy. Those are the shipped pre-namespace
Beta binaries on the default lane, and they lost the official-namespace
default->{default,nightly} exception: discovery still listed a Nightly Mac,
but issuePairGrant denied it with target_not_pairable, surfacing on the
phone as Authorization failed while relay policy and reachability passed.
Legacy callers on the default lane now get the same default+nightly Mac
reach as official namespaced apps, applied through the single
canIOSBindingUseMac choke point so pair grants and proofed discovery agree.
Non-default legacy lanes keep exact tag matching, and canIOSBindingForgetMac
is split off the alias so the destructive forget path is not broadened.
Co-Authored-By: Claude Fable 5 <[email protected]>
Old Beta builds predate the X-Cmux-App-Namespace header and binding request
proofs, so the broker records them as legacy/default. They currently lose the
official-namespace default->nightly exception and get target_not_pairable
against Nightly Macs. These tests pin the intended compatibility: pairing and
discovery gain the fallback, non-default legacy lanes keep exact matching, and
the destructive forget_mac path is not broadened.
Co-Authored-By: Claude Fable 5 <[email protected]>
Revalidating the demo after the set-difference rewrite passed the build (2m13s
then 0.14s, box stopped, exit 0), but the org check afterwards showed its warmup
run still in_progress, holding a 32 vCPU runner. The skill calls cancelling a
required step and the script did not do it, so the one entry point most people
will run was the one leaking.
It records the run it approves and cancels it from the same EXIT trap that stops
the box, so a Ctrl-C cleans up both.
I added the cargo test line to the skill without running it. A concurrency
trial ran it and it fails on a fresh box: 104 failures, exit 101, all one root
cause. `blacksmith testbox run` gives a shell at umask 0002, so test directories
are created group-writable, and cmux-remote's secure-directory check correctly
rejects an ancestor writable by other users without the sticky bit.
Hosted CI runs at umask 022, so the suite passes there and fails here. The suite
is not umask-independent, which means this lane does not reproduce CI unless the
command says so. With `umask 022` prepended, 3504 tests pass in about 88s.
Builds and clippy are unaffected.
Two agents dispatching nine seconds apart deadlocked. The guard correctly
refused to guess between two identical waiting gates, then told the operator to
"approve yours in the UI". An agent has no UI, and nothing in the REST API binds
a run to a Testbox ID, so the recovery was unusable by the caller the lane is
built for. One trial burned 11 minutes and an extra box getting out of it.
Snapshot the waiting gates before dispatching and take the set difference after.
That is exact where a 120 second window is not, since another operator lands
inside any window you pick. On the rare true tie the instruction is now
deterministic and needs no browser: stop your box, re-snapshot, dispatch again.
Also record the trap the same trial nearly fell into. Correlating a box to a run
by timestamp looks right and is wrong: Blacksmith rewrites a box's CREATED value
as it hydrates, measured moving 05:54:43 to 05:55:03 to 05:58:48 on one box. The
authoritative binding is the RUN URL column, which appears only once the box is
ready.
SKILL.md, benchmark.md, and the demo script now share the one algorithm.
Every trial exercised the evidence path, so the skill only ever showed
cargo build. An agent's real task is usually cargo test or clippy, which use the
same warm target/ and need no stage helper; show them.
Move the trust-boundary reasoning to references/trust-boundary.md and keep the
operative summary inline. An agent about to build needs the rule and the
enforcement, not the full argument. SKILL.md is 11.7 KB, still above the 10 KB
guideline; the remaining bulk is the approval block, and the eighth trial proved
that moving an executable step away from the file that needs it strands a live
box, so it stays.
Ninth trial ran all eight documented blocks literally, in order, each exiting 0,
repairing nothing. Two cosmetic inaccuracies left, both mine.
The EXIT trap printed "Testbox <id> is still running; no stop was authorized"
at the end of a run whose ceremony had already stopped the box, because it
inferred liveness from CONFIRM_TESTBOX_STOP_SHA being unset rather than from
the inventory it had just captured. On a clean run that reads as a failure. It
checks list-at-exit.log now.
And I cited the changed-file overhead gap as "several times larger, 8.1s against
1.2s". It measured 1.48s this run with no change to the lane. The guidance not
to read that gap as sync overhead stands; the magnitude does not, so the text no
longer promises one.
Eighth trial ran the documented blocks literally, as instructed, and the
warmup block stranded a live 32 vCPU box. `grep -c .` exits 1 when the count is
zero, and the plan sets `set -euo pipefail`, so the assignment
`waiting_count="$(... | grep -c .)"` ended the script on the first poll. Zero
waiting runs is the normal state right after warmup returns, so the 150s poll
loop was unreachable dead code, and the exact failure commit 8d3edf5b28 set out
to fix happened again, one round later, in the fix itself. There is no error
text; the script simply stops, leaving a warmed box, an un-approved run, and a
receipt the trap correctly refuses to act on.
Guarded every count with `|| true` in both files.
tests/test_testbox_doc_blocks.sh now treats the plan as executable
documentation: it parses all 21 fenced bash blocks under the shell the plan
mandates, then executes each counting construct against an empty result to prove
it survives its normal first state. Verified red against the unguarded line and
green after. Syntax checking could never have caught this, which is why two
rounds of review missed it.
Two more from the same trial. benchmark.md never cancelled the warmup run, so
following it alone leaves the keepalive holding a runner until the 120 minute
timeout; it now cancels and polls to terminal state. And the setup artifact is
required evidence whose command sat in prose rather than a fenced block, so a
literal run never captured it.
Seventh trial ran smooth end to end, zero unexpected failures, and still found
that benchmark.md's warmup block goes straight from the receipt to
`status --wait`. The approval existed only in the surrounding prose, so anyone
copying the block parks at the environment gate, burns the full 15 minute
timeout, and strands a warmed 32 vCPU box. Prose next to a code block does not
protect someone running the code block. The approval, with the polling guard and
its own attempt transcript, is now in the code, along with the DISPATCH_EPOCH
capture it binds to. Removed the unreachable second warmup_status check while
there.
Also qualified the overhead column. The changed-file gap measured 8.1s against
1.2s for the other stages while all three synced strategy=skip, because that
stage's backup, edit, restore, and re-verify work runs inside the CLI call but
outside wall_seconds. It is real, it is not sync and transport, and
operations.md now says to compare overhead only across first-clean and
incremental-noop.
Every bash block in the plan is now syntax-checked as a unit.
Sixth trial reported the run as not smooth, and it was right. The guard I wrote
in SKILL.md Step 3 queries for waiting runs the instant warmup returns. GitHub
had not surfaced the run yet, so zero matched, and the guard printed "more than
one run waiting; approve yours in the UI" and exited. Zero runs is not
ambiguity, it is "not yet". Under benchmark.md's set -e that abort left a live,
un-approved box on the org inventory with its receipt already written, and the
plan's own trap is designed to refuse to stop it.
The guard now polls for 150s, distinguishes zero from two-or-more, and says
which happened. It also tells you that an abort means you already own a running
box, so stop it before retrying or the next dispatch leaves two boxes and one
receipt.
The trap also lied about why it gave up: it printed "warmup returned no owned
Testbox receipt" when the receipt existed and the real cause was
CONFIRM_TESTBOX_STOP_SHA being unset, which is the normal state. It now reports
the condition that actually held, and names the box you must stop yourself. Its
inventory file was called list-after-warmup-failure.log even on the success
path; it is list-at-exit.log now.
Two smaller ones. Record every approval attempt separately, because a retry that
overwrites the first hides a live unapproved box from the evidence pack. And
warm-before-reading has an exception for evidence runs, whose preflight and
receipt must exist before warmup; three agents in a row hit that ordering.
Finally, changed-file rebuilds cmux-remote as well as cmux-tui, so its ~8s is a
small-edit figure, not a single-crate floor.
Fifth trial ran the lane smoothly end to end: every documented command exited
as documented, no retries, no procedural guessing, and the only non-zero exits
were the two the docs predict. 132.25s clean, 0.17s no-op, 8.03s changed-file,
box and runner both proven gone. The artifact check added last round worked: it
recorded a 746 MB binary, 48 bytes larger in the changed-file stage.
Two defects were mine from the previous commit. The artifact prose said
cmux-tui-testbox-<run-id> while the command beside it correctly said
cmux-tui-testbox-setup-<run-id>, so following the prose downloads nothing. And
required field 3 asked for an "identity run ID" for a run the same document
forbids issuing; it now says there is no such run and names the real transcripts.
The third is older and worse. operations.md promised two clocks and described
the second as the local CLI transcript covering sync, transport, and queueing.
No pack ever contained it: stage `wall_seconds` is measured on the box around
cargo, which is why it sits milliseconds above `/usr/bin/time -p` real rather
than well above it, and three separate agents nearly cited it as CLI wall time.
The plan now records the real local clock per stage in <stage>.cli-wall.txt, and
operations.md says plainly that a pack without it cannot measure Testbox
overhead at all.
Fourth trial agent ran the plan with zero non-zero exits and no retries
(127.03s clean, 0.17s no-op, 8.02s changed-file, box and runner proven gone),
then found the ordering defect that matters most: benchmark.md printed the
three-stage loop BEFORE the pin instruction, so following that file top to
bottom benchmarks main rather than your branch. SKILL.md had the right order,
so the two documents disagreed. The pin now has its own section ahead of the
loop, and says plainly that running the loop first measures main.
The stage helper now verifies the build produced cmux-tui/target/debug/cmux-tui
and records its size in each stage JSON. A zero exit proves cargo was happy, not
that anything was produced, and the box is destroyed before anyone can check.
Also: name the setup artifact and give the gh run download command, instead of
"keep the artifact URL" with no artifact name; document that gh run cancel takes
about five minutes to land, so in_progress right after cancelling is not a
failure; and explain the two surfaces that look contradictory after a stop, that
status still prints a completed row while list shows nothing, and that
setup-identity.json says X64 where stage records say x86_64 for the same host.
MacSurfaceGalleryPreviewView (#if DEBUG) builds TerminalPickerMenuActions
without the required selectSimulatorStream closure, so every Debug iOS
build from main fails compiling CmuxMobileShellUI while Release archives
skip the file and stay green. Missed in the PR 10072 partial merge, same
family as #10287/#10290/#10295.
Co-Authored-By: Claude Fable 5 <[email protected]>
Third agent ran the whole plan with no failure, no retry, and every exit code
the docs predict, including the intended 75 from PREVIEW: 129.97s clean, 0.18s
no-op, 9.28s changed-file, box and runner both proven gone. What it found was
that the evidence pack the plan produces cannot be fully trusted.
`blacksmith auth whoami` writes to stderr, so the plan's bare invocation put no
org identity in the pack while the field list implied one; it captures stderr
now. `timings.json` hardcoded schema 2 while stage records self-report schema 3,
and operations.md still described stage records as schema 2, so the wrapper now
carries stage_record_schema and the prose matches the code.
The guard naming a historical evidence directory pointed at a path that does not
exist and could not: `blacksmith-testbox-e40704611ac35f4ffa153` is the first 13
characters of that SHA glued to its last 8, and it lacks the mandatory suffix. A
guard you cannot resolve protects nothing, so both files now state the rule
itself: never write into an evidence directory you did not create this run. The
superseded 161.47s/8.28s/9.13s numbers go with it; three trials have since
measured this lane directly.
The collision-avoidance branch was dead code. It tested for
blacksmith-testbox-<sha> while every directory the plan creates is
blacksmith-testbox-<sha>-<suffix>, so the timestamp path never fired and two
runs of one SHA from one PID collided. It globs the suffix now.
Finally, Blacksmith's CREATED column is a last-transition time, not a creation
time; the same box reported three different values across hydrating, ready, and
stopped. Say so, and point at the status --wait transcript for elapsed time.
Second independent agent, given only "produce defensible benchmark evidence",
found the skill from CLAUDE.md and ran the full plan first try: 129.37s clean,
0.17s no-op, 9.69s changed-file, box and runner both confirmed gone. The
receipt-bound cleanup fixed in the previous commit worked for it. Five things
still failed it.
Two were code. The post-stop poll wrote to status-before-stop.log, so the
evidence pack's own pre-stop record was overwritten with post-stop state and an
auditor would misread when the box existed; it writes status-after-stop.log now.
And cleanup-preview.json paired warmup_ref with the benchmarked source_sha,
so the destruction record named a SHA that is not on the ref beside it; it
records both refs now, schema 2, and the regression test asserts it.
Three were documentation. benchmark.md bills itself as the full plan but never
mentioned the deployment gate, so its warmup block deadlocks for 15 minutes on
`status --wait`; it now points at the guarded approval and warns against
approving workflow_runs[0]. The PREVIEW cleanup exits 75 on success, which the
same plan's mandated `set -e` turns into an abort right before cleanup; both
call sites say so and show the `set +e` wrapper. And SKILL.md claimed stopping
the box ends the warmup run, which is false: the measured run sat in_progress
for four minutes afterwards holding a 32 vCPU runner, so cancelling is now a
required step rather than a hedge.
makeSecondaryClient picked routes with a bare storedReconnectRoutes call,
so with Connection Method = Tailscale the multi-Mac aggregation pool still
pinned stored Iroh routes and dialed background-control sessions over
public paths and managed relays; broker-discovered secondaries took the
same path. Route through orderedReconnectRoutes, which applies the same
strict Tailscale requirement as the foreground dial: grant-authorized
Tailscale routes still aggregate, everything else fails closed and waits
for a new external edge.
Zero-touch Iroh discovery now returns no candidates while Tailscale is
selected, skipping broker round-trips whose iroh-only results could never
be dialed.
Co-Authored-By: Claude Fable 5 <[email protected]>
With Connection Method = Tailscale, background multi-Mac aggregation still
pins a stored Iroh route and opens an Iroh background-control session over
public paths and managed relays. makeSecondaryClient builds its routes
without the Tailscale requirement, bypassing the strict filter every other
dial path applies. Failing test only; the fix lands in the next commit.
Co-Authored-By: Claude Fable 5 <[email protected]>
A transport report today only implies the connection method and carrier
transport through whichever dial events survived the bounded ring; reading
one still needs a Settings screenshot. Record the configured method
(connectionMethodConfigured) at store construction and on every foreground
so any report window states it, decode the existing
connectionMethodPreferenceChanged value into the same readable method name,
and record foregroundTransportSelected with the active route's transport on
connect and on every route change, covering both state-then-route and
route-then-state connect flows plus mid-connection promotions.
Reports now carry lines like:
App feature event (Operation: connectionMethodConfigured, Method: Tailscale Only)
App feature event (Operation: foregroundTransportSelected, Transport: Iroh)
en+ja catalog entries for the new method and field names.
Co-Authored-By: Claude Fable 5 <[email protected]>
A subagent given only "compile cmux-tui on Linux" found the skill from
CLAUDE.md and followed it to real numbers, then reported what broke.
The receipt-bound cleanup could never succeed. #10303 pinned warmup to
--ref main, so the inventory REF column is always main, while the receipt
recorded source_ref, the branch being benchmarked. cleanup.sh compared the two
and exited 66 with "inventory ownership context differs from the warmup
receipt", for every legitimate user. That pushed operators toward a bare stop
and away from the ownership check the docs spend a page justifying. The receipt
now carries warmup_ref and source_ref separately and cleanup compares the
inventory against warmup_ref. tests/test_testbox_cleanup_receipt_ref.sh drives
the real script against a stubbed CLI; it reproduces the old exit 66 and passes
on the fix.
The approval snippet took workflow_runs[0]. Every run in this lane shares a
title and a main head branch, and the agent found a stranger's run waiting two
minutes ahead of its own, so the snippet would have approved someone else's
deployment gate. It now binds to a run created after your own dispatch and
refuses when more than one is waiting, matching the demo script's guard.
Also: `list --all` is not proof nothing is burning, because the keepalive holds
a 32 vCPU runner after the box is gone, so step 7 checks the run too and shows
`gh run cancel`; say which of the two cleanup paths is authoritative and when;
state that warmup does not block; put the --idle-timeout unit on the command;
and note that the aggregator is all-three-stages or nothing.
Running a script named "testbox demo" is the consent. The spend is bounded by
--idle-timeout and by the EXIT trap that stops the box, so a prompt whose answer
is always yes was noise. It also aborted confusingly with no TTY, because `read`
returns non-zero under `set -e`. Print what is about to happen instead.
Reading a runbook does not tell you what a persistent build box feels like.
scripts/blacksmith-testbox-demo.sh warms one, pins it to your pushed HEAD,
builds cmux-tui twice, and prints both times so the second build makes the
point the prose cannot.
It prints every remote command before running it, so the tour doubles as the
documentation. It stops the box it created from an EXIT trap, including on
Ctrl-C, and it only ever stops that box. Deployment approval matches runs
dispatched within two minutes of its own warmup and refuses to guess when more
than one is waiting, so it cannot approve someone else's run. --stages swaps
the two plain builds for the three measured stages.
The skill opened with its threat model and buried the commands, so an agent
about to compile cmux-tui learned the lane existed only after it had already
waited. Lead with the operating rule instead: warm your own box in the first
minute, before reading code, because the four minutes of hydration are four
minutes you can spend reading.
Then a seven-step CLI walkthrough covering warmup, deployment approval, pinning
the box to your commit, building or benchmarking, download, and stop. One box
per worktree and per agent, since `testbox run` synchronizes with
`rsync --delete` and two agents on one ID overwrite each other.
SKILL.md drops from 19.7 KB to 8 KB. Stage orchestration, the cleanup ceremony,
and how to read the two clocks move to references/operations.md; the full
evidence plan stays in benchmark.md. Index the skill in CLAUDE.md, which #10303
missed, so agents can find it at all.
Testing the broker's cross-commit path on a live box disproved a claim I wrote
into the skill. `blacksmith testbox run` synchronizes file contents, not
history: it makes one opportunistic `git fetch` of the benchmarked commit, falls
back to copying changed files, and skips even that once fingerprints match. A
local-only commit fails on the box with `upload-pack: not our ref`, leaving the
box on the `main` checkout the warmup job made, with candidate file contents
written over it. The stage helper then died on a raw `git rev-parse
<sha>^{tree}` error.
Require the pushed commit, and pin the box to it once per box with fetch plus
`git reset --hard` before the first stage. That makes the box an exact checkout
of the benchmarked revision, so a timing always names something anyone can
fetch, and an uncommitted edit can never be what got built. The helper now
verifies the commit is present and checked out, and prints the exact push and
pin commands instead of a git internals error.
Ubuntu 24.04 ships shellcheck 0.9, which reports trap-only functions as
SC2317; 0.10 renamed that to SC2329, which is what the file disabled. The new
lint job runs on the runner's shellcheck, so it flagged all 15 lines of the two
trap handlers. Name both codes.
The main CI suite is dispatch-only while CI is paused, so tests/test_ci_testbox_broker_guard.py
would not have run on a pull request that weakened the lane it guards. Give it
a small always-on workflow that also shellchecks the four helper scripts and
actionlints the warmup workflow with a checksum-pinned actionlint.
No path filter, deliberately. A change that moves or renames the guard is
exactly the change a path filter would let through.
The property that keeps candidate code out of the token-bearing job is a
workflow shape, and no runtime check can observe it without spending a 32 vCPU
box. Assert it statically: manual dispatch only, no input that selects a
source revision, a refs/heads/main guard ahead of begin-testbox, no repository
script or local composite action before the token, every checkout pinned to
github.sha, SHA-pinned actions, and a keepalive that runs from the checked-in
main script.
Verified red against the previous candidate-controlled workflow (two failures:
the candidate-selecting inputs and the missing main guard) and green here.
blacksmith testbox warmup resolves the workflow definition and the hydrated
source from one --ref, so a lane that warms a candidate branch runs that
branch's copy of the workflow before begin-testbox writes the Testbox auth
token into the job. A candidate could therefore delete its own guards.
Hydrate main only. The first step refuses any ref except refs/heads/main, and
no repository code runs before the token. A candidate revision reaches the box
afterwards through blacksmith testbox run, which syncs a maintainer's worktree
onto the warm VM and needs Blacksmith org credentials that already grant box
access, so it moves no trust boundary.
The hydrated commit and the benchmarked commit are now deliberately different.
The stage helper checks the setup marker for VM identity, runner class, and
toolchain completeness instead of source equality, records the hydrated ref and
SHA under a new "hydration" block, and still fails closed when the active Rust,
Cargo, or Zig differs from what warmed the caches.
The lane no longer needs BLACKSMITH_TESTBOX_REVIEWED_REF or
BLACKSMITH_TESTBOX_REVIEWED_SHA; the environment needs a deployment branch rule
of exactly main.
Tab identity was re-derived on every read from either the live surface or
the slot indexes, so each reader invented its own missing-data policy: the
projection failed hard, `rebuild_resource_indexes` silently dropped the tab,
and the browser branch required a live surface. A silent drop is the worst
of the three, because the next projection tombstones durable rows that are
still live.
`State::register_tab_identity` is now the writer every placement path uses,
including `insert_surface_checked` and the browser attach path, which
previously left identity to be harvested from the surface at the next index
rebuild. The reserved-placement install in `resource_project_terminal_selected`
still writes its own placement order, which `register_tab_identity` must not
reorder; it writes the same identity fields.
Every reader now takes identity from the topology: the index rebuild no
longer consults surfaces, the projection resolver reads the owner, and the
layout-undo token and active-tab lookup drop their surface fallbacks.
`State::ensure_tab_identity_coverage` runs at the projection boundary, so a
missing identity fails that mutation by name instead of erasing durable rows.
Browser tabs now project from their durable row when their runtime is gone,
matching terminals. That closes the same failure for a browser view whose
surface never materialized.
`ordered_terminal_tab_ids` demanded a live surface for every pane tab, while
the projection loop right below it already accepted a restored tab that had
no surface yet. Startup restores tabs before adoption, so an unadoptable host
leaves a tab with no surface, and the ordering pre-pass aborted the whole
projection with "pane references missing surface <slot>".
The abort happened inside the exit-detach commit that was supposed to prune
that terminal, so nothing was written and every later start repeated it. One
orphan host recovered, because removing it made the tree consistent; two or
more behind the same pane wedged the session forever.
Both call sites now resolve tab identity through one helper that prefers the
live surface and falls back to the durable indexes.
Also commit the durable exit before deleting the host record in the two
startup paths that proved a host dead. The record is the only evidence that
the host existed, so a failed commit must not erase it first.
A daemon restart that finds several unadoptable terminal hosts in the same
pane must still start. Today the first exit-detach projects the whole tree
while the other restored tab still has no surface, so startup aborts with
"pane references missing surface <slot>" and writes nothing, which makes the
session permanently unopenable.
Fourth and fifth Release-archive breaks from 04ff18eea6: WorkspaceDetailView
and its extensions reference store.selectedMacSurfaceID and
store.selectMacSurface(_:), which were never implemented on
MobileShellComposite (MobileShellCompositePreviewTests already encodes the
contract: starts nil, selectMacSurface sets it without touching
selectedTerminalID, workspace switch clears it). effectiveConnectionStatus
was private in WorkspaceDetailView.swift but used from
WorkspaceDetailView+Surfaces.swift, a different file.
Adds the stored property plus the clearing hook in selectedWorkspaceID.didSet,
the selectMacSurface mutator in MobileShellComposite+SurfaceFocus, and drops
the private modifier.
Co-authored-by: Claude Fable 5 <[email protected]>
supportsPanelArtifacts(in:) exposes MobileWorkspacePreview.ID publicly, but
the file imported CmuxMobileShellModel with a plain import, which is internal
under InternalImportsByDefault. The Release device archive rejects it (method
cannot be declared public because its parameter uses an internal type); Debug
simulator builds do not, which is why test-ios stayed green. Matches the 24
sibling files in this package that already use public import.
Co-authored-by: Claude Fable 5 <[email protected]>
* fix(ios): make artifact failure and scope switches exhaustive
04ff18eea6 added ChatArtifactError.unknown(code:) and the .panel loader
scope but missed two switches, so the cmux-ios scheme has not compiled
since Aug 14 and every iOS TestFlight upload since build 20260814211222
has failed. Adds the .unknown failure presentation (localized en/ja,
never blames connectivity) and routes .panel loader scope to the .panel
viewer scope.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): map unknown artifact errors in MarkdownSurfaceModel
The same 04ff18eea6 change left a third non-exhaustive switch in
MarkdownSurfaceModel.failure(for:), hidden behind the CmuxAgentChatUI
compile failure because CmuxMobileShellUI builds after it. Map
.unknown(code:) to .loadFailed(code:), whose doc contract is exactly
this case (the Mac answered with an unrecognized error), preserving the
code for display.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
CodeRabbit follow-ups: the UI test now also asserts the open-url capture
stays at exactly one entry through the settle window, and
debugSimulateCommandClick no longer exposes the open-url dispatch state
in its payload (nothing consumed it).
Co-Authored-By: Claude Fable 5 <[email protected]>
Root cause: one cmd-click could be handled by two independent open paths.
When the click landed on a link Ghostty recognizes (URL regex or OSC 8
hyperlink), Ghostty consumed the release and dispatched its open_url
action synchronously inside ghostty_surface_mouse_button; cmux routed it
through TerminalLinkOpenCoordinator. Back in handleCommandClickRelease,
the cmd-click word-path fallback ALSO ran: its guard deliberately lets
pointer-snapshot resolutions through when Ghostty consumed the release,
because consumption alone can also mean mouse reporting or prompt clicks.
Both paths then opened the same target, each with its own routing
decision, so with app.preferredEditor configured an image opened in both
Preview (coordinator, raw NSWorkspace) and the preferred editor
(fallback, PreferredEditorService) at once.
Fix, at the routing layer:
- GhosttyNSView now counts Ghostty open_url dispatches; the release path
snapshots the counter around the ghostty_surface_mouse_button call.
When it advanced, Ghostty owns the click's open and the word-path
fallback stays out entirely. The snapshot exception still applies to
consumed-but-not-link releases (mouse reporting, prompt clicks), so
cmd-click on paths inside TUIs keeps working.
- TerminalLinkOpenCoordinator routes local-file external opens through
the shared FileOpening seam (PreferredEditorService) instead of raw
NSWorkspace, so the single chosen handler is the preferred editor when
configured and the system default otherwise, matching every other file
entrypoint. Non-file URLs still open through the system opener.
Co-Authored-By: Claude Fable 5 <[email protected]>
Two failing behavior tests reproducing issue #10222:
- TerminalLinkOpenCoordinatorTests: with app.preferredEditor configured, a
local file that is not routed inside cmux must go through the
preferred-editor seam, never the raw system opener. Fails today because
TerminalLinkOpenCoordinator hands local files to NSWorkspace directly,
ignoring the preferred editor.
- TerminalCmdClickUITests: cmd-clicking an OSC 8 file hyperlink must reach
exactly one handler. Fails today because Ghostty consumes the click and
dispatches its open-url action AND the cmd-click word-path fallback also
opens the pointer's resolved path, delivering the same click to two
handlers (e.g. Preview and the preferred editor at once).
Co-Authored-By: Claude Fable 5 <[email protected]>
* test: reproduce ambiguous iOS dogfood identity
* fix(ios): pin dogfood auth identity end to end
* docs: use every Mac fleet slot for heavy work
* fix(ios): reject agent profile on physical iPhone gates
* fix(ios): preserve profile resolution and auth replacement
* fix(ios): stop tagged Mac before auth relaunch
* fix(auth): gate Mac session replacement on resolved credentials
* fix(auth): reuse matching tagged Mac sessions
* fix(ios): preserve queued builds and secret isolation
* fix(auth): restart stale Mac for default device launch
* fix(ios): preserve queue fallback checkout
* fix(auth): close profile and tagged app teardown gaps
* fix(auth): use actor-backed app termination wait
* fix(ios): clarify separate build and launch argument contracts
* fix(auth): use bootstrap-owned Mac auth readiness
* fix(auth): launch tagged Mac with workspace environment
* fix(ios): mark unauthenticated queue intent explicitly
* fix(ios): authenticate staged queue deliveries
preservingDefaults and the socket zoom test captured object(forKey:),
which reads through the registration domain; after BrowserPanel
registers browser fallbacks, restoring would persist a fallback for a
key that was never written. Snapshot the persistent domain instead.
Co-Authored-By: Claude Fable 5 <[email protected]>
Adds browser.defaultZoomLevel (0.25–5.0, default 1.0) as a shared
BrowserZoomSettings policy: applied at webview creation and prewarm
adoption, used by the Actual Size/reset action, editable in Settings >
Browser, configurable via cmux.json, and documented in the schema and
key reference. browser.zoom.set now also accepts an absolute numeric
zoom and a `surface` alias for surface_id, mirrored in the CLI
(`cmux browser zoom 0.8`).
Fixes#10155
Co-Authored-By: Claude Fable 5 <[email protected]>
* Prevent Mac sleep while agents run or iPhone is connected
Keeps the Mac reachable for the iOS app and long-running agent tasks by
holding a single PreventUserIdleSystemSleep power assertion (display
still sleeps) while either gate is active:
- power.preventSleepWhileAgentsRunning.enabled (default off): any
workspace has a registered agent PID (SleepyAgentCensus).
- power.preventSleepWhileMobileConnected.enabled (default on): at least
one iOS client connection (MobileHostConnectionRegistry).
Mechanism: new PowerAssertionHolder extracted from SleepyModeController
(which now uses two holders, behavior unchanged); new PreventSleepManager
recomputes a pure decision (PreventSleepDecision) on agent-model changes,
per-TabManager tabsPublisher emissions, mobile host status changes, and
settings flips, and acquires/releases the assertion idempotently.
Released on app termination.
Settings: two toggles in the Mobile section (Keep Awake group), catalog
keys in cmux.json, search aliases, EN+JA localization.
Validated empirically on a Mac mini (macOS 26.5): with 1-minute idle
sleep armed and protections removed the machine slept and dropped off
Tailscale (brief DarkWake windows only); with a headless process holding
this exact assertion, 157/157 reachability probes over 8 minutes passed
with zero sleep entries.
* Address review: fix stale-tab reconcile, gate agent census behind setting
- tabsPublisher emits during willSet, before tabManager.tabs commits.
Reconcile now counts agents from the emitting manager's new tab list
(override) instead of the stale census, so closing the last agent
workspace releases the sleep assertion immediately.
- Agent observation (tab subscriptions, per-workspace change streams,
census scan) now attaches only while the default-off agents setting
is enabled; disabled users pay no observer fanout.
- PreventSleepManager owns its own UserDefaults observer; AppDelegate
keeps only start()/stop() lifecycle calls (budget +2 accepted).
- Register the two Keep Awake rows in the settings search anchor
contract list (fixes SettingsRowAnchorResolutionTests).
* Make power assertion logger nonisolated, align subsystem with app convention
* Gate mobile keep-awake on authenticated connections; bound agent-event fanout
- MobileHostServiceStatus gains authenticatedConnectionCount (connections
with at least one authorized request, from clientIDsByConnectionID);
activeConnectionCount is unchanged for existing consumers. The service
posts mobileHostStatusDidChange on the unauthenticated->authenticated
transition and after authenticated-connection teardown so keep-awake
reconciles at both edges.
- PreventSleepManager's mobile gate now uses the authenticated count via
a lean accessor (no statusSnapshot route resolution per reconcile), so
unauthenticated LAN peers holding TCP sessions cannot pin the power
assertion.
- Per-agent runtime events no longer trigger app-wide sweeps: the manager
maintains per-workspace running-agent counts, updates only the emitting
model's count, and reconciles only when that count changes. Full rebuilds
happen only on topology/settings/mobile syncs. SleepyAgentCensus reverts
to main (helper no longer needed).
- Unit test: authenticatedConnectionCount tracks authorized connections
only (no double-count per connection, drops on close).
* Only record client IDs for verbs that required authorization; lean mobile reconcile
- onAuthorizedRequest fires for auth-exempt verbs too (authorizeRequest and
authorizationError both return nil for mobile.host.status), so a spoofed
client_id on the unauthenticated status verb was recorded and counted as
an authenticated connection, letting an unauthenticated LAN peer hold the
keep-awake assertion. Both listener sites now skip recording unless
requiresAuthorization(method:) is true.
- PreventSleepManager's mobile status observer now runs the constant-work
reconcile instead of a full topology sync; mobile events cannot change
workspace topology or agent observation.
* Count authenticated connections at the authorization gate, not the client-id hook
mobile.events.subscribe is authorized but intercepted before
onAuthorizedRequest, and subscribe requests need not carry client_id, so a
phone connected only for live events counted 0 and keep-awake released the
assertion mid-stream. Connections are now marked authenticated in the
authorizeRequest gate itself (any auth-required verb that passes), tracked
in a dedicated authorizedConnectionIDs set that backs
authenticatedConnectionCount; client-id bookkeeping stays as-is for
viewport cleanup. Set cleared on listener adoption/stop/debug-reset and on
connection close (with a status post so keep-awake drops promptly). Test
updated: client-id recording alone no longer counts, gate marking does.
* Refuse stale authenticated-connection inserts after mid-authorization disconnect
authorizationError suspends on network Stack verification; a client that
disconnects during that await has already been cleaned up by onClose, so an
unconditional insert in recordAuthorizedConnection would never be removed
and would pin the keep-awake assertion until the listener restarts. The
record now requires the connection to still be tracked (activeConnections
or the connection registry); record and removal both run on the main actor,
so either interleaving converges. Registry gains contains(id:). Test covers
the refused stale insert via a real liveness gate (never-started dummy
session registered through a DEBUG seam).
* Make PreventSleepDecision a value type instead of a top-level function
Mirrors MobileHostSyncDecision/MobileHostPortApplyOutcome: the pure policy
lives on a value type with stored inputs and an isDesired property, giving
the power feature a scoped API instead of a module-wide free function.
* Replace new debug seams with internal members reached via @testable import
Aziz test/debug-seam policy (cmux PR #6452 precedent): the two seams added
for authorized-connection tests are removed; recordAuthorizedConnection and
activeConnections are internal instead, and the test registers its
never-started dummy session directly.
* Add Keep Mac Awake control on Mac and iOS
* Tolerate headless app activation in caffeine UI test
* Fix iOS caffeine settings compilation
* Use mobile localization support in caffeine settings
* Remove reintroduced disabled toast setting
* Initialize task composer setting on iOS
* Test iOS Keep Mac Awake control
* Capture Keep Mac Awake menu evidence
* Fix iOS test Foundation import
* Revert "Fix iOS test Foundation import"
This reverts commit 37897fcf59.
* Harden mobile caffeine status handling
* Annotate caffeine RPC handlers on main actor
* Harden ambiguous mobile caffeine mutations
* Report failed caffeine mutations accurately
* Keep caffeine retry available after unknown failure
* Isolate caffeine controller injection on main actor
* Fence caffeine snapshots by state revision
---------
Co-authored-by: Abdulaziz Albahar <[email protected]>
- Dock setSurfaceResumeBinding no longer runs the mutating
managedAgentResumeBinding sync-on-read before the Codex acceptance
guard: a rejected incoming binding must not promote the effective
binding to managed state as a side effect of the check.
- Dock setSurfaceResumeBinding now drops the panel's transient
resume-session working-directory rescue when the binding replaces a
different session, mirroring the Workspace path, so close/reopen
cannot launch a replacement session in the previous session's cwd.
- The fork SessionStart/SessionEnd regression tests assert exact
resume-set / resume-clear request counts, so an extra publication
targeting the parent surface cannot slip past the last-request checks.
Co-Authored-By: Claude Fable 5 <[email protected]>
The Workspace group row in the new-workspace composer fell back to the
folder SF Symbol, nearly identical to the Directory row's folder.fill
directly above it. Use rectangle.3.group (the glyph already used for
group actions) so the row reads as a group of workspaces instead of a
second directory picker.
Co-authored-by: Claude Fable 5 <[email protected]>
Forked Claude tabs kept the parent session id: identity could be minted
from the --resume launch argument (process-scanner fork fallback), from
launch-argv sniffing in the hook CLI, and hooks could stamp identity onto
the workspace's focused pane. Fork-of-fork and close/reopen then resumed
the wrong conversation.
Now only an authoritative Claude SessionStart on its owning surface can
install or replace that surface's session identity:
- upsertAuthoritativeClaudeSessionStart installs the record and the
active-surface boundary in one locked transaction; stale late
startup/resume events are rejected unless the owner allows replacement
or the incoming process is demonstrably newer; /clear stays an
ordering barrier.
- Every Claude hook handler requires an authoritative delivery target;
focused-pane fallback can no longer receive another process's identity.
- The accepted SessionStart immediately publishes the surface resume
binding and projects it into the restorable-agent snapshot used by
close history and workspace restore, so a fork cannot retain its
parent's restore state.
- The Claude launch-argument identity fallback is removed from the
process scanner and hook CLI; the generic fork-parent fallback remains
for Codex, where the resumed id genuinely is the session id.
Test fixtures now model the new registration: SessionStart records the
pid on the hook record, so live-process evidence flows from the record,
never from --resume argv; a fail-closed hook that never wrote the store
file counts as "no record".
Closes#10156
Co-Authored-By: Claude Fable 5 <[email protected]>
Preserve one pre-Iroh Tailscale fallback when the registry refresh is Iroh-only, keep registry-published routes authoritative, and serialize forced method-switch reconnects.
* Add task composer workspace group routing
* Move task composer group helpers to file scope
* Preserve restored task composer workspace group
* Gate task composer groups on authoritative inventory
* Require explicit task group recovery
* Fix task group capability scoping
* Fail closed on incomplete group owners
* Invalidate stale group authority
Scale daemon uploads to the binary size, isolate bootstrap SSH traffic from stale ControlMasters, and clean remote writers and temporary files when transfers fail. Preserve process timeout details so workspace bootstrap errors remain actionable.
Follow-up to #10107: the Add account rows should copy-paste the per-tool
install commands (npx coderouter@latest add codex / add opencode) instead of
the cr routing commands, and render the command text visibly in the row.
Co-authored-by: chatmux-connections[bot] <chatmux-connections[bot]@users.noreply.github.com>
- Drop MoshRemoteIPMode's unused cliValue initializer and Codable
conformance: no CLI flag or persisted setting selects an IP mode, so
the speculative API only implied a configuration surface that does
not exist. Production always starts from .remote with the automatic
proxy fallback.
- Make the fish login-shell tests report an explicit skip via
.enabled(if:) instead of silently passing when fish is absent.
Co-Authored-By: Claude Fable 5 <[email protected]>
Add `attach` to claude_builtin_command_name so the wrapper classifies
`claude attach <id>` as a command invocation and execs the real CLI with argv
untouched, like agents/mcp/etc. Hook settings are meaningless for attach
anyway: hooks live in the already-running background writer process, and the
injected fresh --session-id is exactly what turned an attach into a new
session.
Co-Authored-By: Claude Fable 5 <[email protected]>
`claude attach <id>` (the attach door for --bg background sessions) is a real
subcommand but hidden from `claude --help`, so it's missing from the wrapper's
builtin-command list. The wrapper classifies it as a session entrypoint and
injects --session-id/--settings ahead of it, which makes the real CLI treat
"attach" as the [prompt] positional: instead of attaching, it mints a brand-new
session with "attach" pre-filled in the composer.
Test-only commit (two-commit regression policy): CI should go red here.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Move iOS TestFlight into its own sidebar section, placed below coderouter
- Move coderouter Add account above Connected accounts
- Show copy-pastable commands for Codex (cr codex) and OpenCode (cr opencode)
with their logos instead of the generic npx/cmux add commands
Co-authored-by: chatmux-connections[bot] <chatmux-connections[bot]@users.noreply.github.com>
- Dashboard home now shows an iOS app card (next to CodeRouter) that
links to the TestFlight page.
- Remove the Stack <TeamSwitcher> organization picker from the
bottom-left account area so only the cmux account menu remains.
- Add dashboard.home iOS strings to all locales and update the account
menu tests for the retained menu surface.
Co-authored-by: pi (manaflow-ai) <[email protected]>
mosh-server executes the received command with execvp and no shell, so
the staged launcher must be ['/bin/sh', '-c', script] instead of one
'/bin/sh -c ...' string that execvp treats as a literal pathname. The
OpenSSH string form is unchanged.
When SSH_CONNECTION is unusable, fall back to Mosh's SSH-proxy address
resolution instead of local mode: local mode resolves the destination
via DNS and breaks SSH-config-only aliases such as port-forwarded Coder
workspaces. Validate only the SSH_CONNECTION shape Mosh actually parses
(four fields, numeric ports, usable server address, now including
loopback rejection), not the unused peer address.
Co-Authored-By: Claude Fable 5 <[email protected]>
mosh-server executes the remote command argv with execvp and no shell,
so a single '/bin/sh -c ...' string is treated as a literal pathname.
Also pin the address fallback to Mosh proxy resolution and validate
only the SSH_CONNECTION fields Mosh actually parses.
Co-Authored-By: Claude Fable 5 <[email protected]>
Release the Mac terminal viewport lease when the iOS scene deactivates, clear stale local viewport metadata, and restore fresh geometry on reactivation.
Advance Bonsplit to the restored legacy pasteboard payload
(manaflow-ai/bonsplit#219): pane/browser drop overlays decode
tab.id/kind/sourcePaneId/sourceProcessId again, so the blue drop zones
render and drops route. Also share one tab-drag capability registry
between workspaces and the Dock so bonsplit tab-strip destinations can
resolve drags that began in another controller, matching 0.64.22 where
any destination could decode the drag.
Fixes#10033
Co-Authored-By: Claude Fable 5 <[email protected]>
Ends cancelled tab drags immediately instead of after AppKit's snap-back
animation, so drag state is revoked before the next press.
Bonsplit PR: manaflow-ai/bonsplit#218
Co-Authored-By: Claude Fable 5 <[email protected]>
Tab drags silently never started when the press arrived with
clickCount >= 2 — the common click-to-select-then-immediately-drag flow.
Bonsplit PR: manaflow-ai/bonsplit#217
Fixes#10033
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add failing tests for dropping onto a collapsed/empty group header
A workspace dragged from outside a group can only land inside it by
hitting an insertion gap that belongs to the group row scope. When the
group has no visible member row under its header - collapsed, or a group
whose only member is its anchor - the resolver treated the header bottom
edge as a group/root boundary and handed it to the ambiguous horizontal
lane rule, so the left half of the header silently planned a root slot
beside the group instead of inside it.
These tests pin the intended behavior and fail on the current resolver.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Adopt into the group when a drag is released on its header row
A group header row's lower half is the group's own adopt zone: releasing
there should put the workspace inside the group. It already did while the
group's members were rendered below the header, because the resolver
confirmed the group scope by looking at the next visible row. When there
is no member row under the header - the group is collapsed, or its only
member is its anchor - that lookup failed and the drop fell through to
the ambiguous group/root horizontal lane rule, so the left half of the
header planned a root slot beside the group instead. The gap that would
have accepted the workspace is the one the collapse is hiding, leaving no
discoverable way to drop into a collapsed group.
Treat a group header as an unambiguous group target on its bottom edge
and stop consulting the next visible row there; only member rows can sit
on a real group/root boundary. The header's top half still plans the root
slot before the group, so inserting above a leading group keeps working.
The fix lands in the shared resolver, so the AppKit sidebar table, the
SwiftUI reorder overlay, and the mobile move-intent resolver all pick it
up from one path.
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* iPhone auth gate: hard-fail unauthenticated dogfood installs
Installed-but-signed-out was a docs rule (PR 10001) but not enforced;
agents kept handing off phone builds sitting on the login screen. Three
mechanical changes:
1. Hard-fail: scripts/mobile-dev-launch.sh device launches default to
--ensure-mac, and the post-launch readiness wait (mobile.rpc.ready with
the device's dogfood client id) is now the iPhone auth gate: failure
exits non-zero with the reason and exact retry command. --no-attach /
--no-sign-in / --no-setup / --no-launch on the device leg are refused
unless CMUX_ALLOW_UNAUTHENTICATED_INSTALL=1, which is human-only
(agents never set it; same convention as CMUX_ALLOW_LOCAL_XCODEBUILD).
ios/scripts/reload.sh forwards --no-attach explicitly and reports the
verified auth state in its device summary.
2. Verification verb: scripts/verify-iphone-auth.sh --tag <tag>
[--device-id <id>] relaunches the installed app WITHOUT injecting
credentials or a ticket and passes only if persisted sign-in + pairing
produce a usable RPC session with the tagged Mac (PASS/FAIL + reason +
retry command, no screenshots). It writes the same readiness receipt as
a gate pass.
3. Queue truthfulness: an auth-failed drain parks the entry in needs-auth/
(kept, retryable via the new `retry --tag <tag>` verb) instead of
failed/, and cmux notify reports the TRUE state ("VERIFIED signed in +
paired" vs "installed but SIGN-IN FAILED: <reason>" with the retry
command). A fresh readiness receipt is required to count a drain as
verified, so a launcher that lies with exit 0 still parks as
needs-auth. Unauthenticated enqueues need the human-only allowance,
recorded in the entry for the headless LaunchAgent drain.
Install invocations are marked CMUX_SANCTIONED_IPHONE_INSTALL=1 for the
cmuxterm-hq local-build-guards devicectl interceptor
(manaflow-ai/cmuxterm-hq#297), which refuses raw devicectl installs on the
personal iPhone; merge this PR before that one.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: no-attach never reports verified; receipt freshness by removal
- ios/scripts/reload.sh: a --no-attach signed launch (human-authorized
opt-out) now reports UNVERIFIED in the device summary instead of
"verified signed in + paired" (the gate never ran on that path).
- iphone-install-queue.sh: remove any pre-existing readiness receipt
before the signed launch, so freshness is existence-after-removal
instead of a whole-second mtime comparison that a same-second stale
receipt could pass.
- verify-iphone-auth.sh: warn explicitly when the receipt cannot be
persisted after a PASS instead of staying silent.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Deferred delivery: locked/offline phone means enqueue-and-notify, exit 75
Agents burned hours in unlock-watcher loops (one takeover: 45min of
cycles; one script: 87 retries over 4 days). Now the device paths defer
promptly instead of retrying or watching:
- mobile-dev-launch.sh probes reachability ONCE before minting; an
offline phone notifies (best-effort cmux notify), prints the exact
retry command, and exits 75 (EX_TEMPFAIL). A launch that fails because
the phone is LOCKED gets the same treatment.
- ios/scripts/reload.sh: the queued outcome (phone unreachable at build
time) now notifies and exits 75 instead of 0 — queued-not-installed is
never reported as success. If the phone locks mid-delivery (launcher
exit 75 after install), the built signed app is parked in the install
queue, the user is notified to unlock, and the reload exits 75.
- iphone-install-queue.sh drain maps launcher exit 75 to keep-pending
(not needs-auth): a locked phone is a delivery deferral, not an auth
failure, and the LaunchAgent's periodic drain retries it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review round 2: fail closed on unclearable receipt, truthful queue claim
- iphone-install-queue.sh: if a stale readiness receipt cannot be removed
before the signed launch, fail the entry instead of risking a verified
claim backed by an old file.
- ios/scripts/reload.sh: the locked-phone deferred path only prints
"queued; unlock to receive" (and exits 75) when the enqueue actually
succeeded; otherwise it errors with the manual retry command.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Preserve --no-attach in the deferred-delivery enqueue
A human-authorized --no-attach launch that hits a locked phone must queue
with the same intent; dropping the flag would escalate the queued drain
to the full ensure-mac paired flow.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test: sidebar description link is unreadable on the selected row
The link run in a workspace description renders in NSColor.linkColor, which
AppKit paints over the row palette. On an active row the sidebar selection
background is that same blue.
Refs https://github.com/manaflow-ai/cmux/issues/9596
* Give SidebarRowTextView ownership of link styling
AppKit paints .link runs in NSColor.linkColor and ignores the explicit
.foregroundColor the row applies, so a URL in a workspace description was
blue on the blue sidebar selection background when the row was selected.
SidebarRowPalette.attributed now moves every web link onto a private
.sidebarRowLink key and styles the run itself: the palette-derived color
plus an underline so it still reads as a link in both states. The active-row
color comes from sidebarSelectedWorkspaceForegroundNSColor, so a custom
sidebarSelectionColorHex stays legible. Covers the workspace description and
the metadata markdown blocks; SidebarRowTextView.linkURL(at:) reads the
private key, and the http(s)-only destination contract moves to a shared
SidebarRowWebLink.
Fixes https://github.com/manaflow-ai/cmux/issues/9596
* Cover the metadata markdown blocks in the row link tests
CodeRabbit: the new tests exercised customDescription only, while the same
SidebarRowPalette.attributed path also renders snapshot.metadataBlocks.
Adds active/inactive coverage asserting the row-owned .sidebarRowLink
destination, the palette foreground, the underline, and that AppKit's .link is
gone, plus an unsafe-scheme case.
* Measure only glyph pixels in the link raster assertions
CodeRabbit: minimumDistance scanned the whole bitmap, so the uniform selection
fill (itself a blue near NSColor.linkColor) could satisfy or break the
assertions without the link glyphs having any bearing on them. Background
pixels are now excluded, and the helper asserts some text pixels were found so
an empty raster cannot pass silently.
* Move sidebar link behavior onto the row text view
* Make sidebar link raster assertions antialiasing-safe
* Preserve sidebar link accessibility semantics
* Give row links actionable accessibility elements
* Test row link accessibility lifecycle
* Expose row links through accessibility tree
* Test stable and hidden row link lifecycles
* Preserve row link accessibility lifecycles
* Test row link semantic reuse and geometry
* Scope row link proxies to semantic owner
* Test pooled row link proxy release
* Release hidden row link accessibility state
* Test inline rename title restoration
* Preserve plain row text while hidden
* Test on-demand row link geometry
* Complete row link accessibility lifecycle
* Bridge row accessibility callbacks to main actor
* Test lazy row link accessibility materialization
* Defer detached row accessibility proxies
* Release pooled sidebar link proxies
* Harden sidebar AppKit lifecycle tests
* Stabilize sidebar rename field editor test
* Test sidebar link ownership lifecycles
* Repair extracted sidebar description compilation
* Fix sidebar link ownership lifecycle
* Test truncated sidebar links stay inaccessible
* Keep inline rename cancellation test focus-independent
* Hide truncated sidebar links from accessibility
* Apply one immutable sidebar link policy
* Keep sidebar description projection lightweight
* Keep sidebar link accessibility demand-driven
* Make sidebar retirement tests event-driven
* Test truncated sidebar link pointer hit
* Fix AppKit popover close test wait
* Update sidebar rename behavior test
* Make popover close wait state-driven
* Ignore truncated sidebar link glyphs
* Fix popover retirement close wait
* Fix checklist popover window lookup
* Test dark sidebar description color resolution
* Resolve sidebar row colors by appearance
* Test row palette preserves dynamic colors
* Preserve dynamic sidebar semantic colors
Agents kept handing off phone builds that were installed but sitting on
the login screen. Installed-but-signed-out is now explicitly a failed
install: every install must be verified past login and paired before
handoff, raw devicectl installs and --no-sign-in are banned for dogfood
builds, and unresolvable sign-in blockers must be reported with the
retry command.
Co-authored-by: Claude Fable 5 <[email protected]>
* test: cover file drops in global Dock sessions
* fix: route file drops through Dock containers
* refactor: harden Dock file-drop routing
* perf: focus Dock file-drop batches once
* fix: address Dock file-drop review findings
* fix: harden Dock drop routing and observation
* fix: finish Dock drop review follow-ups
* fix: keep preview metadata actor-independent
* fix: index Dock surface ownership
* fix: close Dock file-drop review gaps
* fix: compile Dock drop regression coverage
* test: require indexed Dock pane ownership
* test: cover Dock metadata ownership lifecycle
* refactor: centralize Dock preview metadata ownership
* test: cover late Dock drop target discovery
* fix: resolve late pane drop ownership
* test: cover Dock preview session round trip
* portal: invalidate the split-divider hit-test cache on nested subview insertion
The split-divider region cache trusted KVO of NSView.subviews to catch structural
changes, but addSubview does not reliably emit that KVO across macOS versions, so a
split view inserted into a nested container left the cache stale and hit-testing wrong.
Replace the root-only subview-id check with a structure fingerprint over all observed
views (root, its subviews, split ancestors), recomputed on the lookup path; the KVO
observers stay as an eager fast path but correctness no longer depends on them. Same
fix in the Browser portal, which duplicated the cache.
* portal: compare structure snapshots without intermediate arrays
* portal: cover deep-container split insertion and content-root replacement
The divider cache's structure snapshots only track the content root, its direct
children, and views that were split-related when the cache warmed up. Two gaps:
a split inserted under a container two levels deep changes no observed subview
list, and a replaced-but-still-alive content root passes validation because no
snapshot records which root it was built from. Both leave hit-testing on stale
empty regions. Failing tests first; the fix lands in the next commit.
* portal: validate the divider cache with a root-keyed full-tree split digest
The structure snapshots only covered the content root, its direct children,
and views that were split-related when the cache warmed, so a split inserted
under a deeper container changed no recorded subview list and the stale cache
kept winning. They also never recorded which root they were built from, so a
replaced-but-still-alive content root passed validation against the detached
tree. Replace the snapshots with a digest keyed to the root's identity that a
full-tree walk rebuilds on the lookup path: each split's identity, ancestor
chain, arranged subviews, orientation, and effective visibility. An insertion
under any container now misses the cache, while subview churn that cannot
affect dividers still reuses it, and the subviews KVO stays bounded to the
same views as before. Both portals share the digest through
PortalSplitDividerRegion.
* portal: prove cache hits skip large hierarchy traversal
* portal: invalidate divider caches at hierarchy mutations
* portal: scope hierarchy invalidation to cache roots
* portal: nest cache invalidation helpers
* portal: give hierarchy mutation routing one owner
* portal: hook every hierarchy mutation entrypoint
* test: align OMP resume environment expectation
* test: keep install command fixture inert
* test: cover app-host config path aliases
* ci: canonicalize app-host config evidence paths
* fix: resolve Xcode 26.3 Swift warnings
* fix: preserve Xcode 16 delegate compatibility
* test: bound portal hierarchy mutation hook work
* portal: make hierarchy invalidation generation-based
* test: cover no-op sorts and detached portal roots
* portal: preserve cache validity across sort and reattach
* test: cover detached nested portal subtree mutation
* test: calibrate portal mutation work against AppKit
* portal: distrust detached subtree hierarchy state
* fix: fail closed when portal hierarchy hooks are unavailable
* test: drop stale OMP path override from portal branch
* test: cover split-free subviews replacement
* portal: preserve split-free subviews fast path
* test: validate every warmed portal cache
* test: cover arranged split pane mutations
* portal: track arranged split pane mutations
* test: cover detached subtree parking path
* portal: revoke proofs at unindexed hierarchy boundaries
* test: isolate arranged pane mutation cases
* test: cover wrapped detached portal subtree
* portal: track detached indexed subtree mutations
* portal: keep mutation snapshots with their owner
* test: cover inactive portal proof lifetime
* portal: revoke proofs between cache lifetimes
* test: avoid arranged-pane teardown KVO trap
* test: cover detached portal reorder mutations
* test: restore split arrangement before teardown
* test: document detached cache boundary
* test: use valid arranged-pane lifecycles
---------
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: austinpower1258 <[email protected]>
* test: reproduce cmux open PATH loss without Ghostty helper
* fix: keep bundled open first without Ghostty helper
* test: cover bundled bin in wrapper PATH invariant
* test: let fish PATH regression exit deterministically
* test: keep fish PATH coverage optional locally
* Test dashboard organization switching
* Add direct dashboard organization switching
* Test CodeRouter metrics failure alerting
* Alert on CodeRouter analytics query failures
* Test authorized CodeRouter organization discovery
* Expose authorized CodeRouter organizations
* Test permission-filtered dashboard organizations
* Filter dashboard organization switching by permission
* Test CodeRouter review regressions
* Fix CodeRouter organization navigation and failure states
* Test CodeRouter-only Team pricing benefit
* Test removal of unshipped Team benefits
* Test inherited Team cloud allowance
* Limit Team pricing claims to shipped CodeRouter
* Test final CodeRouter review regressions
* Preserve cached orgs and validate analytics JSON
* Test organization cache and response isolation
* Isolate and validate organization catalog state
* Test personal organization selection
* Preserve personal organization selection
* Test URL-scoped permitted organizations
* Scope the switcher to URL-permitted organizations
* Test authoritative organization fallback
* Use the authoritative selected CodeRouter organization
* Test live and personal organization selection
* Use live Stack organization selection
* Test organization cache refresh and switch failure
* Reconcile and report organization switches
* Test filtered selected organization recovery
* Test dashboard Team benefit copy
* Bound organization loading and correct Team upsell
* Test authored pricing locale boundary
* Keep organization authority current
* Test keyboard placement and normalized team scope
* Make organization switching keyboard reachable
* Test concurrent organization switch guard
* Serialize organization switches
* Test empty organization catalog recovery
* Recover from an empty organization catalog
* Update billing copy expectations
* Test organization network and update deadlines
* Bound organization query and update waits
* Keep timed-out organization mutations exclusive
* Test latest organization switch serialization
* Queue organization switches behind active mutations
* Offer safe reload recovery for stuck switches
* Cancel organization switch effects on unmount
* Reconcile failed queued organization switches
* Discard queued switches after a deadline
* Reconcile late switches without replacing navigation
* Make URL scope authoritative during organization switches
* Keep timed-out persistence safely blocked
* Release timed-out switches with late reconciliation
* Test analytics failure classes and account clearance
* Fix account layout and analytics failure handling
* Persist CodeRouter scope independently of Stack
* Scope CodeRouter organization preference per user
* Use portable state directory sync
* Close terminal host and resource atomically
* Satisfy hosted terminal close Clippy
* Test replayed terminal close isolation
* Keep terminal close replays effect-free
* Close durable terminals without runtimes
* Resolve terminal close replay before live state
* Hide terminal close state internals
* Test resource replay close isolation
* Keep resource close replays effect-free
* Fix terminal replay test identity borrows
* Tombstone detached terminals on explicit close
* Brand account UI as cmux
* Remove vendor term from account search metadata
* Revert "Remove vendor term from account search metadata"
This reverts commit 8d008f2514.
* Add regressions for private team usage metrics
* Add private team-scoped CodeRouter usage metrics
* Harden CodeRouter analytics isolation
* Match the deployed PostHog Endpoint dialect
* Make the usage window exactly 30 UTC days
* Accept both PostHog Endpoint row encodings
* Upgrade web to Next.js 16.3 instant navigation
* Add regressions for auth loading and Vault gating
* Gate Vault and restore Stack Auth rendering
* Add regressions for CodeRouter pricing and Vault gating
* Ship CodeRouter branding behind Vault pricing flag
* Keep Vault flag tests isolated
* Fix instant server and pricing test isolation
* Add iPhone simulator pane control
* Wire mobile simulator control files into app target
* Fix mobile simulator observer lookup
* Use IrohLib for endpoint signing
* Fix mobile simulator stream control
* Fix simulator stream tap policy lint
* Address simulator stream review feedback
iOS control-plane correctness: per-panel serialized start/stop operation
chains on the composite (background stop can no longer interleave a
foreground restart), failed start attempts settle back to idle instead of
spinning forever, simulator selection stops the previous panel before
starting the new one, and a pane's onDisappear deactivates only its own
panel so A->B switches keep B selected.
Ownership integrity: descriptor ownership is now tri-state; broadcast
payloads (state-sync rows, workspace lists) carry unknown ownership, so a
shared tick cannot flip the owning phone to view-only or locked mid-drag.
Passive simulator.state events merge descriptors without promoting idle
panels to starting.
Input and rendering: pointer events flow through one buffered stream drained
in order (no reordered began/moved/ended), and frame base64+image decode
moved off the main actor with forced decompression.
Mac host: advertised simulator capabilities gate on the same feature flag as
RPC dispatch, the stream coordinator prunes cached frames and workspace
mappings for closed panels, and a session whose frame delivery is refused
ends itself so the panel lock releases immediately.
Also drops @unchecked from the frame reader's now-checked Sendable, removes
a redundant initializer, renames the mapper's clamped: parameter to
allowsOutsideImage:, and adds focused tests for the new store behavior.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Move Iroh peer-identity derivation into CmuxIrohTransport
The app target imported IrohLib directly for SecretKey.fromBytes but never
links the IrohLib product, so the hosted test build failed with undefined
symbols for arm64 (the tagged reload only linked by toolchain accident).
CmuxIrohTransport owns the IrohLib dependency, so the derivation now lives
on CmxIrohIdentityMaterial there and the app consumes the public property.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Retry dogfood attach after startup teardown
* Improve mobile simulator tap precision
* Fix mobile simulator capability lint
* Fix mobile simulator input hit testing
* Fix simulator app switcher action
* Keep simulator frame stream live under backpressure
* Add simulator diagnostics telemetry
* Wire simulator diagnostics into macOS target
* Make simulator diagnostics queue test selectable
* Settle failed stream preflight and clear stale locked ownership
A start attempt that exits through the preflight guard (disconnected,
missing capability, or no client) now settles the activation spinner
back to idle, and a locked start rejection clears ownership remembered
from an earlier start so controls cannot stay live under the overlay.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Detect stalled simulator streams and self-heal on the phone
The dogfood freeze: the phone's transport died (direct path lost, relay
fallback, 30s idle timeout), the Mac cleanly released the stream, but the
pane kept showing the last frame under an 'iPhone Control' pill with no
way to tell it was dead. Nothing in the stream layer detected silence.
Mac side now emits simulator.state on a 5s cadence while a session is
active (capability simulator.keepalive.v1), so clients can treat event
silence as staleness without misreading a static Simulator screen; the
keepalive's frame-send request also retries a refused frame that no new
publication would retrigger. The phone arms a capability-gated watchdog
per active panel: a 15s interval with no frame or state event marks the
pane visibly stalled ('Reconnecting to Simulator' overlay, EN+JA) and
re-requests the stream through the serialized per-panel chain, retrying
each silent interval. Stalled is sticky until a fresh frame arrives so a
recovering pane cannot masquerade as live.
Also wires the previously dead includingSimulator parameter so simulator
capabilities are actually omitted when the feature flag is off.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add consolidated on-disk AppLog split from network diagnostics
AppLog (CMUXMobileCore) persists the structured diagnostic ring to two
rotating 5MB files in Application Support, always on since events are
integer-encoded and privacy-safe: cmux-app.log carries everything
app-wide (simulator, browser, composer, render, plus the mirrored
string debug log) and cmux-network.log carries transport dials,
discovery, relay policy, paths, and session lifecycle; app lifecycle
and reachability context land in both. The composition root chains the
ring's event tap into AppLog ahead of the Sentry reporter and mirrors
MobileDebugLog's line stream, so one file tells the in-app story in
wall-clock order. Consecutive frame-pipeline events coalesce into a
'repeated xN' summary when the run breaks. Both files are shareable
from Iroh connection settings (strings EN+JA, plus the previously
uncataloged verbose-log strings).
Simulator diagnosis hardening: a stalled watchdog fire now records its
own DiagnosticSimulatorStreamLifecycle.stalled event (warning-level in
Sentry, so stalls become breadcrumbs and budgeted logs); identical
simulator.state keepalives short-circuit as .unchanged in the stream
store, feeding the watchdog without flooding the ring, breadcrumbs, or
disk every 5s; and the Mac keepalive only retries frames when a reader
exists, avoiding a readerMissing diagnostic per tick on panels without
a frame transport.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Await actor-isolated debug log line stream
Co-Authored-By: Claude Fable 5 <[email protected]>
* Move log sharing to a top-level Diagnostics settings section
The app log covers every feature and the network log covers all
connection diagnostics, so neither belongs on the Iroh screen; that
screen keeps only its connection report and verbose-connection-log
toggle.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fall back to appending when log rotation fails
A failed .1 move followed by createFile truncated the current log in
place, erasing exactly the diagnostics a user might be about to share.
The writer now reopens the existing generation for appending and
retries rotation on later appends.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Discard unused frame-publication handler results
The two clear-handler calls kept their tokens implicitly, tripping the
zero-budget warning bucket for this new file in CI's warning gate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Bound failed-rotation fallback: no header spam, no offset-0 writes
A sustained rotate failure now retries only after another full byte
budget accrues (threshold watermark instead of per-line attempts), the
fallback writes no extra session header, and a generation that cannot
be opened and positioned at its end disables writing instead of
overwriting from offset zero.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add regression test: workspace search selection stays inside the search tab
Selecting a workspace from the search tab's results on iOS could strand
the app on the Workspaces list with no tab bar, no toolbar, no search
field, and a stale query filter: the selection deactivated search,
transitioned to the Workspaces tab, and pushed onto that tab's
NavigationStack while it was still off-window behind the search-field
dismissal, so the path recorded a push that never happened.
The test drives the exact repro: activate search, type a query that
filters to one workspace, tap the result, pop back, and require the app
to still be inside the search tab with usable bottom controls.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Open workspace search selections inside the search tab
Selecting a workspace from search results previously committed the draft
query, transitioned to the Workspaces tab, and pushed the detail onto
that tab's NavigationStack from onChange handlers that can run while the
stack is off-window mid search-dismissal. UIKit drops such pushes: the
path records the detail, so the root list stays up with the tab bar,
root toolbar, and compose button hidden and the committed query still
filtering the list, with no control left to escape (the reported stuck
state). Notification search never had this bug because it pushes results
onto its own stack.
Workspace search now does the same: the search tab's stack gets a real
path plus a workspace navigationDestination, and a tapped result pushes
there directly with no tab transition and no query commit, so popping
back lands on the live search results and the Workspaces tab keeps its
unfiltered list.
The pending cross-tab machinery still serves deeplinks and device-tree
selections made while searching; those consumes are now gated on the
destination stack being on screen (set by its onAppear/onDisappear), so
a deferred push replays from onAppear instead of landing off-window.
The layout-preview fixture mirrors the new shape (path-based search
stack, system back), and the minimized-search UI test is updated to the
new contract: popping back returns to search results, and the committed
filter check moves behind an explicit switch to the Workspaces tab.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Do not assert system search chrome over the pushed search detail
Whether iOS keeps the bottom search control visible above a detail
pushed inside the search tab is platform chrome, not part of the
selection contract; asserting its disappearance made the minimized-
search test fail against the in-stack push flow.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Name each assertion in the minimized-search test
The hosted runner only surfaces assertion messages, so each step of the
updated search-selection flow carries one to make failures diagnosable.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fail fast with named steps in the minimized-search flow
The hosted runner surfaces XCTFail messages as annotations but not
XCTAssert messages; guard-and-fail makes the first broken step visible.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Expect the live search session after popping the selected workspace
Popping the detail returns to the active search (field and keyboard
restored), so the keyboard-dismissal expectation moves to the explicit
leave-search tab switch. Matches the verified on-device behavior.
Co-Authored-By: Claude Fable 5 <[email protected]>
* End the search session when a result is selected
Left presented across the push, the search field re-presents after
popping anchored to the navigation bar at the top instead of the search
tab's bottom control (reported from device dogfood). Deactivating on
select commits the query like every other search exit, so popping back
lands on the still-filtered results with the collapsed bottom control.
The regression test now pins the restored control to the bottom half
and rejects a top-anchored field after popping.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Finish the search round on the Workspaces tab after popping the detail
Popping back from a workspace opened via search previously left the app
on the deactivated search tab: the tinted search control read as a live
search, and the committed query kept filtering a list that looks
identical to the Workspaces root. Popping now returns selection to the
Workspaces tab and clears the committed query, so the search round ends
with the full list and no highlighted control. An explicit submit still
commits the query as the Workspaces filter; the minimized-search test
now exercises that path directly.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* feat(ios): custom sort for the All Computers workspace list
Workspaces from different computers have no deterministic cross-Mac order
of their own, so make the aggregate order a device-local choice with three
modes: Automatic (foreground Mac first, then name — the old behavior),
Computer Order (user drags computers into a priority order; each computer
keeps its own sidebar order), and Recent Activity (one flat list across
every computer, latest lastActivityAt first).
Computer Order runs in the aggregation (through the macIDsInDisplayOrder
seam) so group sections and workspaces reorder together. Recent Activity
runs at the presentation layer only, because time interleaving cannot keep
group members contiguous; it presents flat and disables drag reorder (a
derived order has no spatial move to send).
The preference persists in MobileWorkspaceSortStore (injected UserDefaults,
mirroring the group-collapse store) and is exposed through a Sort By picker
in the workspace list filter menu, All Computers scope only, plus a
drag-to-reorder Computer Order sheet. First-time picks of Computer Order
auto-open the editor. Strings localized en+ja.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): env-seed the layout fixture's sort mode for headless sim verification
Simulator windows on another Space cannot be tap-driven without stealing
the user's display, so the fixture accepts
CMUX_UITEST_WORKSPACE_LIST_PREVIEW_SORT and ..._SORT_PRIORITY to render
each All Computers sort mode for simctl screenshot verification, matching
the existing COUNT/GROUPS seeding knobs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): sort menu gates on known computers, not visible machines
The sort section hid whenever fewer than two computers had workspace rows
on screen, so a paired-but-offline (or connection-wedged) secondary Mac
made the control undiscoverable exactly when cross-computer order matters.
Gate on distinct known computers instead: machines with visible rows plus
every paired Mac. The computer-order editor now also lists paired-but-
offline computers so they keep their slot while disconnected.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): show the sort menu regardless of computer count
The preference is worth setting before a second computer pairs, and any
count gate hides the control behind connection state (a wedged secondary
Mac already did once). All Computers scope alone decides visibility now.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(ios): Mail-style illustrated sort tiles in a view-options card
Sort hidden inside a filter-icon menu was undiscoverable, and a text-only
'Computer Order' label communicates nothing. Replace the filter Menu with
a menu-styled popover whose top row is three drawn schematic tiles, each a
miniature of what the mode does to the list (computer sections; ranked
sections with drag grips; a flat time-stamped run), with Mail-style radio
checks. Selection re-sorts the list live behind the card. The computer-
order editor presents from the card itself, and the filter rows (read
state, machines) share it, keeping one entry point. UIMenu rows only
render text plus icon-sized images, hence a popover rather than a Menu.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): view-options card round 2 per dogfood
Drop the machines filter section (the title picker already owns computer
selection), make selecting Computer Order inert (the editor opens only
from the explicit Edit Computer Order row), inset the schematic rank
badges off the tile border, and use regular-weight selection checkmarks.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): pin the view-options card to regular-weight body text
The popover inherits the presenting toolbar button's font environment, so
rows rendered with toolbar weight; set body + regular at the card root.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): indent schematic rank badges further, shrink edit-row arrows
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): rename the Automatic sort mode to Connected First
'Automatic' says nothing; 'Connected First' states the rule: the connected
computer leads, the rest follow by name. Raw value and persistence keep
the automatic spelling.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(ios): automatic sort mode becomes Last Opened
'Automatic'/'Connected First' mislabeled the rule and the alphabetical
tail matched no user model. The mode is now Last Opened: the connected
computer counts as opened now, the rest order by when this device last
used them (pairing lastSeenAt as the device-local record), never-opened
computers alphabetical last. Raw value and persistence keep 'automatic'.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): review round — recompute on pairing refresh, robust editor seed
Cursor: the Last Opened order read pairedMacs.lastSeenAt but a pairing
refresh never rebuilt the derived list, so the aggregate order went stale
until the next workspace event; recompute on pairedMacs change (the
derivation already depended on pairedMacs for customization stamping).
Also seed the computer-order editor from the aggregated rows instead of
the filter-menu machine list, which empties below its two-machine floor
and would drop a singleton computer or mismatch the tail order.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add standalone iOS keyboard pinning lab
* Test rapid iOS keyboard dock reversals
* Unify iOS keyboard dock presentation
* Fix CLI compile break from classify() tuple access
https://github.com/manaflow-ai/cmux/pull/9804 landed
`FeedEventClassifier.classify(...).0` while classify() already returned
the named FeedEventClassification struct, so CLI/cmux.swift no longer
compiles on main (every app-host and tests-build-and-lag CI job fails
with "value of type 'FeedEventClassification' has no member '0'").
Use .hookEventName, matching the other call site.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Strengthen rapid keyboard dock coverage
* test(panes): drop stale MobileInjectedAttachStartupTests referencing removed API
The main merge replaced MobileStartupConnectionCoordinator's
connectInjectedAttach with the claim/finish lifecycle, and
DogfoodAttachPreparationTests already covers that lifecycle end to end.
The stale file kept the whole CmuxMobileShellUITests target from
compiling, so no package UI suite could run in CI.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scope pairing scanner guidance copy onto MobilePairingScannerSheet
The caseless MobilePairingScannerGuidanceCopy enum (from #9493) trips the
namespace-enum rule in scripts/lint-ios-package-conventions.sh, turning the
package-conventions-lint job red for every branch that touches Packages/.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scope keyboard dock seam measurement to transitions
* Scope dock seam metric to keyboard transitions
* Test whole dock during keyboard reversal
* Isolate keyboard dock from terminal layout
* Animate hosted keyboard dock reflows
* Localize keyboard pinning lab name
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add inline notification replies and debug mode
* Add iOS inline replies for terminal pushes
* Address review: serialize question-category writes, live-retarget replies, iOS reply re-park
- FeedCoordinator: all CMUXFeedQuestion.* category get->set round trips now
append to one MainActor-serialized chain so a mint racing a mint or a
cancel can no longer clobber the other's setNotificationCategories write
(Greptile P1, CodeRabbit TOCTOU).
- Banner text replies resolve the live surface owner via
agentNotificationDeliveryTarget before surface.send_text and route with
the resolved workspace_id, failing closed when the surface is gone.
- iOS: a failed inline-reply RPC re-parks the reply (original createdAt, so
the 120s TTL still bounds retries) instead of dropping it; a newer reply
parked mid-send still wins.
- Debug: caller-target resolution moved behind the shared production seam
resolvedCallerNotificationTarget; DEBUG-only param parsing lives in
NotificationDebugTarget.swift and the resolver's helpers are private
again. debug.notification.emit fails closed for feed kinds without a
resolved target. Missing-param errors now say what to pass (EN+JA).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Debug: add debug.notification.status verb reporting system notification settings
Reports authorizationStatus, alertStyle, and per-surface settings straight
from UNUserNotificationCenter so authorization problems on a dev build are
diagnosable over the socket.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix post-merge compile: replyShape in new PhonePushPayload call sites
Co-Authored-By: Claude Fable 5 <[email protected]>
* Debug: local reply-notification emitter on iOS Settings
A DEBUG-only button in Settings > Push Alerts schedules a LOCAL notification
carrying the same cmux.terminal.reply category and cmux userInfo schema as a
Mac-forwarded push, addressed at the selected workspace/terminal. The response
path cannot tell local from remote, so the inline Reply UX, parking, and the
terminal.input RPC back to the Mac are verifiable on a device without APNs —
dev web deployments have no push service configured.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Debug Macs default the push lane to shared staging
Dev iPhones register APNs tokens with the staging deployment (the device
rig default), so a Debug Mac posting pushes to its tag-local localhost
port can never deliver: that origin has no token registry, and every
forward died queued. Route /api/notifications/* through a push-specific
base that mirrors irohBrokerBaseURL: explicit CMUX_PUSH_API_BASE_URL or
VM-API overrides win, Debug defaults to staging, Release keeps the
production VM-API origin.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Push lane ignores the rig-baked VM origin
The tag rig bakes a localhost CMUX_VM_API_BASE_URL into every Debug
bundle's LSEnvironment, so deferring to that knob re-broke the push lane
on every fresh build. Only an explicit CMUX_PUSH_API_BASE_URL (env or
~/.cmux-dev.env) overrides the Debug staging default now.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Log per-attempt push delivery status
The queue only logged terminal outcomes, so a failing rig read as opaque
invalid_response/retry_exhausted lines with no way to tell a redirect from
a decode mismatch from a transport error. Log host, HTTP status, byte
count, and classification per attempt (never content).
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fix nonisolated access to logValue in delivery attempt log
Co-Authored-By: Claude Fable 5 <[email protected]>
* Default replyShapeWire in control notification witnesses
Main added direct test call sites for the control notification entrypoints
that predate the reply-shape parameter; a nil default keeps every legacy
caller source-compatible while the socket dispatcher still passes the wire
value through.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address cursor Mediums: preserve minted categories on install, retry parked reply while channel is down
- The launch/category (re)install now merges live CMUXFeedQuestion.*
categories through the new bounded read instead of replacing the whole
set, so a re-configure can no longer strip a live question banner's
option buttons. Regression test included.
- A reply parked because the RPC channel is unavailable arms the same
bounded retry ladder as a failed send, so a channel that recovers
without emitting a store event cannot strand the reply until TTL.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: cmux reload-cloud <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
* Add IROH client refresh coalescing regression tests
* Coalesce IROH client registration refreshes
* Test unchanged Iroh events avoid broker traffic
* Publish Iroh registration only when reachability changes
* Stress unchanged Iroh endpoint events
* Exercise sustained Iroh endpoint churn
* Cover Iroh publication fingerprint changes
* Add failing test: raced live discovery must still read the broker
A live discovery that observes an in-flight unchanged-fingerprint refresh
and its coalesced successors can return .refreshed without any
authoritative broker read. TestIrohEndpoint gains an armable address()
gate so each raced refresh is deterministically held in flight.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Keep raced live discovery authoritative across coalesced no-op refreshes
Observing a coalesced successor no longer forfeits the live discovery
request's right to schedule one discovery-forced refresh, and a
no-op .refreshed outcome without a generation advance no longer
satisfies the request or masks an earlier real failure.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review quick wins: skip signing on read-only refreshes, cover IPv6 ports
Payload signing now happens only after the read-only eligibility gate
declines, so the read-only fast path no longer performs a discarded
signature. The publication-state test also pins IPv6-only direct-port
changes as requiring publication.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add failing test: host sign-out must clear publication state
Co-Authored-By: Claude Fable 5 <[email protected]>
* Clear host publication state on successful sign-out
Mirrors the client sign-out path so a stale fingerprint cannot suppress
the next session's non-forced publications.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
https://github.com/manaflow-ai/cmux/pull/9804 landed
`FeedEventClassifier.classify(...).0` while classify() already returned
the named FeedEventClassification struct, so CLI/cmux.swift no longer
compiles on main (every app-host and tests-build-and-lag CI job fails
with "value of type 'FeedEventClassification' has no member '0'").
Use .hookEventName, matching the other call site.
Co-authored-by: Claude Fable 5 <[email protected]>
Long-pressing a workspace row now offers a Move to Group submenu (one
item per group on the workspace's Mac, current membership checked and
disabled) plus Remove from Group, so a workspace can join a group
without drag-and-drop. Selection routes through the same
MobileWorkspaceMovePolicy intent + optimistic joinGroupAtEnd path as
dropping a row onto a group, and reuses drag gating, so the picker can
never offer a move the drop path would reject. Covers both the UIKit
table pipeline and the SwiftUI row context menu.
Co-authored-by: Claude Fable 5 <[email protected]>
The tolerant full-suite app-host pass runs this suite alongside dozens
of parallel suites; a 5s subprocess bound killed the CLI mid-startup
(status 15, empty stdout) while the idle focused gate passed. 30s keeps
the test deadline-bounded without racing runner load.
Co-Authored-By: Claude Fable 5 <[email protected]>
Review flagged that the optional live-target probe received the entire
remaining attention deadline: a stalled probe could consume the whole
budget and starve the notify/clear send it exists to serve. The probe is
now capped (1s) and always leaves a send reserve (0.75s) of the shared
deadline; when the remaining budget cannot fund both, the probe is
skipped and the command falls back to ambient addressing.
New behavior test: with the fake stalling agent.resolve_delivery_target
for 3s (past the whole deadline), the notification is still written,
addressed to the ambient identities. FakeCmuxSocket now keeps draining
buffered request lines when its replies hit a closed peer — matching the
real app's per-connection worker, which reads written lines after the
hook process exits (verified pi suite unaffected).
Co-Authored-By: Claude Fable 5 <[email protected]>
Review flagged that the attention notify/clear was a plain V1 command
built from ambient env identities: on a restored remote pane those are
snapshot aliases, and the relay remaps IDs only inside JSON requests, so
the command would target a stale pane and the blocked agent stayed
silent on restored remote terminals.
The attention delivery now resolves the live identity first through the
alias-safe `agent.resolve_delivery_target` {surface_id} re-home probe —
the same contract Claude's hooks use; the probe's JSON request IS
relay-remapped, so the app answers with live identities — and addresses
the V1 command to the answer, falling back to the ambient identities
when the probe is unsupported or fails (correct for local panes). The
probe, connect, auth, and acknowledged send all share the one absolute
2s deadline.
New behavior test: with the fake resolving the ambient surface to a
re-homed (workspace, surface) pair, the notification must target the
resolved pair and never the ambient identities.
Co-Authored-By: Claude Fable 5 <[email protected]>
Review correctly flagged that the essential notify/clear connection
reused the telemetry lane's 50ms fast-fail bounds: a relay-backed
socket's multi-round-trip HMAC handshake (or a busy local socket) could
never finish inside them, so remote terminals silently lost the
permission notification. The attention transport now runs under one
absolute deadline (feedAttentionAcknowledgeTimeoutSeconds) spanning
connect, authentication, and the acknowledged send; the telemetry lane
keeps its deliberate fast-fail bounds.
New behavior test: with the fake socket delaying every reply (including
auth) by 0.5s under a socket password, the notification still delivers —
a fast-fail transport drops it.
Co-Authored-By: Claude Fable 5 <[email protected]>
Review noted the added coverage stopped at classification and pure command
construction — a misrouted promptLine dispatch would restore the silent
agent while every unit test stayed green. Add a focused behavior suite
that spawns the real CLI against the existing FakeCmuxSocket harness and
asserts, on the actual socket transport:
- codex PermissionRequest emits the exact gated notify_target_async line
and it precedes the feed.push telemetry frame;
- codex PostToolUse emits the exact pane-scoped clear_notifications line
before its telemetry frame;
- codex PreToolUse emits neither (no premature clear, no over-notify);
- the hook AWAITS the app's acknowledgement: with the fake delaying its
OK by 0.5s, a fire-and-forget regression would return instantly.
Verified red/green: the suite fails against the pre-fix release CLI
0.64.22 ("missing gated permission notification") and passes against this
branch's build. Wired into ci.yml beside the other CLI hook suites.
Co-Authored-By: Claude Fable 5 <[email protected]>
- A deadline-released spawn marks the shim install completed without a
shim; cancelling the in-flight install (teardown, agent-hibernation
suspend) now reopens the gate so the next runtime creation attempts a
fresh install instead of running shim-less forever (Bugbot finding).
Covered in the hung-shim regression test.
- CLI queued-send test: pin the child CLI to an English locale and fail
loudly when the mock socket server does not complete (CodeRabbit).
- Reword the queued marker to "queued (terminal starting; input will be
sent when its PTY is ready)" in the catalog and the CLI default
(CodeRabbit grammar note).
Co-Authored-By: Claude Fable 5 <[email protected]>
Two review-suggested test additions: a nil surface ID must yield no
command (both UUID targets required), and a newline in a
payload-controlled tool name must not split the single socket command
line.
Co-Authored-By: Claude Fable 5 <[email protected]>
Codex's fire-and-forget prompt-submit worker clears the pane at turn
start from a detached process; in a narrow window (worker slower than the
model's first approval-needing tool call) its late clear can remove the
new permission notification. This is the same pre-existing exposure the
shipped wrapper-path notification has always had — this change does not
widen the class — and eliminating it requires origin-time-fenced clears,
a cross-layer notification-store protocol change out of scope here.
Record the invariant at the send site.
Co-Authored-By: Claude Fable 5 <[email protected]>
Two review findings:
- On relay-backed sockets, the acknowledged attention send closes its
connection, so the follow-up one-way feed write reconnected implicitly
with the default (unbounded-by-write-timeout) relay challenge — able to
outlive the agent's hook budget. The feed frame now travels on its own
explicitly bounded best-effort connection whenever an attention command
was sent; no implicit reconnect remains.
- The attention command construction (UUID gating, payload shape, tool
name sanitization, needs-permission meta) moves into the shared-compiled
FeedEventClassifier as a pure builder, and new unit tests assert the
exact notify_target_async / clear_notifications wire lines plus the nil
cases. Transport ordering (awaited acknowledge before the hook returns)
remains verified by the mock-socket harness documented in the PR — the
app-hosted unit target cannot spawn the CLI against a live socket.
Co-Authored-By: Claude Fable 5 <[email protected]>
Review correctly noted that one-way writes return before the app's
detached per-connection worker enqueues the mutation, so a completed hook
process was no proof its clear had been applied — a delayed clear could
still erase a newer request's live notification. The notify/clear line is
now sent request/response and awaited (bounded at 2s) before the
synchronous feed hook returns, the same contract Claude's and Hermes'
hooks use for clear_notifications/notify_target_async. Codex runs these
hooks synchronously, so the next hook's process starts only after this
mutation is in the app's ordered lane. The feed frame stays one-way:
nonessential telemetry whose failure must never swallow the notification.
Verified against an acknowledging mock socket: PermissionRequest emits
awaited notify then feed.push, PostToolUse emits awaited clear then
feed.push, PreToolUse emits only feed.push; warm hook latency ~0.15s.
Co-Authored-By: Claude Fable 5 <[email protected]>
The previous commit routed the native-approval-prompt clear through
sendFeedTelemetry so wrapper-launched codex seats would clear on tool
completion. Review correctly flagged that the wrapper-injected hooks run
as fire-and-forget nohup workers with no ordering guarantee: a delayed
PostToolUse worker's pane clear could erase a NEWER request's live
permission notification — silencing a blocked agent, the exact failure
this PR fixes. Remove the wrapper-lane clear and document why; wrapper
staleness is pre-existing shipped behavior that self-heals at the next
prompt-submit pane clear. The synchronous feed-hook path keeps the clear:
its events arrive in codex's own order.
Verified against a mock socket: `codex-hook post-tool-use` emits no clear;
`hooks feed --source codex --event PostToolUse` emits exactly one.
Co-Authored-By: Claude Fable 5 <[email protected]>
Two review findings on the delivery lanes:
- The notify/clear line now precedes the feed frame in both send branches:
the feed frame can be large and its best-effort 50ms write can fail
under backpressure, and a failed telemetry write must never swallow the
permission notification (that would recreate #9592's silence).
- The wrapper-injected codex hooks route tool telemetry through
`hooks codex post-tool-use` → sendFeedTelemetry, which bypassed the
feed-hook clear: wrapper-launched seats posted the permission
notification via `hooks codex notification` but never cleared it on
tool completion. sendFeedTelemetry now derives the same
FeedEventClassifier decision and prepends the pane-scoped clear, giving
both ingress paths one shared classification/side-effect path. The
target helper falls back to the pane env (CMUX_WORKSPACE_ID /
CMUX_SURFACE_ID) when the event lacks identities.
Verified against a mock socket on both paths:
`hooks feed --source codex --event PermissionRequest` emits
notify_target_async then feed.push; `--event PostToolUse` and
`codex-hook post-tool-use` emit clear_notifications then feed.push.
Co-Authored-By: Claude Fable 5 <[email protected]>
Address review findings on the clear semantics and delivery:
- Codex gives no ordering guarantee between its PermissionRequest and
pre-tool hooks, so a start-time clear could race and erase the
just-raised prompt while the agent is still blocked — reintroducing the
silence behind #9592. Clears now fire only on tool COMPLETION
(PostToolUse/post_tool_use), which strictly follows any approval.
beforeShellExecution and PreToolUse are covered as non-clearing in tests.
- wireMapping is now the single owner of clearsNativeApprovalPrompt;
classify no longer rewraps the classification.
- The socketPath telemetry lane sends the feed frame and the
notify/clear line over ONE connection (batched
sendBestEffortFeedTelemetry(lines:)) instead of paying a second
connect + auth per tool event.
Verified against a mock socket: PermissionRequest emits feed.push +
notify_target_async (redacted body), PreToolUse emits only feed.push, and
PostToolUse emits feed.push + a pane-scoped clear_notifications.
Co-Authored-By: Claude Fable 5 <[email protected]>
Adds the reviewed SHA-256 for the GhosttyKit.xcframework release built
from ghostty 3fbdd078d (fork main merge of manaflow-ai/ghostty#185),
and updates docs/ghostty-fork.md with the new pin, the Hangul NFC/NFD
canonical font resolution summary, its upstream-merge conflict notes
(ghostty-org/ghostty discussion #4163), and the reapplied VT
stream-boundary commit on fork main.
For #9583
Co-Authored-By: Claude Fable 5 <[email protected]>
Review asked for per-request keyed notification clears. Rejected:
notifications carry no request identity anywhere in cmux, and pane-wide
uncorrelated clears on progress signals are the shipped contract for every
agent integration (Claude session-start/prompt-submit/pre-tool-use, the
generic approvalResponse action for Hermes' resolved native approvals, and
codex's own prompt-submit hook — which also self-heals denied-approval
residue at the next turn). Record that invariant on the flag so future
reviewers see the ownership decision.
Co-Authored-By: Claude Fable 5 <[email protected]>
Bumps the ghostty submodule to the fix: font selection now resolves
a decomposed Hangul grapheme cluster through its algorithmically
composed precomposed syllable, so canonically equivalent NFC and NFD
text produces the identical resolver query and selects the same
fallback face (and honors the same font-codepoint-map entries).
Terminal cell contents are unchanged, preserving copy/paste of the
original NFD codepoints. Both commits are reachable from
manaflow-ai/ghostty main via its merged PR #185.
Fixes#9583
Co-Authored-By: Claude Fable 5 <[email protected]>
Bumps the ghostty submodule to the commit that adds the failing
run-iterator test: canonically equivalent NFC and NFD Hangul must
resolve the same font face. Parent CI does not execute the submodule
Zig test suite, so this pointer exists to keep the test-first
structure visible; the red run is documented in the PR.
For #9583
Co-Authored-By: Claude Fable 5 <[email protected]>
Review findings on the previous commit:
- The notification body carried the full tool summary (complete shell
command). Commands can embed credentials, and notification banners reach
lock screens, paired phones, and the recorded notification history. The
body now names only the tool — the same "<tool> needs approval" string
the in-app Feed approval banner uses — never the tool input.
- Codex fires PermissionRequest before its own "Approve for me" reviewer
(#5507), so an auto-approved request would leave a stale or false
"Permission" alert with nothing pending. Codex tool lifecycle progress
(PreToolUse/PostToolUse feed events) now clears the pane's notifications,
mirroring Claude's pre-tool-use clear_notifications contract. The clear is
registry-scoped to sources that raise native approval prompts, so other
agents' tool telemetry never touches the notification queue.
The immediate notify on PermissionRequest is retained deliberately: codex
has no post-reviewer hook, and the wrapper-injected schema already posts
this same immediate needs-permission notification via `hooks codex
notification`; suppressing until authoritative proof would recreate the
silence reported in #9592.
Verified against a mock socket: PermissionRequest now emits
`notify_target_async <ws> <sf> Codex|Permission|shell needs approval|c=needs-permission;p=0`
and PreToolUse/PostToolUse emit
`clear_notifications --tab=<ws> --panel=<sf>`.
Co-Authored-By: Claude Fable 5 <[email protected]>
Codex blocks in its own approval reviewer when its PermissionRequest hook
fires, and that hook is wired only to the feed bridge — which deliberately
normalizes it to non-actionable PreToolUse telemetry so cmux Feed never
competes with Codex's native prompt ("Approve for me" depends on this). That
normalization also silently dropped the only signal Codex emits while
blocked, so notifications.agentPermissionPrompt never fired for codex seats.
Separate the two concerns in the classifier registry: a new
.nativeApprovalPrompt semantic keeps the exact telemetry wire behavior
(PreToolUse, non-actionable, no blocking wait) but marks the classification
notifiesNativeApprovalPrompt. Codex's PermissionRequest/permission_request
register with it; any future native-approval agent opts in with one registry
line. On that flag, the feed hook sends a fire-and-forget notify_target_async
built through the shared AgentHookNotificationClassifier, so the alert
carries the same "Permission"/"Approval needed" strings and the
c=needs-permission meta the generic notification hook and Claude's
permission_prompt path use — gated app-side by the existing
"Agent Needs Permission" setting. No new user-facing strings.
Verified against a mock socket: `cmux hooks feed --source codex --event
PermissionRequest` previously emitted only the feed.push frame (release
0.64.22); it now also emits
`notify_target_async <ws> <sf> Codex|Permission|<command>|c=needs-permission;p=0`,
while codex PreToolUse still emits no notification.
Fixes https://github.com/manaflow-ai/cmux/issues/9592
Co-Authored-By: Claude Fable 5 <[email protected]>
OMP's reload() delegates to switchSession() on the unchanged session
file and re-emits session_switch with the same session id. Guard the
ownership handler so a same-id "switch" emits nothing: a spurious
session-start would mark an idle pane running with no agent_end coming,
leaving it non-hibernatable and shown active indefinitely.
Co-Authored-By: Claude Fable 5 <[email protected]>
OMP's reload() delegates to switchSession() on the unchanged session
file and re-emits session_switch with the same session id. Treating that
as an ownership transition emits a spurious session-start hook that
flips an idle pane's record back to running with no agent_end coming,
leaving the pane non-hibernatable and shown active indefinitely. Drive a
same-id session_switch through the harness and require it to emit
nothing; the guard lands separately.
Co-Authored-By: Claude Fable 5 <[email protected]>
Fixes the permanent app-wide PTY-spawn wedge from issue #9769 by
removing the class of silent one-shot drops in the cold-start path:
- Bootstrap window custody: `ensureHeadlessStartupWindowIfNeeded` now
reclaims a pane host that window-portal churn parked outside any
window (detachHostedView ends in removeFromSuperview), and discards a
stale bootstrap window once the pane host lives in a real window.
Previously a recorded-but-empty bootstrap window early-returned every
future cold start while the follow-up attach deferred on the missing
window — permanently, for input-demand starts (`cmux send`,
`--command`) and background priming alike.
- Claude command-shim deadline: the optional wrapper-shim install no
longer gates `createSurface` indefinitely. A bounded, cancellable
deadline (injected clock, default 5s) lets spawn proceed without the
shim when the install hangs; a late install result still serves future
runtime creations.
- Background-prime slot pinning: a surface whose lifecycle forbids
runtime creation (closing/closed panel, agent-hibernation suspension)
no longer counts as background-prime work, so the prime coordinator
releases the workspace's hidden mount slot instead of retaining one of
the two global slots forever on its timeout path.
- Queued send visibility: `cmux send`/`send-key`/`send-panel`/
`send-key-panel` human output now appends a localized "queued (...)"
marker when the reply carries `queued: true`, so a send waiting on PTY
spawn is distinguishable from a delivered one. JSON output already
carried the flag. The v1 socket lane's bare-"OK" contract predates the
queued flag and is left unchanged for legacy parsers; delivery itself
is guaranteed by the spawn fixes above for every entrypoint.
The regression tests from the previous commit now pass.
Fixes#9769
Co-Authored-By: Claude Fable 5 <[email protected]>
OMP loads a fresh copy of the cmux extension for every session in the
process, so the pane-ownership pin cannot live in module scope: each
subagent's module copy would start with no owner, adopt the subagent's
own session id, and keep emitting the lifecycle hooks the ownership
guard is meant to suppress. Store the owning session id on globalThis,
which is shared by all module instances in the process, so the
top-level session's claim is visible to every subagent's copy.
Verified with the reworked dual-instance harnesses: the module-scope
implementation fails test_omp_subagent_lifecycle.py and this version
passes both it and test_omp_extension_install.py.
Co-Authored-By: Claude Fable 5 <[email protected]>
Structured review caught that the ownership guard relied on module-scope
state, but OMP loads a fresh copy of the extension module for every
session in the process: each extension import goes through a unique
?mtime= cache-busting URL (the loader's mtime token is a monotonically
increasing counter, not the file mtime), so the top-level session and
every task subagent get separate module instances.
The bun harnesses previously imported the extension once and routed all
sessions through that single instance, which made a module-scope
ownership pin look correct. Both harnesses now load a separate
cache-busted module instance per simulated session, so any cross-session
state the extension relies on must genuinely survive separate module
scopes. The subagent lifecycle test fails against the current
module-scope implementation; the fix lands separately.
Co-Authored-By: Claude Fable 5 <[email protected]>
The attach-time focus grab sizes the shared field editor from the
field's current frame, and a zero-frame grab mis-sizes the editor's
dark box over the row — the same lifecycle SidebarRowChecklistItemLine
already documents and handles. Run the row layout pass before adding
the field so it enters the window with its title-slot frame, and clear
the field-editor background after attach, reusing the checklist's
helper.
Codex review finding on #9798.
Co-Authored-By: Claude Fable 5 <[email protected]>
Fixes#9591 (the omp instance of #9523).
OMP task-tool subagents run in-process, inherit CMUX_SURFACE_ID, and
each has its own session id, so every subagent's agent_end fired a stop
hook against the shared surface. The lifecycle sink is last-write-wins
per surface, so the last background subagent to finish marked the pane
idle while the main agent was still mid-turn, and the Agent Hibernation
sweep SIGHUPed the live pane ~65s later.
The v2 extension pins the owning session at the first session_start
(the top-level runtime always bootstraps before any subagent exists)
and drops every lifecycle hook from a non-owner context. Ownership
follows session_switch/session_branch - the top-level-only events OMP
emits for /new, fork, resume, and handoff - and re-emits session-start
so cmux rebinds the surface to the new session id (previously
post-/new sessions were never rebound). A subagent's session_shutdown
no longer drains or evicts the owner's queued hooks.
agent_end with willContinue (a scheduled automatic continuation:
auto-retry, queued messages, session_stop continuations, background
jobs) no longer emits a stop hook, the omp analog of Pi's agent_end ->
agent_settled fix (#8729). No version gating is needed: older OMP
builds simply omit the field, and OMP's session_stop settle event is
unsuitable because it never fires after an abort.
test_omp_extension_install.py moves to the ownership contract:
in-process session-id changes flow through session_switch, queue
pressure comes from rapid switches (the queued Stop still survives
eviction), and a foreign session_shutdown is verified not to drain the
owner's queue.
Co-Authored-By: Claude Fable 5 <[email protected]>
Double-clicking a workspace name on the AppKit sidebar list committed
the untouched title ~1ms after the rename field appeared: after
makeFirstResponder began the editing session, the follow-up
selectText(nil) re-entered the field-editor machinery, synchronously
fired controlTextDidEndEditing, and the row's forked
SidebarRowInlineRenameField honored it by committing stringValue.
Delete the fork and route the AppKit list through the SwiftUI sidebar's
engine, one rename session per edit (SidebarRowInlineRenameSession):
- SidebarInlineRenameTextField focuses and selects once, when the field
enters the window; the selectText restart is gone by construction.
- SidebarInlineRenameCoordinator resolves Enter, double-Escape, and
focus loss at most once, passes IME composition through, and commits
the live field-editor text instead of a stale stringValue.
- SidebarInlineRenameCommit gives the AppKit path the same commit
policy as SwiftUI: empty drafts and unchanged auto-titles resolve to
no write, so a stray commit can never freeze auto-naming.
SidebarWorkspaceRowModel gains hasUserCustomTitle (plumbed from
SidebarWorkspaceRowInput) as the policy baseline.
Cell suspension resolves the session before teardown, so end-editing
during teardown can no longer re-enter commit while the write itself
stays deferred past the table mutation. isEditing is now derived from
the session instead of a mutable flag. Existing suspension tests that
poked the old field's internals now drive the real session through the
field editor.
Fixes#9495
Co-Authored-By: Claude Fable 5 <[email protected]>
In browser focus mode, CmuxWebView.performKeyEquivalent forwards the chord
to the page once and then consumes every Command equivalent so cmux and the
main menu never see it. WebKit's resend of a page-unhandled Cmd+Z /
Cmd+Shift+Z therefore died in the focus-mode branch before the web-content
undo/redo fallback in keyDown could run. Perform the web view's own editing
undo/redo when WebKit declines the chord in that branch, matching the
non-focus-mode routing.
Co-Authored-By: Claude Fable 5 <[email protected]>
The browser focus-mode branch of CmuxWebView.performKeyEquivalent consumes
every Command chord once the page has seen it, so WebKit's resend of a
page-unhandled Cmd+Z is swallowed there before the web-content undo/redo
fallback can run, leaving issue #9677 reproducible in focus mode.
Committed before the fix so CI proves the test catches the bug.
Co-Authored-By: Claude Fable 5 <[email protected]>
WebKit registers every web-content edit command on the web view's
undoManager (WebViewImpl::registerEditCommand calls [m_view undoManager]).
NSResponder resolved that to the window's shared undo manager, mixing every
web view's edit commands into one stack whose registered targets can
outlive their web view — the stale-target crash behind #7272. The fix for
that crash routed Cmd+Z / Cmd+Shift+Z away from the AppKit Edit menu when a
browser web view is focused, which also silenced in-page undo/redo because
nothing performed the command anymore.
CmuxWebView now owns webContentUndoManager and overrides undoManager, so
each page's undo stack is scoped to its web view's lifetime: stale-target
entries are impossible by construction and the window's undo manager never
sees web content. An undo/redo chord reaching CmuxWebView.keyDown has
already been offered to the page via performKeyEquivalent and declined
(WebKit resends unhandled keys), so keyDown now performs the web view's own
editing undo/redo instead of re-forwarding the chord into WebKit, matching
Safari's Edit-menu behavior. Pages that handle Cmd+Z themselves (e.g.
Google Docs) still consume the first pass and never reach this path.
Fixes https://github.com/manaflow-ai/cmux/issues/9677
Co-Authored-By: Claude Fable 5 <[email protected]>
FeedEventClassifier.classify now returns a FeedEventClassification struct
carrying a notifiesNativeApprovalPrompt flag alongside the wire event name
and actionability. The flag is false for every current semantic, so runtime
behavior is unchanged in this commit; the new test asserting that codex
PermissionRequest events set it is expected to FAIL, demonstrating
https://github.com/manaflow-ai/cmux/issues/9592 (the event is normalized to
non-actionable PreToolUse telemetry at ingest and no agentPermissionPrompt
notification is ever raised).
Co-Authored-By: Claude Fable 5 <[email protected]>
Regression test for #9591: OMP task-tool subagents run in the same
process as the top-level session and inherit CMUX_SURFACE_ID, so every
subagent's agent_end was reported as the pane's idle transition. The
last background subagent to finish marked the whole pane idle while the
main agent was still mid-turn, and the Agent Hibernation sweep then
SIGHUPed the live pane.
The new test drives the generated OMP extension through the issue's
exact repro shape: a main session mid-turn, a background subagent that
boots/runs/finishes, an agent_end with willContinue (a scheduled
automatic continuation), the real terminal settle, and a session_switch
(/new). It requires that only the owning top-level session ever reaches
cmux lifecycle hooks, that willContinue defers the stop hook, and that
session_switch re-pins ownership and rebinds the new session id.
Fails against the current v1 extension; the fix lands separately so CI
proves the test catches the bug.
Co-Authored-By: Claude Fable 5 <[email protected]>
Regression coverage for https://github.com/manaflow-ai/cmux/issues/9677:
with a browser web view focused and the page declining the chord (WebKit's
resend of an unhandled key), the routed Cmd+Z / Cmd+Shift+Z must execute
undo/redo on the web view's own undo manager instead of being swallowed,
and each web view's undo manager must be scoped to that view rather than
the window's shared undo manager (the stale-target crash class from
https://github.com/manaflow-ai/cmux/issues/7272).
Committed before the fix so CI proves the tests catch the bug.
Co-Authored-By: Claude Fable 5 <[email protected]>
Issue #9769: after a CLI dispatch burst, new terminal surfaces never
acquire a PTY and `cmux send` silently queues or drops input while
printing OK. These tests reproduce the mechanism:
- TerminalSurfaceBootstrapCustodyTests: window-portal churn parks the
pane host outside any window while a bootstrap startup window is still
recorded; every later cold start (input demand, queued send,
background prime) early-returns and defers on the missing window, so
the surface never spawns. A hung Claude command-shim install likewise
starves createSurface forever.
- BackgroundPrimeStartableSurfaceTests: a surface whose lifecycle
forbids runtime creation still counts as background-prime work, so the
prime coordinator's timeout path pins one of the two global hidden
mount slots forever.
- CLISendQueuedOutputTests: a queued send reply prints a bare
"OK surface:N workspace:M", indistinguishable from a delivered send.
CI is expected red on this commit; the fix follows separately.
Co-Authored-By: Claude Fable 5 <[email protected]>
Double-clicking a workspace name on the AppKit sidebar list creates the
inline rename field but tears the editing session down ~1ms later,
committing the untouched title. These tests drive the real AppKit
editing path (cell in a window, shared field editor, commands dispatched
through the field editor) and assert the intended behavior: begin keeps
the session alive without a write, Enter commits the live editor text
once, an unchanged title is a no-op, Escape cancels, and focus loss
commits the typed draft.
Test-only commit: CI is expected to go red until the fix lands.
Issue: https://github.com/manaflow-ai/cmux/issues/9495
Co-Authored-By: Claude Fable 5 <[email protected]>
* test: a replayed phone click on a text field must focus it
Replayed clicks reach the streamed page as DOM events, but WebKit
refuses to move field focus for clicks in a window that is never key
(the offscreen render host). A tapped text field never focuses, the
phone keyboard never rises, and backspace falls through as page-level
history back-navigation instead of deleting. Red test plus the bare
panel replay seam the fix will hang off.
* Focus editables under replayed phone clicks; verbose browser-stream diagnostics
Programmatic JS focus is exempt from WebKit's key-window rule, so a
replayed click now hit-tests the tap point (descending one shadow-root
level) and focuses the editable it finds. The phone keyboard rises via
the existing editable_focused beacon, and typing lands in the field. A
bare backspace with no focused editable is suppressed instead of
falling through as WebKit history back-navigation, which lost page
state when users tried to delete text.
Verbose browser-stream diagnostics for debugging user reports: the Mac
host ring (Sentry-attached) records stream lifecycle (start, replace,
stop, first frame), input replay outcomes (kind, click count,
suppressed backspace, text length), focus-assist results, beacon
editable transitions, and create resolutions; the phone debug log
gains browser.create and browser.stream lifecycle lines.
* Address review: semantic diagnostic decodes, first-frame after delivery, shadow-aware focus checks
The four browser diagnostic codes now decode into named fields (stage,
input kind, count, focus outcome, panel correlation) instead of falling
through to detail_1/2/3, with a suppressed backspace becoming its own
input kind so counts stay unambiguous. The first-frame lifecycle stage
records once per session after the first successful browser.frame
delivery, never for a capture whose send failed. Editable-focus
detection descends shadow roots in the suppression check, the focus
assist's already-focused check, and the beacon, so widget-wrapped
inputs receive backspace and raise the keyboard; regression test
covers backspace delivery to a shadow-root input.
Notifications now opens as a normal workspace pane tab instead of a window-level overlay. Legacy overlay snapshots migrate to tabs, and background notification panes cannot steal terminal focus.
Verified with tagged cloud build npbg and focused hosted UI coverage.
* Add failing test: parked reveal-time sidebar click must request an apply
A click landing while row actions are detached is deferred (#9225) but the
replay only runs from the next authoritative apply, and nothing requests
one: the park mutates no SwiftUI-tracked state, the sidebar body is
Equatable-gated, and an idle app never re-arms the rows. The click stays
parked until unrelated invalidation, historically an app deactivate/
reactivate cycle.
The test drives the reveal gap (suspend + reveal without an apply), sends
the row click, and asserts the controller fires the new
onDeferredRowClickAwaitingApply seam exactly once; the seam is inert in
this commit so CI shows the test red.
Refs https://github.com/manaflow-ai/cmux/issues/9690
Co-Authored-By: Claude Fable 5 <[email protected]>
* Request an authoritative apply when a sidebar click parks awaiting actions
Fixes https://github.com/manaflow-ai/cmux/issues/9690: infrequently,
sidebar row taps did nothing until an app deactivate/reactivate cycle.
A click landing on a presentation-snapshot row (live action captures
released) is deferred and replayed from the next authoritative apply
(#9225). But the replay was passive: applies only happen when the
Equatable-gated sidebar body re-evaluates, and the park itself mutates no
SwiftUI-tracked state, so an idle app never re-armed the rows. The parked
click waited for unrelated invalidation, which an app focus cycle
eventually provided via window-key row repaints.
The controller now fires onDeferredRowClickAwaitingApply when it parks a
click; SidebarWorkspaceTableView forwards it to VerticalTabsSidebar, which
bumps a @State token read by appKitWorkspaceScrollArea (same pattern as
appKitPostResizeRefreshToken). The body re-evaluates, updateNSView
re-applies fresh action-carrying rows, and the parked click replays
immediately. The request fires only from a physical click, never from a
replay re-park, so a request per click is the ceiling and it cannot loop.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* iOS: New Browser creates and streams a Mac browser pane
The New Browser picker action opened the phone-local WKWebView pane,
which no longer matches the streamed Mac-browser surface used by the
Mac Browsers rows. Add a mobile.browser.create RPC (mirroring
mobile.terminal.create) plus a browser.stream.create.v1 capability, and
route the button through the same activate-and-start stream path as
panel selection. The local pane remains as a fallback for Macs without
the capability, while disconnected, or when creation is rejected.
* iOS: purposeful blank-page state and Mac double-click taps for browser streams
A fresh New Browser pane mirrored an empty white capture, which read as a
glitch; the pane now shows an opaque new-page placeholder until the first
navigation gives the panel a URL. Double tap no longer zooms locally: taps
forward immediately with a rising Mac click count (double tap = double
click for word selection, triple = paragraph), and removing the
double-tap recognizer also removes the recognizer-failure delay from
every single click. Pinch keeps owning zoom.
* test: streaming a discarded background tab must restore its web view
A session-restored or memory-discarded background tab has only a blank
web shell; a phone stream started on it mirrors white frames until a
manual reload. Red test: mobile stream start must begin the
discard-restore navigation like revealing the tab on the Mac does.
* Restore discarded web views when a mobile browser stream starts
Streaming counts as a visibility touch: kick the discard-restore
navigation before the first capture, so a phone opening a preexisting
background tab after Mac launch streams real content instead of a
white blank that only a manual reload fixed.
* Address review: owned create request, reconcile uncertain create, generic error copy
A late mobile.browser.create result no longer activates its panel over a
selection the user made in the meantime: completion applies only while
its request ID is still current, and every competing picker action
invalidates it. An uncertain create outcome (timeout, decode failure,
client swap) now refreshes panel discovery so a committed Mac panel
surfaces in the picker instead of becoming an orphan. The create RPC's
encode-failure body uses product-level copy instead of serialization
detail.
* Update App Review Mac reviewer instructions
* Stabilize iOS Iroh event stream recovery
* Force live Iroh discovery for startup auto-connect
* Revert "Stabilize iOS Iroh event stream recovery"
This reverts commit 1a6cf89388.
* Make reviewer-setup.md notes block the canonical ASC template
CodeRabbit flagged conflicting instructions: the checklist said to paste
all of review-notes.md into App Store Connect while reviewer-setup.md had
its own pasteable block with the manual pairing fallback. The
reviewer-setup.md block is now the single canonical template and
review-notes.md is marked reference-only.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test(ios): keep onboarding content fixed in viewport
* fix(ios): fit onboarding within every viewport
* test(ios): harden onboarding viewport checks
* test(ios): cover compact onboarding layouts
* test(ios): baseline fallback footer geometry
* fix(ios): fit compact onboarding connection layout
* test(ios): require real agent onboarding capture
* fix(ios): use real agent onboarding capture
* test(ios): preserve original onboarding capture
* fix(ios): keep original onboarding captures
* test(ios): describe restored onboarding capture
* Pin GhosttyKit checksum for iOS startup fix (#9487)
* test(ios): bound onboarding chrome
* Retake onboarding captures from the current app with real workspace content
The onboarding tour framed captures were taken in July against the old
home chrome (top search bar) and placeholder fixture rows (iOS avatar
tuning / Docs / Notes with no activity). Retaken all eight variants
(workspaces + notifications x en/ja x light/dark) at 1320x2868 from the
current UI: bottom-aligned minimized search next to the floating tabs,
six workspace rows with agent-activity previews, same-day timestamps,
and unread state, so the tour shows what the shipped list really looks
like.
The workspace fixture rows now carry realistic use cases (agent fixed a
crash and opened a PR, build green, agent waiting on approval) instead
of bare terminal-name subtitles; ids workspace-main/workspace-docs and
the Docs-vs-main search disjointness the bottom-search UI tests depend
on are preserved. Five notification-feed preview bodies drop their
self-referential test-speak (wrapping-verification sentence, 'ready to
open in the iOS app') for agent-report copy, in both English and
Japanese; the one UI test asserting the approval body verbatim is
updated with it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Top-align onboarding pages and enlarge the framed iPhone
The fit pass left each page's visual centered in the leftover space, so
the pairing card floated mid-page and the framed iPhone shrank into a
480pt cap with dead space above and below. Scene content now pins to the
top (copy, then visual directly beneath, spare space at the bottom), and
the iPhone frame cap rises to 560pt on phones / 700pt on iPad; small
viewports are unaffected because the frame layout still fits itself to
the proposed height.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use the Deep Blue bezel for dark-mode onboarding frames
The silver product frame glows against the dark tour backdrop. Dark
appearance now loads frameit's Deep Blue iPhone 17 Pro Max artwork
(same 1470x3000 screen geometry, so the existing frame-derived mask
serves both), selected alongside the appearance-specific capture; the
artwork-resolution test now covers both colorways.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Austin Wang <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
UserNotifications serializes add, settings, authorization, category, and removal operations onto one internal connection queue. A removal blocked in synchronous XPC can therefore make a later main-actor add dispatch-sync behind it and beachball the app.
Route the complete macOS notification-center surface through one dedicated serial background queue. Start an independent two-second deadline at submission time so calls queued behind a permanently wedged entry still resolve as timed out; prevent expired queued work from entering the framework later, and preserve local-feedback degradation at user-visible delivery paths.
Keep the launch-time delegate assignment synchronous because Apple requires it before didFinishLaunching returns, while category installation and every XPC-touching method use the bounded service. Add deterministic service tests for wedged entry, never-completing callbacks, queue starvation, healthy completion, authorization, and removals, and run the package suite in CI.
* test(ios): require truthful Tailscale-only selection
* fix(ios): make Tailscale selection authoritative
* test(ios): verify strict transport choice copy
* fix(ios): stop Iroh discovery in strict mode
* test(ios): align strict transport fixtures
* test(ios): await failed route teardown
* test(ios): assert transport selection behavior
* fix(ios): bind onboarding preview transport choice
* test(ios): pin exclusive method selection and physical teardown
Review follow-ups from PR 9497: the onboarding UI test now asserts the
untapped method is deselected after each tap, the strict-switch test
polls the boxed live Iroh transport for physical close instead of only
the store's logical route, and the connectionMethodStore declaration
documents why nil is unreachable in the shipping app.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
0eecd5afea (#9607) switched SUBROUTER_STACK_TENANT_DELETE_TOKEN to the
validated env object, but t3-env freezes values at first import, so
tenant-control configuration became unobservable after boot and the
unconfigured paths broke: the exchange route returns 200 instead of 503
and account deletion fires hosted tenant deletes for accounts that never
enabled Subrouter. web tests have been red on main since (CI paused).
env.ts still validates presence on Vercel non-preview deployments.
Co-authored-by: Claude Fable 5 <[email protected]>
* test: require Vercel-native CodeRouter data plane
* Move CodeRouter data plane to Vercel
* Allow authenticated operators to run RDS migrations
* Serialize refreshes and fail over cooked accounts
The internationalization check is right that cmux-cli ships no string catalog,
so String(localized:) in the CLI renders its default value — measured earlier
when a plural variation failed to select. Rather than add a resource phase to a
command-line tool, this routes the command's strings through
CMUXDiffViewerLocalization, which already resolves
CLIExecutableLocator.enclosingAppBundle() and honors AppleLanguages.
Verified per locale: help renders 使い方 under ja and 사용법 under ko, the list
header renders "Комментарии ревью: 3" under ru, state labels render 未対応 and
в ожидании, and the guards render their Russian and Japanese messages.
Also documents DiffComment.init and the payload tests.
The Slavic plural finding was right, but CLDR variations do not fire here: the
CLI resolves strings against its own bundle, which carries no string catalog, so
every localized CLI string renders its defaultValue. Measured — with the header
moved to variations, count=1 printed "1 review comments".
So selection stays in code (correct singular today) and the plural strings avoid
letting the numeral govern the noun, which keeps one form grammatical for every
count above one: ru "Комментарии ревью: %1$lld", uk "Коментарі рецензування:
%1$lld", pl "Komentarze przeglądu: %1$lld", ar "تعليقات المراجعة: %1$lld".
Bosnian already used a form valid for every count above one.
The ambient-global-state rule flags a caseless enum whose whole API is static
helpers, and it was the right call here: an instance can own the formatter, so
"one formatter per reply" is now a property of the type rather than something
each caller has to remember. Tests can inject a formatter too.
TerminalController, DiffCommentsBridge, and the tests construct a mapper; the
bridge's list reply collapses to comments.map(payload.json).
The product dependency I added carried CMUXDebugLog's package UUID with a
CmuxDiffComments comment, so a clean resolve could have associated the product
with the wrong package. My local builds hid it behind an already-resolved
SourcePackages cache.
Audited every XCSwiftPackageProductDependency in the project against its
referenced object: no other mismatch. Verified with SourcePackages deleted so
resolution ran from scratch.
Also documents the `ls` alias in the comments contract row.
Per the package-boundary review: the comment model and its wire mapping are
reusable domain logic shared by the WebKit bridge, the socket method, and the
tests, so they move behind a SwiftPM boundary.
- Packages/macOS/CmuxDiffComments exposes public DiffComment and
DiffCommentPayload and depends on Foundation only.
- Persistence stays app-side: DiffCommentStore keeps its directory resolution
and the SessionRestorePolicy test check, so no app-wide dependency crosses
into the package.
- The app target, bridge, socket handler, and both test files import the
package; no behavior changes.
Both surfaces that serialize review comments now share one type that touches
neither AppKit nor controller state: TerminalController keeps only the socket
dispatch, DiffCommentsBridge keeps only the WebKit glue, and the mapping and
consumed-filtering live in DiffCommentPayload with the tests pointed at it.
This also makes the two response paths structurally identical, so the webview
reply cannot drift back to allocating a formatter per comment. If the project
wants this behind a SwiftPM package, it is now a file move rather than a
refactor.
- Replace the English fallbacks with real translations for all 18 remaining
locales across the 13 keys this PR adds. The internationalization rule lists
copied English as an unacceptable way to fill a locale slot, so matching the
catalog's existing fallback habit was not enough for new keys. CLI tokens and
format specifiers are preserved verbatim.
- socket.comments.missingRepoRoot now reads "A repository path is required."
The pre-merge privacy check wants API identifiers out of user-facing text;
the CLI already fails with its own message first, so a direct socket caller
loses nothing it cannot get from the invalid_params code.
- Document the four comments CLI helpers.
* Make hosted tenant exchange self-contained
* Route CLI auth through versioned exchange endpoint
* Use a semantic tenant exchange endpoint
* Use CodeRouter control header for hosted exchange
* Read hosted credentials through validated runtime env
- parseOption takes the token after --repo verbatim, so `--repo --all` would
resolve a repository named "--all" and hand it to git as a path. Reject a
value starting with --, pointing at ./-name for dash-prefixed paths.
- Replace the literal "comment(s)" header with cli.comments.list.header.one
and .other selected by count, matching how cli.memory.output.processCount is
written. Both keys cover all 20 locales.
- comments list now rejects every unrecognized remainder token, not only ones
starting with --, so a stray positional cannot be silently ignored.
- The webview bridge's comments.list response builds one ISO8601DateFormatter
and passes it to commentJSON, matching the socket response.
- Route the command's user-facing text through the catalog: nine new
cli.comments.* keys for the errors and list output, each covering all 20
locales with the reviewed ja value and the accepted English fallback.
cli.comments.usage and socket.comments.missingRepoRoot now carry entries for
all 20 locales the catalog supports. The reviewed en and ja values are
unchanged; the remaining 18 locales use the accepted English fallback, which
is how 229 of the catalog's other fully-covered keys are written.
- Localizable.xcstrings: add the ja entry for cli.comments.usage and add the
socket.comments.missingRepoRoot key, which the handler referenced but the
catalog never defined. Both keys now match the en+ja coverage that the
other cli.*.usage and socket.* keys use.
- DiffCommentsBridge: add commentJSON(_:formatter:) so a caller mapping many
comments allocates one ISO8601DateFormatter per reply instead of one per
comment; the existing single-comment signature delegates to it.
- cmux comments list: reject unrecognized -- options instead of ignoring
them, so a typo cannot read as a supported request.
Review comments saved in the diff viewer already reach agents through the
TextBox pending pool (push). This adds the pull direction: a read-only CLI
that asks the running app for a repository's saved comments, so external
tools never depend on the store's key derivation or in-memory cache.
- comments.list v2 method: canonicalizes repo_root via DiffCommentStore and
delegates to a pure commentsListPayload(comments:repoRoot:includeConsumed:)
so the reply shape is testable without a socket
- cmux comments list [--repo <path>] [--all] [--json]: resolves the git
toplevel (default: cwd), prints a human summary or JSON
- CommentsListPayloadTests: default listing omits consumed comments,
include_consumed adds them with an ISO8601 consumedAt, anchor fields are
preserved, an empty store reports zero
- cli-contract.md: command table row + no-socket help probe
* cmuxTests: derive the theme reload target from a dash-free socket suffix
The CLI derives a theme reload target from the socket file name, collapsing every run of
non-alphanumerics in the slug to a dot. #6452 made this fixture's socket path unique with a raw
UUID to stop two runs colliding in /tmp, which put the UUID's dashes into the derived identifier
as dots, so the expected literal could no longer match and the test waited out its five seconds.
The stdout assertion kept passing because the derived id still has the expected value as a
prefix, which is why this read as a timeout rather than a string mismatch.
Keeps the unique suffix hex-only so the expected identifier stays a plain template instead of a
call into the CLI's own helper, which would agree by construction.
* cmuxTests: drop two palette assertions for a gate that no longer exists
#8173 replaced the fork-probe reuse gate: `!cachedResultHadFallback` became
`cachedResultIsFresh`, and the fallback case is now re-verified against SharedLiveAgentIndex at
the call site instead of being refused outright. The parameter stayed in both signatures, so
these two assertions still compiled while asserting the opposite of what the product does, and
WorkspaceForkConversationContextMenuTests asserts the new contract in both directions a few
files away.
Removes the two assertions whose only purpose was the removed term, and renames the clear-side
test to say what it still covers.
* cmuxTests: stop the remote-connection suite killing its own test host
Three separate problems, in order of blast radius.
Two assertions indexed `operations` right after asserting its count. A count assertion does not
stop execution, so on failure the next line trapped with Index out of range and took the shared
test host down, and every remaining test in the shard never ran. Measured twice in one run.
Four @MainActor tests waited on a DispatchSemaphore. configureRemoteConnection enqueues its
session transition as a main-actor Task, so blocking the main actor stopped the very work being
waited on from ever being scheduled. They now use expectations, which pump the run loop.
Fifteen fixtures passed an unresolved %C control template. The broker deliberately refuses to
own a path it cannot resolve, so no lease was ever taken and cleanup could not run; six inverted
expectations were passing vacuously as a result. They now use the resolved form ssh -G produces,
and a new test pins the unowned-template policy so the fixtures cannot quietly regress to it.
Two more read activeRemoteSessionControllerID straight after configureRemoteConnection and now
await the transition instead.
* cmuxTests: point the daemon-upload tests at the transport that replaced scp
Two tests waited on an scp invocation that no longer happens. #8434 moved the daemon upload off scp
and onto the ssh exec channel, streaming the binary into `cat >`, and did not touch these tests.
Their stubs only fulfilled inside an `executable == "/usr/bin/scp"` branch, so the expectation
could never fire, the wait spent its whole budget, and the unwrap on the next line reported nil.
Both now capture the upload from the ssh branch. The property each one is about is unchanged: the
daemon still has to land on an absolute path under the remote HOME, that path just travels inside
the remote command instead of an scp destination, so the assertion moved with it.
The scp branch is kept and fails loudly. If the upload ever returns to scp, that should be a
sentence in the failure output rather than a silent timeout, which is precisely how these two broke.
The reinstall test also now records how many capability hellos preceded the upload and requires at
least one. Retargeting alone would have let it pass on a first install, which is not the
missing-pty-capability path it is named for.
Renamed the first test off "ScpDestination" since it no longer describes what is asserted.
* cmuxTests: fix three CLI tests that could not pass, and stop one hiding why
Three separate causes, all in the fixtures rather than the product.
Two socket-selection tests replied to the CLI with a bareword. SocketClient only treats OK, OK …,
PONG, ERROR: … or JSON as a complete single-line reply, so a bareword sends it into the multiline
drain pass, where reconfiguring the receive timeout on a socket whose peer already hung up fails with
EINVAL — and the CLI reports "Invalid argument" instead of the reply it already had buffered. The
replies are now OK-framed. These were the only two barewords in the suite, which is why eleven
near-identical siblings pass.
Both now also assert which responder received the request. That is the property they exist for —
the tagged socket is chosen and the stable one is not — and unlike the stdout comparison it cannot
be made vacuous by a future change to the reply.
A fork-diagnostics fixture passed agent "project-agent", which is not in the CLI's catalog, so the
command exited before emitting any JSON. The test has never passed; it went in already red alongside
the pi-family gate it is meant to cover. It now uses grok, a catalog agent that is neither pi-family
nor one of the transcript-walking agents, so the basename gate is still what is under test.
The shared helper turned all of that into a JSON decoding failure, because it only expected a zero
exit before parsing. It now requires the exit status and a completed run, so the next fixture mistake
reports the CLI's own error text instead of a parse error.
* cmuxTests: pair the pi-basename fixture with an agent that can actually fork
The pi-family basename test asked for fork_command_available, fork_supported and
fork_startup_input_available, but its fixture stored the record under a grok
launcher pair. A captured launch command is only used when its launcher describes
the requested agent, so the grok/omo pair was dropped as untrusted, no fork argv
was built for any agent, and all four assertions failed on
agent_has_no_fork_command without ever reaching the rule under test.
Store the record under opencode instead, whose wrapper launcher is omo. The
capture is now trusted, the fork argv resolves through the omo launcher, and the
executable basename stays /tmp/pi so the disagreement between the structured
identity and the basename is still what the test measures. The omo launcher also
answers fork support before the opencode executable probe, so the result does not
depend on a /tmp/pi existing on the machine running the test.
* cmuxTests: assert the stderr-closed CLI does not crash, instead of a CLI that no longer exists
This test asserted exit 1 and a "Usage:" banner on stdout. Neither has been true since #f48922aa94:
an unknown command exits 2 with a single line and no usage dump, and that line goes to stderr — which
the test closes with 2>&-. So it could not pass, and the crash it was written for was not what it
checked.
The regression is still worth guarding. cc4a6109d8 replaced FileHandle.standardError.write, which
raises and aborts when stderr is closed, with a raw Darwin.write that returns -1 on EBADF. The oracle
is therefore that the CLI exited on its own terms rather than dying from a signal, so ProcessRunResult
now carries terminationReason and both runners set it. Without that, a signalled process is
indistinguishable from an ordinary non-zero exit, because its terminationStatus is just the signal
number.
The command now runs under exec, so the process being waited on is the CLI rather than the shell. A
shell reports a signalled child as a normal exit with status 128+signal, which would have hidden
exactly the crash being tested.
It also pins CMUX_SOCKET_PATH and the home directory. Socket resolution otherwise consults a
machine-global marker file, and a spawn with a pristine temp home was measured reaching a real running
app — which would make the exit code depend on what is running on the machine. With the socket pinned
the unknown-command path is a single branch, so the test asserts exit 2 exactly rather than settling
for non-zero.
* cmuxTests: isolate the CLI regression suite from the machine's own cmux
A CLI spawned from this suite with a pristine temp home and a scrubbed
environment still reached a real running app. CFFIXED_USER_HOME moves the socket
directory but not socket discovery: the CLI also reads the machine-wide
/tmp/cmux-last-socket-path marker, and for an untagged debug build it scans /tmp
for cmux-debug-*.sock and connects to what it finds. Resolution runs before the
command dispatches, so even `claude-teams --help` did this. Every spawn site that
is not itself testing resolution now pins CMUX_SOCKET_PATH to a per-run path, the
three stable-variant tests write the marker inside their own temp home, and
runShell takes an explicit environment instead of handing the child everything
the test host was launched with.
Two tests bound a responder on /tmp/cmux.sock, the release app's socket path, and
UnixSocketResponder unlinks before it binds, so a run could take the control
socket away from a release app in use. The early returns meant to prevent that
raced the app, disagreed about whether a dangling symlink counts as present, and
turned the tests into silent passes. The symlink fallback case moves to the
user-scoped stable path inside its temp home. The legacy case keeps the part that
needs the real path, that /tmp/cmux.sock is classified as a stable implicit
default, and no longer creates, binds, or removes it. Three more guards tested
paths inside a freshly created temp home and could never fire, so they are gone.
stderr was pointed at the stdout pipe while about thirty tests parse stdout as
JSON or compare it to an exact reply, so one diagnostic line from the runtime
broke a content check instead of naming itself. stderr now has its own pipe,
failure messages carry both streams, and the negative checks that meant "the CLI
never said this anywhere" read both rather than silently narrowing to stdout.
Readers for both pipes start before the wait, because reading after
waitUntilExit deadlocks once a child fills a pipe buffer and that looks like a
hang inside the CLI. A launch failure is reported on stdout as well as stderr,
since five sibling suites share this runner and print only stdout.
Runs that assert nothing about latency no longer carry a 5s cap and take a 60s
guard instead, which still fails a stuck CLI rather than passing slowly. The two
browser-download tests keep their 3s and 16s caps, where the deadline is the
assertion. The two theme tests with fixed bundle identifiers now scope them per
run, since the reload notification goes out machine-wide; for the nightly one
that means scoping the socket file name too, because the identifier is derived
from it.
* cmuxTests: assert the exit code this fixture actually produces
The stderr-closed test asserted exit 2, the unknown-command code. Measured, it exits 1: the pinned
socket has no listener, so the CLI fails at connect and the top-level handler returns before the
unknown-command arm runs. That ordering makes the fixture a better exercise of what the test guards,
not a worse one, because the connect error is written to the stderr the test has closed. The run
confirmed the guard itself holds — termination reason was a normal exit, not a signal.
* cmuxTests: report stderr in sessions helper failures
* cmuxTests: preserve restore assertions after stream split
* cmuxTests: close review gaps in process and upload fixtures
* cmuxTests: align remote fixtures with streamed input and scoped identity
* cmuxTests: yield main actor while awaiting daemon upload
* cmuxTests: repair CLI regression fixtures and child lifetimes
* cmuxTests: isolate daemon bootstrap fixtures from ControlMaster
* cmuxTests: keep theme notification state nonisolated
* cmuxTests: detach live argv fixture from test host
* cmuxTests: own Go discovery in daemon reinstall fixture
* cmuxTests: make subprocess and bootstrap fixtures deterministic
* cmuxTests: remove detached fixture wall clock
* cmuxTests: use async-safe scoped locking
* cmuxTests: make off-host process work concurrent
* cmuxTests: keep blocking process wait off cooperative executor
---------
Co-authored-by: ejc3 <[email protected]>
* cmuxTests: gate the keyDown-forward assertion on a live surface
testTypingRepairForwardsKeyDown asserted that the repaired keyDown reached
libghostty, but forwarding only happens once the runtime surface is live, and the
headless xctest host does not always spin one up. The assertion then fails for a
reason that has nothing to do with typing repair.
Gate the forward observation on a live surface, matching the constraint the
neighbouring tests in this file already respect.
This commit previously also carried a fix for remote-workspace restore handing
the terminal a local working directory. That fix is #8634, which restructures the
same logic behind a named predicate and gates both paths rather than one, so it
is dropped here to avoid two competing changes to the same function.
* cmuxTests: skip the keyDown-forward check visibly instead of silently dropping it
The forward assertion was wrapped in `if terminalPanel.surface.hasLiveSurface`, which means on a host
without a live libghostty surface the oracle simply disappears and the test still reports green. The
comment even said the headless host does not always provide one, so the coverage was not just
conditional, it was silently conditional, and nothing distinguished a run that checked the forwarding
from a run that did not.
XCTSkipUnless says it out loud. The repair-routing assertions above run first and are checked on every
host either way, so nothing that was verified before is verified less now.
---------
Co-authored-by: ejc3 <[email protected]>
* remote-tmux: cover a pane that retains past its own ceiling
Red on purpose. Two tests drive the two branches that can reach a pane's retention
ceiling and assert what should happen: the pane recovers and the transport keeps
running. Today both fail with the connection in `.reconnecting` and no pane marked
for a deferred reseed.
They need one change to be reachable at all. The per-pane comparison read the
hard-coded static while the seed tests inject a small mirror limit, so no fixture
could reach this branch and it has never had coverage. The comparison now goes
through `min(static, mirror budget)`, which is the same value at the shipped
default because the mirror-wide default is exactly twice the per-pane static.
That bound is also the honest one: without it a single pane may retain more than
the whole mirror is allowed, which is why one retaining pane always crosses the
per-pane line first and the mirror-wide check only becomes reachable with three
panes retaining at once.
* remote-tmux: recover one pane instead of restarting the session on seed overflow
A pane whose surface has not reached its remote size yet cannot accept a seed, so the
mirror retains it. When that retention crossed the pane's ceiling the mirror called
`beginReconnecting()`, which is the "this control stream is unusable" path. The stream
was fine; a renderer had run out of room.
What it cost: the state change wipes every pane's retained seed and every deferred
reseed, then the reattach reseeds all of them with `clearScrollback: true`. That emits
ESC[3J, so each pane loses its locally saved lines and gets back at most what
`capture-pane` returns. One slow pane truncated the scrollback of every other pane in
the session.
The remedy was already in this file, three lines below each of the three call sites,
and already used for the neighbouring condition: drop that pane's retained bytes, mark
it, and re-seed it from an authoritative `capture-pane` once its grid is ready. The
recapture is what makes dropping the bytes safe, and it touches one pane.
The condition is reachable in ordinary use — a large `cat` in a pane whose tab has not
been opened yet will do it on a fast link. It is close to unreachable on the
high-latency path that motivated #8436, so the harsh branch fired mainly in conditions
that PR was not about.
Of the eighteen `beginReconnecting()` calls in Sources, this was the only one outside
the connection itself and the only one that could fire while the stream was healthy.
* remote-tmux: a seed budget ceiling no longer restarts a healthy stream
Two sites in the connection's own seed accounting called `beginReconnecting()` under an
explicit `connectionState == .connected` guard, so a producer running out of room restarted a
stream that was working. The reattach then reseeds every pane with `clearScrollback`, emitting
ESC[3J, so one slow pane truncated every sibling pane's scrollback — the same blast radius the
mirror-side change removes, one layer down. Two independent design reviews ranked this the
worst remaining problem, and it makes the claim in this branch's description true rather than
nearly true.
`recoverPaneSeedBudget` discards that pane's retained bytes and re-seeds it authoritatively.
Freeing the bytes first is what makes room for the re-seed to be admitted, and the re-seed is
deferred to the next main-actor turn because this runs inside the reservation that just failed
— a synchronous call re-enters it and recurses until the stack overflows, which a fuzz run
measured.
The budget test follows: it pinned `.reconnecting` and an empty seed table, and now pins the
blast radius instead. The pane that crossed the budget is released, a pane that did not keeps
its seed, and only the offender's bytes return to the budget.
* remote-tmux: recover an overflowing pane seed once under total-budget backpressure
reservePendingPaneSeedBytes already recovers the pane (recording
pane-seed-total-backpressure) when the aggregate budget is exhausted, and
the caller's combined guard then recovered it a second time under
pane-seed-backpressure. Each recovery enqueues a clear-scrollback reseed,
so one overflow scheduled two. Split the guard so the per-pane ceiling
keeps its marker and a reserve failure returns without recovering again.
* remote-tmux: cover bounded pane seed recovery retries
* remote-tmux: bound deferred pane seed recovery
---------
Co-authored-by: ejc3 <[email protected]>
* tests: settle focus broadcasts before asserting focus-history and notification dismissal
Five tests in the Workspace/TabManager suites called Workspace.focusPanel (or
addWorkspace) and immediately asserted the side effects that focus is supposed to
produce: a focus-history entry, and dismissal of the focused pane's unread
notification. Those side effects no longer happen synchronously.
Both of them run from the .ghosttyDidFocusSurface observer in TabManager, and that
notification is emitted through FocusSurfaceBroadcaster, which by contract never
delivers synchronously -- it coalesces onto a later main-queue turn so that emitting
mid-mutation cannot re-enter the focus/selection path. That indirection is what fixed
the unbounded focus cycle in issue #5100. Focus itself still lands synchronously,
which is why the surrounding focusedPanelId assertions kept passing and only the
side-effect assertions failed.
Drain the main queue before reading focus history or notification state, matching what
the passing tests in these same two files already do.
testFocusHistoryMenuSnapshotCarriesFocusedTimestamp needed one more correction: a
.back snapshot lists where focus would return to, so its first item is the focus record
stamped by TabManager()'s own initial workspace, not by the later addWorkspace call.
The lower bound of the causal interval now reads before TabManager() so it actually
brackets the record under assertion.
These suites are not in any CI -only-testing allowlist, so nothing caught the drift.
* tests: inject focus-history timestamps
* tests: settle active focus-history coverage
---------
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
`openBrowser(insertAtEnd:)` passed a final position to `reorderTab`, which is
addressed in bonsplit insertion gaps. The end of the strip is `count`, not
`count - 1`, so the old value asked for the gap in front of the last tab and
left the new browser one slot short of the end.
It looked correct whenever exactly one tab followed the insertion point, since
the position and the gap agree there, which is why the existing two-tab test
did not catch it.
Before: TabManagerSurfaceCreationTests, 11 tests, 1 failure
After: TabManagerSurfaceCreationTests, 11 tests, 0 failures
Co-authored-by: ejc3 <[email protected]>
* sidebar-git: give the PR refresh run-loop test something real to observe
testPullRequestRefreshRepositoryDiscoveryDoesNotBlockMainRunLoop counted calls to
a stubbed `git remote -v` subprocess as its proxy for "repository discovery ran".
The refresh stopped spawning that process in #2797, which replaced it with
in-process config parsing, so the counter sat at zero and the assertion failed.
The checks after it were worse than failing: with no discovery observed, they held
whether or not anything happened at all.
Repository discovery is the blocking filesystem work the refresh does before it
reaches the network, so that is what the test should watch. This adds
GitRepositoryDiscovering for the two calls PullRequestProbeService makes while
resolving candidate seeds, and lets a host inject it. GitMetadataService conforms
and stays the only implementation the app installs, so behavior is unchanged;
PullRequestPollService and the probe service accept the protocol instead of the
concrete type, which every existing call site already satisfies.
The test injects a discovery that counts and sleeps. It resolves no slugs, which
keeps the refresh off the GitHub transport and away from `gh auth token`.
The test now makes two claims rather than three. The invocation count is the one
that can fail for a product reason, and it is the one that was broken. The
run-loop tick gap stays as a coarse guard against a seconds-long stall.
The old "discovery did not run on the main thread" check is gone, along with the
observation box that fed it. `repositorySlugs` is a nonisolated async requirement,
so SE-0338 runs it off the caller's actor however the refresh schedules it: the
check passed no matter what the product did, including if discovery were rewritten
to be awaited inline. A test that cannot fail is not evidence, and keeping it
would have implied coverage the test does not have.
The run-loop tick gap stays as a coarse guard, and its comment now says why it is
loose: 45 seeds times 30ms of injected blocking is 1.35s against a 2.0s ceiling, so
this test's own work cannot trip it. It fires only if the product adds a
multi-second main-thread stall on top.
The counter is renamed to RepositoryDiscoveryInvocationCounter, since it counts
discovery calls rather than command-runner calls, and the new TabManager parameter
carries a note that it overrides discovery for the pull-request refresh only.
* chore: prepare PR 8724 origin transfer
* test: transfer deterministic PR refresh coverage
---------
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
One test in this suite has been killing the xctest host, which is worse than a red suite:
the host dies with no verdict and every suite batched with it loses its results too.
The evidence names the test. scripts/ci/cmux-unit-test-timings.json was generated from a
green main run by scraping per-test completion lines, and it holds 247 entries for this
suite. testWelcomeWindowSidebarShortcutsUseSharedToggleCommands is the only declared test
absent from it. A test that neither passes nor fails nor skips is one the host died inside.
That test is also the only place in this 12,000-line file that calls performClose on a
window it constructed, and the only closed window here that leaves AppKit's close-time
release enabled; the other twenty disable it, and the product does the same for its own
windows. The test holds the window through ARC while the delegate's window context and the
focus-capture swizzle hold weak references to it, so the deferred close drops the last
retain a runloop turn later and the process aborts rather than failing a test.
Separately, the one test that constructs a second AppDelegate restored AppDelegate.shared
but not the surface registry's route retirer, which init had pointed at the temporary
delegate and which the registry holds weakly. That left the retirer nil for the remainder
of the host, so later tests ran against a registry that never sweeps retired routes.
A third latent host kill stays for its own change: a key-event helper calls fatalError
instead of failing, and converting it needs a throwing signature at fourteen call sites,
which does not belong in the same diff as the crash it would obscure.
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
* file explorer: show git status for repos reached through a symlink
GitStatusProvider compared git's physical repo root (/private/var/...) against
the caller's explorer root spelled logically (/var, /tmp, or a symlinked project
dir) by raw string prefix, so every entry was dropped and the file explorer showed
no git status for any workspace behind a symlink. Resolve both roots to one spelling
for the containment check and emit keys under the caller's spelling so
FileExplorerStore lookups match. The ssh path keeps the caller's spelling on both
axes, so remote paths are never resolved against the local filesystem.
* file explorer: say when the root == "/" key branch is reached
---------
Co-authored-by: ejc3 <[email protected]>
BrowserPortalFirstRevealScrollTests declares 16 tests. Run alone it completed 10
of them and restarted the app host three times, so the suite had no verdict and
anything sharing its host lost one too.
makeWindowFixture builds an NSWindow and three tests close it. AppKit releases a
window on close unless the owner opts out, and ARC still holds a strong reference,
so each of those closes over-releases and takes the process down. The count lines
up: exactly three tests call close(), and there were exactly three restarts. The
one test that builds its own window already sets the flag, so this was an omission
in the shared fixture rather than a deliberate difference.
The product does this everywhere it owns a window (BrowserPanel, the prewarmed
pool, the popup controller, ReleasingWindowController); only this fixture missed it.
Before: 3 restarts, 10 of 16 tests ran, ** TEST FAILED **
After: 0 restarts, 16 of 16 tests ran, ** TEST SUCCEEDED **
Both arms ran on the same worktree and the same warm derived-data path, one suite
per app host, with only this change between them.
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
The HostBrowserSignInFlow harness waits spun on Task.yield() until their
condition held. Under CPU contention that is a bet on when the awaited task
gets scheduled, and the spinning loop competes with it for the same cores.
Running the package suites a few at a time was enough to lose the whole
target to
HostBrowserSignInFlowTestSupport.swift:102: Fatal error: Timed out waiting
for 1 host-browser session(s); got 0
since the timeout aborts the process and takes all 167 tests with it.
Raising the deadline does not fix that. With 48 busy loops on 16 cores, a
ten-second budget aborted the same way, only later. So each wait now suspends
until the fake it waits on resumes it: the session factory resumes session
waiters as it appends a session, the fake client resumes them as a currentUser
read parks on the closed user gate, and the gateable client resumes them once
an exchange has written its tokens or a clear has emptied the store. The
condition wait registers with the observation system instead, since the flow
and the coordinator are both @Observable. FlowFakeAuthClient's
storedAccessTokenDidPark() and ManualTestClock already worked this way.
The deadlines stay on as a net, so a genuine hang still reports by name rather
than suspending the run forever. They no longer bound a passing run.
Co-authored-by: ejc3 <[email protected]>
* Default test-process windows to releasedWhenClosed = false
AppKit defaults a code-created NSWindow to releasedWhenClosed == YES, so under ARC every close()
in test teardown sends an extra release. The window deallocates while still in the test's
autorelease pool, and the post-test pool drain then over-releases it: EXC_BAD_ACCESS in
objc_release, which kills the shared app host. xcodebuild relaunches the host and its summary
covers only the last launch, so verdicts pending in the dead host go missing rather than red.
A constructor in the test bundle swizzles NSWindow's two designated initializers so every window
created in the test process defaults to releasedWhenClosed == NO. A subclass's super.init reaches
the swizzled implementation, so NSPanel and every test-local subclass are covered without being
touched. Nothing in cmux sets releasedWhenClosed = YES deliberately, and production already sets
NO at its own call sites. The tradeoff is that AppKit-internal self-releasing windows leak in the
test process, which is harmless there.
Rebased onto current main from #7768; only the two source files are carried over, and the project
file entries are re-added against main's copy.
Co-authored-by: ejc3 <[email protected]>
* cmuxTests: also disable the window appearance animation in the guard
Greptile's review asked for this and trackTestWindow already does both: a window's appearance
animation is its own object and can outlive the window, committing CoreAnimation transactions off
the main thread for the rest of the run. Setting animationBehavior alongside releasedWhenClosed
means AppKit never creates the animation for a test-process window.
Nothing in the test targets asserts on animationBehavior, and the three production sites that
choose one deliberately assign after init returns, so an init-time default cannot override them.
Measured before pushing: the guard tests plus BrowserDeveloperToolsVisibilityPersistenceTests
produce the same verdicts with and without this change — same 11 pre-existing failures, nothing
added or removed, 0 restarts both ways.
* cmuxTests: cover guarded window animation defaults
* cmuxTests: type Swift Testing failure comment
---------
Co-authored-by: lawrencecchen <[email protected]>
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
* tests: drain all paneRects in programmaticMirrorReorder… (broken by #7315)
#7315 (exact feed-forward sizing / verified pane geometry) changed a mirror
window to publish only when its own paneRects reply lands, and those fetches are
enqueued incrementally — window @2's fetch appears after @1 resolves. The test
replied to a single snapshot of pending paneRects, so @2 never published, the
mirror built one tab instead of two, and the reorder + windowOrder assertions
failed (panelIds.count == 1, not 2).
The product is correct — the sibling mirror suites and the multiplex fuzzer build
multi-window mirrors green. This is a stale test setup: drain every paneRects
fetch (bounded loop) so both windows publish, then the two-tab reorder holds.
Red/green: on clean main the test fails with panelIds.count → 1 == 2; with the
drain it passes (1 test). Test-only change; no product code touched.
* tests: drain post-#7315 follow-up commands in RemoteTmuxWindowReorderTests
#7315 (verified pane geometry) and the pane-border-status work changed the
control-command stream the reorder/close state machine emits: a window-list
publish now also enqueues a per-window paneRects refetch, and closing a window
issues a border-status unsubscribe (a plain send(), kind .other). The suite
drives the connection with positional commandNumber:0 replies, so an undrained
follow-up sits at the FIFO head and swallows the reply meant for the reorder/
close list-windows recovery — the batch never recovers, the connection never
reconnects, and retained panes never release. All 33 assertions across 9 tests
failed on clean main for this one reason.
Fix is test-only: publish helpers drain every follow-up (paneRects + .other), a
drainLeadingOther helper clears them ahead of each correlated reply, and the
exact-pending assertions compare with those incidental follow-ups filtered out.
The product is correct — the multiplex fuzzer and the sibling mirror suites build
multi-window mirrors and reorder/close them green.
Red/green: clean main fails the suite with 33 issues; with this it passes 14/14.
No product code changed. Broke in #7315.
* tests: address review findings on the mirror/reorder test fixups
From the CodeRabbit/Greptile pass:
- drainLeadingOther replied to every paneRects with a hardcoded `%0`; a
re-published @2/@3 needs its own pane id (the `windowId * 10` convention
publishWindows stages), or its pending layout can't publish.
- reorderPending filtered incidentals globally, so a paneRects landing BETWEEN
two list-windows (an ordering anomaly) would be elided and the equality
assertion would still pass. Trim only TRAILING incidental follow-ups; an
interleaved one now survives and fails the assertion.
- The mirror-targeting rects drain iterated a stale snapshot while each reply
consumes the FIFO head, so an incidental preceding a fetch could mis-correlate
pane data. Drain strictly from the head and stop at the first correlated command.
* tests: stop the reorder drains from swallowing correlated commands
Both drain helpers replied to whatever sat at the FIFO head, so a `listWindows` or
`windowReorder` arriving early was consumed with an empty reply and its later
positional result mis-correlated — the failure the drains exist to prevent. Each now
answers only the incidental follow-ups (`paneRects`, `.other`) and stops at the first
correlated command. `drainLeadingOther` also gains the bounded guard the other drains
already had.
---------
Co-authored-by: ejc3 <[email protected]>
Co-authored-by: Austin Wang <[email protected]>
* tests: update the remote-tmux resolver assertion to the shared builder's argv
RemoteTmuxAuthTests/controlModeArgumentsUseRemoteTmuxResolverAfterDestinationGuard
fails on main. It asserts the remote command ends with
'cmux-remote-tmux' '-CC' 'attach-session' '-t' 'work session'
but #8442 generalized the tmux-specific resolver into RemoteExecutableCommandBuilder,
which passes the executable name and not-found sentinel as arguments:
'cmux-remote-executable' 'tmux' 'cmux-remote-tmux: tmux not found' '-CC' ...
The test's intent still holds — a destination that looks like an SSH flag is still
passed after `--` and the remote command still routes through the resolver — so only
the asserted literal was stale. Pin both halves instead: the command goes through the
resolver, and what it forwards is the tmux attach for this session.
* tests: pin the resolver's not-found sentinel in the control-mode argv
The resolver argv is 'cmux-remote-executable' <name> <sentinel> followed by the
forwarded arguments, so asserting the executable name and the tmux attach suffix
left the sentinel between them unpinned. Derive it from
RemoteTmuxHost.tmuxNotFoundSentinel so the assertion cannot drift from the
constant the resolver actually emits.
---------
Co-authored-by: ejc3 <[email protected]>
* cmuxTests: stop a fake WKNavigation from killing the test host
BrowserDiscardRestorePolicyCancelTests logs that it started and then produces no verdict at
all, which is what a dead host looks like rather than a failing assertion. That is why the
suite reads as consistent with the product when you go through it test by test: it does not
fail, it dies, and it takes every suite sharing the host down with it.
The cause is constructing WKNavigation directly. WebKit builds the embedded C++
API::Navigation itself, so a bare WKNavigation() carries unconstructed storage. Allocating one
is harmless; releasing it is not. Reproduced outside the test bundle, deterministically:
EXC_BREAKPOINT inside CFRetain from -[WKNavigation dealloc] with WebKit initialised, and
SIGSEGV through WebCoreObjCScheduleDeallocateOnMainRunLoop from the same dealloc without it.
The first death needs no window: the fake is stored as the pending restore navigation, the
next call clears that reference, and the release traps mid-assertion before anything prints.
That also explains why no output survives. The probe reproduced the missing-log signature
too: with stdout on a pipe, the crash discards the buffer, so even the line printed just
before it never reaches the log.
The bookkeeping under test only ever compares these by identity, so the fakes are minted
through a helper that keeps them retained for the run. No assertion changes. WKNavigation()
appears nowhere else in the repo.
Whether the eight tests then pass is a separate question this crash has been hiding.
* cmuxTests: the same suite closes a test-owned window AppKit also releases
Retaining the fake navigations got this suite far enough to run and pass several tests where
it previously produced nothing, which confirmed the first cause and exposed a second one in
the same file. One test builds an NSWindow, holds it through ARC, and closes it in a defer
without disabling AppKit's close-time release, so the last retain goes away underneath the
live references and the host aborts instead of a test failing. That is the same defect already
proven in the shortcut routing suite, and about forty other closing sites in cmuxTests
already guard against it.
---------
Co-authored-by: ejc3 <[email protected]>
closeWorkspace checks only that more than one tab is open, then runs its whole
teardown. It frees every panel's Ghostty surface, which SIGHUPs the child
processes, empties the workspace's panels and titles, clears owningTabManager,
and publishes a workspace-closed event. Membership in `tabs` is only enforced at
the very end, when the array element is removed; the recordHistory block does
look the index up earlier, but only to decide where in the history to record.
So handing a manager a workspace from another window kills that workspace's
terminals, strips its panels, and announces a close for a workspace that is still
open on screen.
#889 added this teardown and, directly above it, a `tabs.firstIndex(where:)`
guard, along with the test that covers this. A later "Reapply" merge kept the
teardown and dropped the guard, so the destructive half outlived its
precondition.
Two call sites already make this check themselves rather than relying on
closeWorkspace: AppDelegate re-checks `sourceManager.tabs.contains` before
closing a source workspace, and TerminalController records `existedBefore` and
skips candidates that fail it. Both predate #889, so they are not compensating
for the lost guard — they are evidence that callers have always needed this
precondition and have been paying for it individually.
One path does change. Workspace.swift resolves a manager as
`owningTabManager ?? tabManagerFor(tabId:) ?? AppDelegate.shared?.tabManager`,
and that last fallback is reached precisely when no manager owns the workspace.
Previously such a call tore the workspace down through an unrelated manager;
now it returns early, which is the intent of the guard.
testCloseWorkspaceIgnoresWorkspaceNotOwnedByManager covers this and has been
failing: it hands the manager a foreign workspace and checks that the workspace
keeps its panel, which is the terminal that would otherwise be killed.
Co-authored-by: ejc3 <[email protected]>
* CMUXProjectModel: find the worktree root instead of counting directories
Every project-loading test in XcodeProjectAdapterTests fails under `swift test`:
Caught error: unreadable(file:///.../Packages/cmux.xcodeproj)
The suite locates cmux.xcodeproj by stepping up five parent directories from
its own #filePath. That was right when packages sat directly in Packages/, but
they now live under a group folder (Packages/macOS/CMUXProjectModel), so five
steps land on Packages/ and the adapter is handed a path that does not exist.
Seven of the fifteen tests in the package fail as a result.
Search upward for the directory that actually contains cmux.xcodeproj. Packages
are expected to move between the Shared, iOS and macOS group folders, so a fixed
depth breaks again on the next move while a search does not.
The search stops after eight directories rather than running to the filesystem
root. An unbounded walk out of a checkout that is nested inside another checkout
would find the outer checkout's cmux.xcodeproj and quietly test that project
instead. It also resolves symlinks on #filePath first, so a symlinked package
path still lands on a directory the walk can compare against.
When nothing is found the initializer throws and names what happened, instead of
handing the adapter a path it invented. Copying the package somewhere with no
cmux.xcodeproj above it now reports
cmux.xcodeproj is not in /.../Tests/CMUXProjectModelTests or in any of the 7
directories above it, so these tests have no project to load. Run them from a
cmux checkout, or point CMUX_PROJECT_FIXTURE at a directory that contains
cmux.xcodeproj.
and all nine tests in the suite fail. Two of them used to pass against the
missing path without loading anything: canLoad only inspects the path extension,
and the workspace test returns early when the file is absent.
* CMUXProjectModelTests: derive fixture siblings from the containing directory
CMUX_PROJECT_FIXTURE pointing at cmux.xcworkspace produced a projectURL nested inside
the workspace bundle, and a .xcodeproj override had the symmetric bug. When the override
names either bundle, its siblings now come from the directory that holds it.
---------
Co-authored-by: ejc3 <[email protected]>
loadChildren only checked cancellation after provider.listDirectory, so a
root reload triggered during an SSH provider swap left the cancelled local
load free to still call listDirectory, now through the freshly swapped SSH
transport, listing the old local path. Bail at the top of loadChildren when
the task is already cancelled, before any listing.
Co-authored-by: ejc3 <[email protected]>
controlSurfaceReorder passed the CLI's requested final tab position straight
to reorderSurface, but that API takes a bonsplit insertion gap. Moving a tab
to a higher slot needs index + 1 so the gap lands after the tab currently in
that slot; otherwise a move to sourceIndex + 1 is a silent no-op that still
reports success. Mirror the final-to-insertion conversion the other reorder
call sites already do.
Co-authored-by: ejc3 <[email protected]>
Three suites created an NSWindow and later closed it without opting out of
AppKit's close-time release, so each close over-released a window ARC still
held and took the whole test host down with it.
A dead host is worse than a failing test: the suite reports no verdict, and
every suite sharing that host loses its verdict too.
TerminalNotificationSocketActionTests 2 restarts, 0 of 7 tests ran -> 0 restarts, 7 pass
FilePreviewPanelTextSavingTests 2 restarts, 2 of 27 tests ran -> 0 restarts, 27 run
FilePreviewReviewFeedbackTests 1 restart, 11 of 17 tests ran -> 0 restarts, 17 run
FilePreviewPanelTextSavingTests closes a window in fourteen tests, all through
one private windowHosting helper, so the guard goes there rather than at each
call site. The other twenty-two suites in that file build windows and only ever
orderOut them, which does not release, so they need nothing.
Two of these suites still have assertion failures behind the crash that nobody
could see while the host was dying: three in FilePreviewPanelTextSavingTests
(24 of 27 pass) and one in FilePreviewReviewFeedbackTests (16 of 17 pass).
Those are separate bugs and get their own change.
The product already sets isReleasedWhenClosed = false everywhere it owns a
window; only these fixtures were missing it.
Co-authored-by: ejc3 <[email protected]>
* tests: pin the restore model these four unread tests were written against
Four session-restore tests in WorkspaceManualUnreadTests still describe the
pre-#2797-era restore model, where an unread notification present at snapshot time
was dropped and came back as a purely visual "restored unread indicator" with a
count of zero.
e4856922b0 changed that on purpose. Snapshots now carry the notifications
themselves, restore re-inserts them still unread, and the restored-unread
indicator is set only when a snapshot claims unread with no unread notification to
back it. Both gates are live: the workspace level checks
`snapshot.notifications?.contains { !$0.isRead }` before setting the indicator, and
the panel level does the same. Setting both would count one notification twice.
So these tests asserted an indicator that the product deliberately no longer sets,
and a count of zero for a notification the product deliberately keeps unread. They
now assert the restored notification directly and leave the indicator false, which
is the behavior the product implements. The independence the last two tests are
named for still holds: manual unread and a restored notification each contribute,
so the count is two until the manual half is cleared.
The assertions after markPanelRead and markRead are untouched, because marking read
clears the notification and the old expectations there were already correct. The
test names are unchanged; the CI shard timings key on them.
* cmuxTests: assert the combined unread count, not only its two flags
The independence test checked the manual indicator and the notification separately but
never the number they add up to, so a regression in either contribution could not move
a count this suite looks at. unreadCount(forTabId:) is the notification total plus one
for any workspace-level indicator, so this setup must read 2 before the panel is marked
read and 0 after.
---------
Co-authored-by: ejc3 <[email protected]>
A remote that runs tmux under a pty sends CRLF, because ONLCR rewrites every
newline on the way out. The session-list parser split on "\n" and then tried
to strip a trailing "\r", and neither step works: Swift treats CRLF as a
single Character, so the split finds no separator and `line.last == "\r"`
never matches, since the last Character of `...crlf\r\n` is `"\r\n"`.
The whole listing therefore parsed as one session whose name swallowed the
rest of the output, so such a host showed a single bogus workspace instead of
its sessions.
Split on any newline instead, which is what the sibling parser for the same
transport's stdout already does in RemoteTmuxVersion.swift. The strip and the
empty-line guard both go away, because split(whereSeparator:) omits empty
subsequences.
Before: RemoteTmuxSessionListParserTests, 8 tests, ** TEST FAILED **
After: RemoteTmuxSessionListParserTests, 8 tests, ** TEST SUCCEEDED **
Co-authored-by: ejc3 <[email protected]>
Two test files define a file-private `waitForCondition` that polls by hopping
through DispatchQueue.main under XCTWaiter, so the main queue keeps draining while
a test waits. The test target also has a module-scope one that polls with
Thread.sleep and runs no run loop at all.
Swift prefers the overload that fills in fewer defaulted parameters, so
`waitForCondition(timeout: X) { ... }` binds to the module-scope blocking helper --
it only defaults pollInterval, where the file-private one would also default file
and line. A call with no timeout: argument cannot bind to it and gets the pumping
helper. So adding a timeout silently changed which helper ran, and blocked the main
thread for the whole budget.
That is fatal for anything waiting on main-actor work. Three call sites pass an
explicit timeout and all three are red on main:
- testRemoteSplitSkipsInitialGitMetadataProbe and
testUnrelatedDefaultsChangeDoesNotRestartGitMetadataRefreshes wait for the
initial sidebar git probe to drain. That probe is registered synchronously at
schedule time and only clears once the ladder task, the snapshot, and its
MainActor.run apply get main-actor turns, so a blocking wait denies the very work
it waits for. Both fail on their first assertion, before reaching what they mean
to check, with the run wedged long enough that the crash reporter logged an ANR.
The product is correct in both cases.
- testFocusedPanelTitleRefreshesAutoWorkspaceTitleInSplitWorkspace waits for a
panel title to propagate. The .ghosttyDidSetTitle observer is registered with
queue: .main, so it runs as a queued main-queue operation rather than inline with
the post, and the apply is deferred again by the panel title coalescer's default
1/30s delay. A second of Thread.sleep starves both hops. Unlike the other two it
fails at the behavior the test is named after, with earlier assertions passing.
Renaming the blocking helper to waitForConditionBlocking makes all three resolve to
their file-private helpers again, with the same budgets and poll intervals. Its
three existing callers wait on a socket accumulator filled off the main thread, so
they keep the blocking form and are unaffected. A future waitForCondition(timeout:)
in a file without a pumping helper now fails to compile instead of quietly
blocking.
Co-authored-by: ejc3 <[email protected]>
* tests: expect composited terminal background colors
GhosttyBackgroundThemeTests and PanelAppearanceBackgroundTests still expect
GhosttyBackgroundTheme.color to hand back the configured color with the
opacity in its alpha channel. That was true until #3166, which routed the
helper through WindowAppearanceSnapshot.compositedTerminalColor: the color is
now blended over the window background, so the opacity lands in the RGB
channels and the result is always opaque. The tests were never updated, so all
three background-theme tests and one panel test have been failing since.
Compositing is the behavior we want -- chromeColorScheme derives a luminance
from this color, which only means something once it is opaque -- so the
expectations move to the composited values.
The expected blend is computed in the test rather than by calling the app's
resolver. Asserting that the resolver equals itself would agree by
construction and could never catch a compositing regression; recomputing the
blend keeps the assertions honest. Restoring the pre-#3166 withAlphaComponent
behavior locally turns all nine tests red, which is what we want from them.
* tests: enable the agent-chat flag for the menu grouping test
renderedContextMenuGroupsCreateLayoutsAndManagementTail requires a New Agent
Chat item in the new-workspace menu, but #7705 put that item behind the
agent-chat-ui-enabled-release flag with a default of off, so the item is absent
and the test fails looking for it. The two changes landed within hours of each
other -- the menu reorganization (#7709) went in first and the flag PR did not
pick up its new test.
Default-off is the intent of #7705, and this test is about where the create
entries sit relative to the Layouts section, so turn the flag on for the body
using the helper the suite already has for exactly this.
---------
Co-authored-by: ejc3 <[email protected]>
* Skip an inherited terminal surface the registry no longer owns
WorkspaceSplitWorkingDirectoryTests has two tests named for what they are meant to
prove — testNewTerminalSurfaceSkipsFreedInheritedSurfacePointer and its split-path
twin — and both currently prove the opposite by killing the test host:
_os_unfair_lock_corruption_abort
_os_unfair_lock_lock_slow
ghostty_surface_inherited_config
Workspace.inheritedTerminalConfig(preferredPanelId:inPane:)
Workspace.newTerminalSurfaceLocal(...) / Workspace.newTerminalSplitLocal(...)
inheritedTerminalConfig guarded on `surface.surface != nil`, but a non-nil wrapper
pointer is not proof the native surface is alive. Teardown unregisters the runtime
surface and only then frees it, so a wrapper still holding the pointer after an
out-of-band free hands its caller freed memory. libghostty then locks an
os_unfair_lock inside that freed allocation, the kernel detects lock corruption, and
the process is SIGKILLed — so an unrelated suite sharing the test host dies too, and
its verdict is lost with it.
The registry owns exactly the pointers that have not been freed, which makes it the
liveness oracle a nil-check cannot be, and TerminalSurfaceRegistering already exposes
runtimeSurfaceOwnerId. TerminalSurface gains liveRuntimeSurface, which returns the
pointer only while the registry still owns it and otherwise clears the wrapper, so
every caller that reads through it sees nil instead of freed memory. Clearing goes
through the existing setter, so it advances runtimeSurfaceGeneration exactly as a
normal teardown does and pointer-backed caches invalidate for the same reason.
inheritedTerminalConfig now reads liveRuntimeSurface, so a stale candidate is skipped
like a torn-down one. That is also the behaviour the tests' second assertion asks for:
XCTAssertNil(sourcePanel.surface.surface, "Expected stale surface pointer to be
quarantined").
* Use liveSurfaceForGhosttyAccess for the inherited-config liveness check
Review pointed out that the new liveRuntimeSurface accessor checked only that
the registry owner was non-nil, so a recycled pointer owned by a different
surface would pass. liveSurfaceForGhosttyAccess already does the full check —
owner id equality plus an allocation-liveness probe — and quarantines a stale
wrapper the same way. Delete the weaker duplicate and call the existing
accessor from inheritedTerminalConfig; a stale candidate is still skipped in
favor of the font-lineage fallback.
---------
Co-authored-by: ejc3 <[email protected]>
Seven test call sites constructed a bare SavingTextView(). The product never
does: makeFilePreviewTextView() builds an explicit TextKit 1 stack, because a
default NSTextView is TextKit 2 and its selection path pegged the main thread on
large documents (#4576, #5255). A bare init therefore has no configured text
container, and it also skips applyFilePreviewTextEditorInsets(), which the
factory applies.
So these tests exercised a view the app refuses to ship, and failed on it: the
save-shortcut tests read back an empty string instead of the saved text, and the
inset test read nil where it expected a value.
The files already disagreed with themselves. FilePreviewReviewFeedbackTests used
the bare init at line 44 and the factory at line 408, CanvasShortcutContextTests
uses the factory at three sites, and FilePreviewTextEditorTextKitTests exists to
assert the factory yields a pure TextKit 1 view.
FilePreviewPanelTextSavingTests 27 tests, 3 failures -> 0 failures
FilePreviewReviewFeedbackTests 17 tests, 1 failure -> 0 failures
Both suites need the window-release guard in #8701 to reach these assertions at
all; without it they crash the test host first. Both arms above were measured
with that guard applied.
Co-authored-by: ejc3 <[email protected]>
* tailscale: reject non-canonical (leading-zero) IPv4 when classifying peers
parseIPv4 accepted "0100.64.1.2"-style leading-zero octets as decimal on
Darwin versions with a lenient inet_pton, so a host the dialer's inet_aton
reads as octal was classified as a Tailscale peer. Require the parse to
round-trip through inet_ntop to canonical dotted-decimal and refuse any
spelling that does not. IPv6 is unchanged.
* tailscale: reject non-canonical (leading-zero) IPv4 when classifying peers
parseIPv4 accepted "0100.64.1.2"-style leading-zero octets as decimal on
Darwin versions with a lenient inet_pton, so a host the dialer's inet_aton
reads as octal was classified as a Tailscale peer. Require the parse to
round-trip through inet_ntop to canonical dotted-decimal and refuse any
spelling that does not. IPv6 is unchanged.
---------
Co-authored-by: ejc3 <[email protected]>
branchTokensForSearch split a branch ref on the metadata delimiters (which
include "-"), so "feature/cmd-palette-indexing" tokenized to feature/cmd/palette/
indexing and the hyphenated short name never appeared in the search index; typing
the branch short name found nothing. Emit the part after the last "/" as a whole
token too, the way a directory basename already is.
Co-authored-by: ejc3 <[email protected]>
A single kick near the end of an existing scan burst can receive fewer than the three authoritative misses required to retire a stale port. Pin that idle-workspace timing path before changing the scheduler.
* sidebar-git: fix four unsatisfiable tests in the sidebar git suites
Two of them describe a git index that git cannot produce. The index trailer is the
SHA-1 of the index content, so rewriting only the trailing checksum while leaving
the entry table byte-identical is not a state a real repository reaches. The
product reads that shape deliberately: the index content signature covers the
entry count, path, mode and object id but not the trailer, so an index whose
content signature is unchanged is rebaselined as clean, which is exactly what
testCleanIndexSignatureRebaselinesWhenIndexRewriteKeepsTrackedContentClean pins.
The v4 and empty-index tests asserted the opposite for the same input, so one of
the two had to fail. They now stage a real change -- a new object id, and an added
entry -- whose stat still matches the worktree, so the dirty verdict comes from the
content signature the way it does for a real staged change.
The predicates are unchanged; the empty-index test's scenario and message move
from a staged delete to a staged add, because staging the first entry out of an
empty index is what actually moves the content signature.
writeGitIndexVersion4 gains the objectIDBytes parameter that
writeGitIndexVersion2EntryFromStat already had. It defaults to the zero id, so the
test call sites that do not stage a change need no edit; the convenience overload
threads it through. writeGitIndexVersion3SkipWorktreeEntry still
hard-codes a zero object id; it has no need to express a staged change.
testDisablingGitWatchClearsCachedPullRequestBadgesWhenPullRequestsAreShownByDefault
sent a scoped report_git_branch to a TabManager that only TerminalController knew
about. That path resolves its workspace through AppDelegate's main-window
contexts, so the report was dropped and the seeded badge survived. The test now
registers a windowless context like the other socket-routing tests, and asserts
the workspace is resolvable before sending, so a future wiring break reports there
instead of at the far assertion.
testSameDirectoryInitialGitMetadataProbesShareOneSnapshotRead waited with a helper
that blocks the main thread while pumping the main queue. That works in a
synchronous test, but this body is async: it runs as a main-actor job, inside a
main-queue drain that libdispatch will not re-enter, so the nested run loop ran
neither the helper's own poll hops nor the snapshot's MainActor.run apply. The
wait could only expire. It now awaits a suspending sibling helper with the same
timeout and interval.
The suspending helper propagates cancellation rather than swallowing it, so a
cancelled test unwinds instead of spinning the condition until its deadline.
* test: use a monotonic clock in the suspending wait helper
---------
Co-authored-by: ejc3 <[email protected]>
`swift test` in Packages/macOS/CmuxNotifications does not build:
NotificationDismissalModelTests.swift:47:5: error: missing return in
instance method expected to return 'UUID?'
A test double's panelId(forSurfaceOrPanelId:in:) bumps a counter and then
leaves its lookup as a bare expression. Two statements means no implicit
return, so the whole target fails to compile and all 66 tests in its 5 suites
are silently skipped.
Return the lookup. Nothing else in the target changes.
Co-authored-by: ejc3 <[email protected]>
Pass -w to the PID-scoped TCP listener query so unrelated filesystem mount warnings cannot poison every scan. Actual command failures, timeouts, malformed output, and PID-scoped uncertainty remain incomplete.
This is the green half for the persistent-warning regression in 6b54e60f76.
Issue #9152 reports a Time Machine filesystem warning on every lsof invocation. Model that warning in the full port lifecycle harness and prove PID-scoped TCP evidence must remain authoritative, while tightening the existing process-identity and retry test seams.
This is the tests-only red half of the regression pair.
* test: full unacked browser-stream window must recover, not deadlock
A full window whose acknowledgements never arrive (subscriber not yet
wired at start, connection route swap) currently parks the stream in
.flowControlled forever: the phone shows 'Waiting for Browser' with no
recovery path. Red on purpose; the fix lands in the next commit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: make the mobile browser stream self-healing
Three liveness fixes for the iOS browser mirror, all Mac-side:
Ack-stall recovery: a full unacked window now converts to a bounded
wait and, after 3s with no ack progress, is abandoned in favor of a
fresh capture. Frames are self-contained images, so recapture is a safe
retransmission. Previously lost in-flight frames (subscriber not wired
yet, connection route swap) deadlocked the stream and the phone sat on
'Waiting for Browser' forever.
Synchronized first capture: the first snapshot for a (re)hosted web
view now waits for WebKit's next committed render (bounded by a 1s
timeout with a 3-attempt fallback) instead of capturing the blank white
uncommitted buffer that used to be the phone's first frame. All other
captures get a 2s timeout so an occluded render host cannot wedge the
drive loop on a synchronized settle snapshot.
Idle reconciliation: an idle stream emits one lossless settle frame
after 10s of quiet. Page-driven dirty signals are lost whenever WebKit
suspends requestAnimationFrame for the occluded offscreen host, which
killed the injected beacon and froze the mirror on stale or blank
content; this bounds any such staleness to 10s.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: scope capture commitment to the captured web view
A capture that raced a web view replacement must not mark the
replacement's synchronized first capture as done (its first frame would
be the blank bitmap again), nor consume its bounded retry budget.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add regression test for cancelled last-terminal close
* Restart last terminal when close is cancelled
* Allow quit-cancel flow in debug UI tests
* Keep terminal recovery in the close transaction
* Always confirm quit after the last terminal exits
* Preserve terminal recovery with window docks
* Keep quit recovery inside the close request
* Document last-terminal quit confirmation invariant
* Clear close state only after cancellation
* Join terminal recovery to active quit decisions
* Test the last-terminal quit decision end to end
* Preserve notifications until quit commits
Restore the original navigation-action API and remove the regression test that encoded the same-URL stale-identity fallback. Keep PR #9482 nil/empty normalization as the sole convergence rule.
* test: require Tailscale-only Mac pairing QR
* fix: focus Mac pairing on Tailscale QR
* test: require Tailscale pairing action names
* fix: name QR entrypoints for Tailscale
* test: require Tailscale setup guidance in scanner
* fix: explain Tailscale pairing prerequisites
The workspace list's computer picker already switches Macs, pairing
lives in the Connection Method section and onboarding, and hiding a
computer lives in the Hidden Computers list. Settings > Switch Computer
duplicated all three, so drop MobileHostPickerView, its Settings entry,
and the 15 mobile.hostPicker.*/switchMac localization keys (en+ja) it
alone used. The Connection section now renders only when it has a live
connection row, so its header never sits empty.
Co-authored-by: Claude Fable 5 <[email protected]>
* Add model selection lab to the New Task composer
Adds a curated per-provider model catalog (Claude, Codex, OpenCode) with
opt-in model-flag injection into template commands, and five UX variants
for picking the model in the New Task sheet (combined agent menu, model
row, trailing chip, pill strip, context row), switchable at runtime from
the DEBUG-only CMUX Labs 'New Task Model Lab'. No model selected keeps
template commands byte-for-byte verbatim; release builds stay off.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add Codex-style composer layout to the New Task sheet
New minimal layout (lab-switchable, DEBUG default): full-bleed prompt
canvas titled by the working directory, back chevron, and a bottom
control bar with a + options sheet (name, Mac, directory), agent pill,
model pill, and a circular submit button. Classic card layout stays
available via CMUX Labs and renders unchanged; release builds keep
classic. Adds GPT-5.6 Luna to the Codex model catalog.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review findings on the model picker
- Dissolve the MobileTaskAgentModelCatalog static namespace into
MobileTaskAgentProvider (detection init, models, model(id:),
command(applying:to:)) per the no-static-namespace policy.
- Replace an existing --model/-m/--model= value in place instead of
injecting a duplicate flag that the template's own value would
override; stop scanning at the -- end-of-options token.
- Pin the task-composer accessibility preview to the classic layout and
Off variant on fresh installs so the XCUITest suite keeps a stable
element tree; CMUX_UITEST_TASK_COMPOSER_LAYOUT/_MODEL_VARIANT opt in.
- Give each lab variant exactly one placement in the composer layout:
combined stays in the agent submenu, contextRow stays in Task Options,
the rest collapse to the standalone bottom-bar pill.
- Gate the composer submit button on blocking completed-operation
recovery, matching the classic layout.
- Make combined menu taps a single atomic template+model mutation.
- Share the model display-name fallback and accessibility triple; share
the directory search/list fallback closures.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-2 review findings
- Quote-aware token scanning in command(applying:to:): flag text inside
single/double-quoted arguments is one opaque token, so quoted mentions
of --model are never rewritten; every real model flag before -- is
replaced (not just the first); a flag directly before -- gets its
value supplied in place.
- Gate selectedModel on the rendered picker variant so a draft-restored
model cannot ride into snapshots or submissions while the picker is
Off; the stored selection survives for when a variant is re-enabled.
- Show the selected model in the composer layout's combined variant
(agent pill title gains ' · <model>') and add visible checkmarks to
the combined submenu rows.
- Extend compact composer controls (+, submit, pills, chip, row, pill
strip) to 44pt activation targets without changing their visuals.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-3 review findings
- Stop the model-flag scan at the first simple command's end: a newline
separator or a token carrying an unquoted ;, |, or &. A compound
template like 'claude "$CMUX_TASK_PROMPT"; formatter --model compact'
now inserts Claude's flag after the first token and leaves the later
command untouched.
- Route every submission through effectiveSubmissionSnapshot: while the
picker variant is Off, a hidden model captured by the cached restored
request (or an adopted recovery request) is stripped and the command
recomposed with the same operation identifier, closing the untouched-
draft bypass of the selectedModel gate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-4 review findings
- Build the composer options sheet lazily: MinimalLayout now takes a
deferred builder, so directory-candidate construction (a workspace
walk) runs only when Task Options is presented, not on every prompt
keystroke's body rebuild.
- Reconcile a hidden model at the submission boundary by marking the
request dirty instead of post-hoc snapshot surgery: resolution runs
through makeSubmissionSnapshot (whose selectedModel gate strips the
model) and MobileTaskSubmissionIdentity mints a fresh operation ID for
the changed bytes, keeping retries idempotent.
- Process a model flag attached to a command separator: --model=old;,
--model old;, and --model; are rewritten before scanning stops, so a
stale value can no longer override the selection.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address round-5 review findings
- Reconcile a hidden model at BOTH request-resolution boundaries via one
shared resolver: when the Off picker hides a model a clean cached
request still carries, resolution is forced through the selectedModel
gate and the identity mints a fresh operation ID, so a persisted draft
can never pair model-less bytes with an ID previously bound to
model-bearing bytes. Replaces the submit-only proxy check.
- Treat redirection operators as part of the simple command: & adjacent
to > (2>&1, >&2, &>file) and | preceded by > (>|file) no longer end
the flag scan, so a stale --model after a redirection is still
replaced. Control operators (;, |, &, &&, ||) still end it.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address final review round: comments and delisted-model drafts
- Stop the model-flag scan at an unquoted word-initial #: a commented
flag is never rewritten, and the selection is inserted after the first
token instead of being silently swallowed by a comment edit.
- Do not reuse a draft's operation ID (or restore its completed-
operation recovery) when the draft's model no longer survives
curated-list validation; the resulting default-model command gets a
fresh idempotency key.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use the adjustments glyph for the composer options button
Dogfood feedback: + implied adding something; the button configures the
task (name, Mac, directory), so it now shows slider.horizontal.3.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Stop pill labels clipping when the selection gets longer
Dogfood feedback: switching the agent or model to a longer title left
the pill label clipped for the length of the resize animation. The pill
content now uses fixedSize so the capsule adopts the new intrinsic
width immediately, and the label subtree is identity-keyed on the title
so it swaps instead of animating through stale-width frames.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add mobile task attachments
* Always show the model pill in the composer layout; fix iOS 26 pill clipping
Dogfood feedback: the combined lab variant hid the standalone model
pill (models lived only in the agent submenu), which read as the model
picker disappearing. The composer layout now has one canonical model
treatment: a dedicated pill beside the agent pill for every non-Off
variant; the agent menu is forced plain and the options sheet never
repeats the contextRow, so the pill stays the single entry point.
The label clipping on longer titles survived the fixedSize fix because
the identity key sat on the label content while the UIKit menu button
still animated its frame. The .id now keys the whole Menu, so a title
change swaps the button instead of animating through stale bounds.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Fade the composer pill scroller into the bar at both edges
Dogfood request: pills should dissolve toward the neighboring options
and submit buttons instead of clipping at the scroller bounds. iOS 26
uses the native soft scroll edge effect (progressive blur + fade);
earlier systems approximate it with a 14pt alpha-mask fade per edge.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Discover task models from connected Macs
* Adopt MacPairingKey lookups in task capability checks
Main's typed MacPairingKey re-key changed the secondary-subscription
registry key from a device-id string to the full pairing key. The
attachment and model-discovery capability checks now resolve through a
shared controlSubscriptionMatching helper that keeps the old semantics:
exact pairing when a tag is given, any same-device pairing otherwise.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Scroll the composer pills under the bar buttons with a real edge effect
Dogfood feedback: the scroll edge effect never rendered because the
buttons sat NEXT TO the scroller, so no content ever passed beneath an
edge. The pill scroller now spans the bar with the attachment/options
buttons and the submit button living in its leading/trailing safe-area
insets: pills genuinely scroll under them, which is what activates the
native iOS 26 soft scroll edge effect (progressive blur + fade). Pre-26
keeps an opaque button background as the fallback occlusion.
Also stop the prompt editor from yanking long text back down while
scrolling up: interactive keyboard dismissal resized the editor every
drag frame and UITextView re-scrolled to the caret each time; dismissal
is now immediate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Render the pill scroller edge effect through UIKit's container interaction
SwiftUI's scrollEdgeEffectStyle only styles effects the system already
owns (bars/glass), so pills merely underlapped the buttons. The bar row
is now a thin UIKit host: a horizontal UIScrollView spans the bar, the
button clusters float above it, and on iOS 26 each cluster carries a
UIScrollEdgeElementContainerInteraction bound to the scroll view's
edge, which renders the real progressive blur+fade beneath the buttons
as pills pass under. Pre-26 clusters keep an opaque background.
Content insets track the cluster widths (the attachment button is
capability-gated), resting the pills between the clusters.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Attach the scroll edge effect via probes instead of rehosting the bar
The UIKit-hosted bar livelocked SwiftUI (hosting-controller sizing
feedback re-rendered every frame) and blanked the composer, which also
made the pills unscrollable. The pills return to the proven SwiftUI
ScrollView under safe-area-inset button clusters; a zero-size probe in
the scroll content walks to the backing UIScrollView and a coordinator
binds UIScrollEdgeElementContainerInteraction to transparent container
views behind each cluster. Fail-soft: if the probe finds no scroll view
or the OS predates iOS 26, nothing attaches and the bar just underlaps.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Reproduce the scroll edge effect deterministically in SwiftUI
Two native attempts failed structurally: SwiftUI's scrollEdgeEffectStyle
never renders for floating siblings, and hosting the bar (or just the
clusters) in UIKit for UIScrollEdgeElementContainerInteraction either
livelocked the view graph or dropped cluster content, because the
effect's shape must come from the container's descendants. The bar now
stays pure SwiftUI: clusters carry an ultraThinMaterial background
(full blur under the buttons) and a 24pt gradient-masked material band
beside each cluster fades passing pills into the bar background --
the same progressive blur+fade the system effect draws, with no
UIKit bridging left to break scrolling or layout.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Use native scroll edge effects in task composer
* Fix task composer scroll edge blur
* Use native shaped scroll edge effects
* Test composer pill scroller hard edges
* Fix composer hard-edge UI test lookup
* Restore hard edges to composer pill scroller
* Exercise overflowing composer pills in hard-edge test
* Test composer prompt scroll gesture ownership
* Prioritize prompt scrolling over sheet drag
* Test composer prompt scroll position stability
* Keep composer prompt at manual scroll position
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test: cover mobile input session ownership
* fix: centralize mobile terminal input ownership
* chore: add mobile dock verification geometry
* test: cover keyboard ownership review edges
* fix: close mobile input ownership review gaps
* Test foreground recovery teardown handoff
* Keep disconnected recovery foreground-only
* Respect the active foreground recovery owner
* Test clientless foreground aggregation
* Require a live client for aggregation
* Use UIKit keyboard guide for terminal dock
* Test workspace group docs locale overrides
* Translate workspace group anchor guidance
* Test localized workspace group action labels
* Translate workspace group action labels
* Test workspace group docs match native labels
* Match Khmer docs to workspace group menu
* Use static imports in localization test
* Add regression tests for PATH directory shadowing of provider binaries
FileManager.isExecutableFile(atPath:) returns true for directories on macOS,
so a directory named like a provider binary earlier on PATH is selected by the
CLI and app PATH walks. These tests fail until the resolvers reject directories.
Refs #8743
Co-Authored-By: Claude Opus 5 <[email protected]>
* Skip directories when resolving provider executables on PATH
FileManager.isExecutableFile(atPath:) returns true for directories on macOS, so
a directory named like a provider binary (~/bin/omx/, ~/bin/claude/) earlier on
PATH was selected as the executable and the launch failed at execv with a
confusing "Permission denied". Reject directories with
fileExists(atPath:isDirectory:) before the executable check in all three PATH
walks, mirroring the guard resolveClaudeExecutable already applied to configured
candidates.
Fixes#8743
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add Pi landing page and agent SEO
* Keep homepage copy localized
* Limit Pi discovery to English
* Expand coding agent SEO coverage
* Localize coding agent landing pages
* Localize Pi guide card
* Remove stale English-only Pi link copy
* Keep agent metadata locale-native
Fix same-pane tab reordering to middle indices by taking the Bonsplit SwiftUI delegate path as the sole reorder owner. Includes hosted E2E coverage for later-to-middle and earlier-to-middle tab drags.
* ios: keep diff scroll momentum by persisting the row only at scroll idle
FileDiffPageView propagated every scrollPosition row change up into the
pager's @State while the finger was still down or the view was
decelerating. Each write re-rendered the pager mid-fling and the bound
scrollPosition(id⚓.top) re-anchored the tracked row on the next
layout pass, cancelling the remaining momentum: lifting the finger
stopped the diff dead.
Route persistence through SettledScrollRowReporter, which reports the
tracked row only when the scroll phase returns to idle (plus once on
page unmount), so nothing re-renders during a fling. Restore-on-remount
behavior is unchanged.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: add a many-screen diff to the changes preview fixture
Every hand-written fixture diff fits on one screen, so scroll flings
and deceleration could not be exercised deterministically. Add a
400-line generated diff (Sources/RenderPipeline.swift) to the DEBUG
changes preview fixture.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: drop the anchor from the diff page's live scrollPosition binding
On-sim verification showed flings still died with only the idle-phase
reporter fix: scrollPosition(id⚓.top) itself re-aligns the
tracked row flush to the viewport top on every internal position
update during deceleration, so the fling stops at the first row
crossing (the settled frame shows the row pixel-flush at the top).
Removing the anchor removes the alignment contract; tracking and
restore-on-remount keep working via the id binding.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: give the diff scroll offset a single owner
Real-path dogfood showed flings, rubber-banding, and pull-to-refresh
displacement all being cut short on real diffs even with the anchor
removed: any live scrollPosition(id:) binding makes SwiftUI a second
continuous owner of the scroll offset, and on heterogeneous multi-
thousand-row diffs every lazy row materialization re-resolves the bound
position against the moving offset (the uniform 400-row fixture never
re-resolved, which is why the earlier sim verification passed).
Drop the binding entirely. The top row is tracked in a plain reference
box via onScrollTargetVisibilityChange (no view state, no body
dependency, no layout participation), persisted at scroll-idle and on
unmount as before, and restore-on-remount becomes a one-shot
ScrollViewReader.scrollTo at appear. After that single command the
offset is owned exclusively by the scroll view's physics.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: resolve the settled diff row by document order, not callback order
onScrollTargetVisibilityChange documents no ordering for the ids it
reports, so taking visibleIDs.first as the top row was an assumption.
FileDiffPresentation now carries a rowOrderIndex built once alongside
the rows (off-main on the async paths), and TopVisibleRowPolicy picks
the id earliest in document order. Also balances the braces in the
generated fixture diff and documents why the pre-iOS-18 fallback is
acceptable (app floor is iOS 18.4; macOS builds this package for
tests only).
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: preserve diff restore across refresh
* ios: avoid fixture lint false positive
* ios: run diff preparation off the main actor
* ios: align sentry-cocoa pins with main (9.24.0)
The fleet builder's shared warm DerivedData precompiles Sentry modules
against main's pin; this branch's older 9.21/9.23 pins invalidated those
.pcm files ("header has been modified since the module file was built")
and failed every cloud iOS build. Pins-only change, byte-identical to
main's lockfiles.
Co-Authored-By: Claude Fable 5 <[email protected]>
* ios: restore the diff scroll row from an explicit target, not the tracker
On-sim the remount restore landed at the file top: the visibility
callback fires for the unrestored top of the list before onAppear runs,
overwriting rowTracker.topRowID, so restoring from the tracker anchored
to row 1. The restore target is now captured explicitly — from the pager
at mount, from the live tracker only when a refresh re-arms the restore —
and scrollTo is re-applied once after the first layout pass because
LazyVStack only estimates offsets for unrealized rows.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Add failing test for auto-naming --mcp-config argument
Extracts the claude summarizer argv into
AutoNamingEnvironmentPolicy.claudeSummarizerArguments and asserts the
--mcp-config value is a valid MCP configuration object. It currently
emits a bare {}, which Claude Code rejects.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Pass a valid empty MCP configuration to the auto-naming summarizer
Claude Code 2.1.220 validates --mcp-config against a schema requiring an
mcpServers record, so the bare {} cmux passed made the summarizer exit
on argument validation and every workspace auto-naming attempt recorded
category: failed. Emit {"mcpServers":{}} instead.
Fixes#9457
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add failing test for leaf local-path Package.resolved false positive
Adding a dependency-free local-path package to a manifest that already has
remote pins makes check-package-resolved-policy.py demand three Package.resolved
diffs that swift package resolve cannot produce.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Key Package.resolved policy off reachable remote dependency calls
check-package-resolved-policy.py demanded a Package.resolved diff whenever a
manifest's dependency calls changed and that manifest's graph had any remote
dependency anywhere. Adding a dependency-free local-path package to such a
manifest therefore reported violations for lockfiles that `swift package
resolve` leaves byte-identical, so the demanded diff could not exist.
The graph now records the normalized text of every `.package(url:)` call per
manifest, and a manifest edit requires a lockfile diff only when the set of
url calls reachable through its local-path closure differs between merge-base
and HEAD. That set is exactly what SwiftPM pins, so version-requirement bumps
on an unchanged URL and newly reachable remote-bearing local packages still
require the diff.
Fixes#8871
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add failing test for recovered daemon transport bounce leaving sidebar error
Co-Authored-By: Claude Opus 5 <[email protected]>
* Retract recovered daemon transport errors from the workspace sidebar
Fixes#8917
* Move the daemon recovery regression test to Swift Testing
* Drop stray blank line in WorkspaceRemoteConnectionTests
* Import CmuxSidebar in the daemon recovery test
---------
Co-authored-by: Claude Opus 5 <[email protected]>
Track whether mobile workspace-list responses actually include groups, preserve the last authoritative group snapshot through reconnect and empty transient states, and allow healthy connected empty snapshots to clear stale group headers.
* Add failing test: Attempt Update with no update available must not report install failure
* Add failing UI test: Attempt Update with no update available must not show an error pill
* Treat 'no update available' as a success in Attempt Update
Fixes the red "Update Didn't Start / check your internet connection" pill
shown when Attempt Update runs while already on the latest version.
* Add failing regression test for Cmd+Shift+R on a focused workspace group
Covers https://github.com/manaflow-ai/cmux/issues/9199: renaming from the
shortcut while a group's anchor row is focused leaves the group header
name untouched.
* Rename the focused workspace group with Cmd+Shift+R
A workspace group's header row is backed by an anchor workspace whose own
title is hidden: the row renders the group name. The anchor's title is
seeded from the group name at creation and never resynced, so renaming
the focused anchor workspace prefilled a stale name ("Group 3") and
changed nothing visible.
Resolve the palette rename target through a shared resolver: when the
focused workspace is a group anchor, target the group, matching that
row's "Rename Group..." context menu item. Every other workspace still
renames itself.
Fixes#9199
* Move rename-target resolution onto CommandPaletteRenameTarget
Review feedback: the static-only resolver enum was a namespace type. The
focused-workspace resolution is now an initializer on the value it builds,
and the group anchor descriptor lives in its own file.
* Make the group rename UI test tolerate headless CI activation
* Assert the group rename regression through the control socket
The accessibility-label assertion passed on the unfixed build, so it was
not catching the bug. Setup and verification now go through the control
socket: create a group, rename only the group so the anchor workspace
title goes stale, focus the anchor, press Cmd+Shift+R, and assert the
group's name in the model changed.
* Drop the non-discriminating group rename UI test
The test passed on a build without the fix (run 30786168603), so it did
not capture the regression. Coverage stays with the resolver unit tests
until a UI-level check that actually fails on the bug is written.
* Restore the group rename UI test with a launch path that cannot pass vacuously
The previous version wrapped app.launch() in a non-strict XCTExpectFailure.
On a headless runner that absorbs the launch failure and, with
continueAfterFailure = false, abandons the rest of the test body without
recording anything, so the test reported success without running a single
assertion (proved by a variant carrying an unconditional XCTFail that also
passed: run 30787440944).
* Drop the group rename UI test: it cannot run on the e2e runner
With the vacuous-pass workaround removed, the test fails on both a fixed
and an unfixed build at the same line: app.activate() raises "Failed to
activate application (current state: Running Background)". The hosted
runner has no foreground GUI session, and XCUITest keystrokes only reach
a frontmost app, so a keystroke-driven test cannot work there.
Runs: 30788920066 (no fix) and 30788928069 (fix) — identical failure.
Pins the cmux Ghostty submodule to the locale-before-crash-reporting startup fix. ASC evidence for local 2026-08-02 showed all three cmux INTERNAL reports shared EXC_BAD_ACCESS in ghostty_init + 1388 with the main thread in setlocale/loadlocale via GhosttyRuntime.swift:110.
* Add failing tests for route-content equivalence hardening
Pins six behaviors from the cubic review of #9342: reorder-only
capability, relay fleet, and grant verification key revisions keep
live sessions; a snapshot installed for a revision recorded without
content fails closed; an older route revision install cannot roll
back a newer one; a redundant-dial close raced by invalidation
redials instead of returning the closed winner.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Harden route-content equivalence against reorders and races
Canonicalizes route content so order carries no meaning where the
admission policy reads sets: binding capabilities, the relay fleet,
and grant verification keys (by kid) are sorted when the content is
built, so reorder-only revision bumps keep live sessions.
didInstallRouteRevision now drops installs older than the recorded
revision, so an older completion of an overlapping reconciliation
cannot roll back a newer installed revision. The same-revision branch
compares the stored baseline and fails closed through the standard
superseded-peer invalidation when the baseline is missing or differs,
instead of silently adopting the content.
The peer session no longer returns a stale winner capture after the
redundant-dial close: settleRedundantDial re-reads the active slot
and its liveness after the close suspension and redials when the
winner was invalidated, replaced, or remotely closed.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* test(ios): cover workspace group row actions
* fix(ios): restore workspace group row actions
* test(ios): cover group destructive confirmations
* test(ios): cover group read-state action refresh
* fix(ios): refresh group native action state
* test(ios): cover group native action inputs
* fix(ios): refresh group native action inputs
* test(ios): cover group swipe completion and rename alert
* fix(ios): restore workspace preview compilation
* test(ios): target visible group rename fixture
* fix(ios): preserve group swipe completion and compact rename
* test(ios): exercise group action presentation lifecycles
* test(ios): preserve workspace actions on group menus
* fix(ios): preserve workspace actions on group menus
* test(ios): exercise full group read swipe
* test(ios): isolate native group menu assertions
* test(ios): cover preserved group actions
* test(ios): keep preview fixture state owned
* test(ios): cover preserved group create actions
* fix(ios): preserve group creation entrypoints
* fix(ios): make destructive group requests atomic
* test(ios): cover configured group icons
* fix(ios): sync effective group icons
* test(ios): target live group row swipe
* test(ios): disambiguate group workspace rename
* fix(ios): disambiguate group workspace rename
* test: cover disconnected iOS dogfood launch
* fix: require connected iOS dogfood launches
* fix: make mobile readiness event driven
* Add failing iroh wake reconnect regressions
* Guarantee bounded foreground reconnect
* fix: harden mobile readiness lifecycle
* perf: buffer deadline event reads
* Add failing test: session snapshot mid-revalidation must classify transient
Every launch/foreground kicks a /users/me revalidation and
sessionTokenTransitionIsActive is true for its whole round trip.
authenticatedSessionSnapshot() throws .unauthorized for that window, which
the iroh broker token source treats as signed out, so endpoint activation
fails closed (endpointFailed authorizationFailed) on every app launch until
the revalidation completes. The same state is already classified
.networkError by accessToken(); the snapshot must match.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Classify transient token misses as connectivity, not authorization failure
Three-layer fix for the launch-time wedge where every iroh endpoint
activation failed closed (endpointFailed authorizationFailed) while a
foreground session revalidation owned the token store:
1. AuthCoordinator.authenticatedSessionSnapshot() now throws .networkError
while sessionTokenTransitionIsActive, matching accessToken()'s
classification. Every launch/foreground kicks a network /users/me
revalidation, and that window previously read as "signed out".
2. CmxIrohBrokerTokenSource.credentialPair is now throwing. A throw means
"cannot read a coherent pair right now" and the broker classifies it
.connectivity, so retry policies, verified-policy preservation, and the
cached offline-policy bootstrap all apply. nil still means definitively
signed out and fails closed with .missingAuthentication.
3. The iOS activation token source maps AuthError.unauthorized to nil
(fail closed) and rethrows every transient failure instead of collapsing
both into nil with try?.
Diagnosed from cmuxdiag exports on build 1.0.4 (20260731034828): three
consecutive relayPolicyRefreshFailed/endpointFailed(authorizationFailed)
within 10ms each (no network round trip) at launch, recovering only ~15s
later when the revalidation settled and the backoff retried.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Apply the same transient-token classification to the Mac host runtime
The Mac host's activation token source had the identical try? collapse:
a session revalidation window read as signed-out and tore the host
runtime down as unauthorized. Same mapping as iOS: unauthorized fails
closed with nil, transient failures rethrow and classify connectivity.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Add failing wake-auth transport regressions
A broker 401 at app wake (token pair rotated by another lane between
capture and server validation) must not tear down the verified iroh
runtime, and the Mac being redialed must not be dialed a second time as
a background-control aggregation candidate.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Survive wake-time broker auth rejections without endpoint teardown
At app wake the relay-policy refresh races the RPC lane's force token
refresh: the pair captured coherently a moment earlier reaches the
broker after rotation and gets a 401. That single 401 used to fail the
endpoint, clear routes and the offline cache (or tear down the whole
runtime on warm wakes), and nap 30-36s of flat backoff, turning a
seconds-long token race into the 30s-2.5min reconnect outages visible
in every wake ring.
Four changes:
- CmxIrohTrustBrokerClient recovers exactly once from a 401: the token
source re-captures (force-minting only when the rejected access token
is unchanged) and the request retries with the recovered pair. Frozen
pinned sources (sign-out revocation) opt out by default.
- 401/403 now preserve verified policy during refresh, and 401 retries
initial activation; resolvePolicy falls back to the verified offline
bootstrap on auth rejections like it already did for connectivity, so
LAN and cached-relay dials keep working while auth settles.
- The relay-policy refresh loop retries authorization failures on a
2s..120s ladder instead of the flat 30s+jitter schedule.
- The Mac being redialed is excluded from secondary aggregation while a
stored-Mac reconnect is in flight, removing the duplicate
background-control dial (and its drain wait) from every recovery.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Handle route-gated diagnostics in iOS settings
* test: remove source-shape admission assertion
* test(iroh): cover cached registration recovery
* fix(iroh): recover cached host registration
* test connection readiness failures
* test: cover cached host binding publication
* fix: publish cached mobile host binding
* iOS: replace disconnect chrome with Mail-style status line under the computers picker
While a reconnect attempt has not been rejected, the last visible workspace
list and terminals stay accessible. The workspace list shows a caption status
line (spinner + Reconnecting… / Not Connected) under the computers picker,
like Mail's Checking for Mail…; the terminal keeps only the compact status
pill. The full-screen TerminalDisconnectedOverlay, the list's
Disconnected/Reconnecting status row for non-startup states, and the
connection status toasts are removed. The reauth banner (rejected
connection, Sign Out is the only fix) and the initial-restore status row
(Retry / Add Computer, possibly no cached content) remain. Input gating and
the pill's recovery folding, previously behind the Toasts beta flag, are now
unconditional; a Reconnect item appears in the picker menu while Not
Connected.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Harden mobile connection readiness
* Keep subscription readiness separate from recovery
* Model delayed subscription acknowledgements
* test: cover usable mobile session readiness
* fix: require usable mobile connection readiness
* test: fail closed across broker auth cancellation
* test: cover complete iOS dogfood readiness
* test: fail closed when Mac pairing setup is unavailable
* fix: make iOS dev reload dogfood ready
* test: require ensure-mac to self-heal exact tag
* fix: let ensure-mac relaunch its exact tag
* fix(ios): keep list probe state coordinator-owned
* test(ios): pass active listener to recovery validation
* test: cover unsigned simulator identity evidence
* fix: trust seeded identity in unsigned simulator
* test: disambiguate group rename alert save
* test: expose expired-ticket group rename failure
* Authorize mac-scoped workspace mutations by Stack account, not ticket lifetime
The mobile data plane's design authority is the signed-in Stack account;
attach tickets are route discovery plus scope narrowing. Four verbs
(workspace.move, workspace.group.action, workspace.group.create, and
workspace.create with group_id) still hard-required a current attach
ticket, and minted tickets default to a 600s TTL, so iOS drag-and-drop
and the + button's New Workspace Group item silently disappeared ten
minutes after pairing (and never appeared for tokenless zero-touch
pairings).
Host: ticketAuthorizationResultIfNeeded no longer fails these verbs when
the attach token is missing, unknown, or expired; a token that maps to a
current stored ticket still narrows scope, so workspace-pinned tickets
remain rejected for Mac-wide mutations. Advertised as
workspace.mutations.account_auth.v1.
iOS: MobileShellWorkspaceMutationTicketPolicy mirrors the host: against
hosts advertising the capability, mutations stay allowed unless a
current workspace-scoped ticket narrows the connection; legacy hosts
keep the fail-closed behavior. Applied to the foreground gate, the
per-target mutation gate, and secondary-Mac handle capabilities.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): cover recovery transport drain
* fix(ios): drain stale route before recovery
* test: expose process-local readiness clock
* fix: use system monotonic readiness clock
* test(ios): expose scoped-ticket group rename gap
* fix(ios): preserve account-authorized group actions
* test(ios): keep group menus group scoped
* fix(ios): keep workspace group menus group scoped
* fix(ios): pass readiness clock after main merge
* test(ios): close group action review gaps
* Fix missing return in restoreCLIArgument (main compile break)
5bf9595804 (#9265) left the final expression of a multi-statement String?
method without an explicit return; every target compiling this file fails,
which currently blocks all merge-gate runs.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): redact workspace mutation failure diagnostics
An rpcError message is an arbitrary host string; exported diagnostics now
carry only the bounded DiagnosticFailureKind plus the short RPC code, and
the os.log line marks the raw error private.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): stop presenting gated connect attempts as timeouts
connectAttemptGated means another attempt owns the route, not that the
Mac failed to respond. New pairing category with wait-for-active-attempt
copy and guidance (en+ja) instead of 'No response from …' timeout text.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): add missing statusLine keys to MobileShellUI catalog
mobile.workspaces.statusLine.reconnecting/notConnected were referenced by
WorkspaceConnectionStatusLineView but absent from the package catalog, so
Japanese fell back to English defaults.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(cli): monotonic events timeout budget, deterministic reconnect wait
The --timeout budget now runs on ContinuousClock so wall-clock changes
cannot expire or extend it; each socket call derives a fresh short-lived
Date from the monotonic remainder and authentication re-checks the budget
first. The reconnect pause replaces the Timer+RunLoop pump (which can spin
or park on the CLI's unpumped command thread) with a bounded thread sleep
clamped to the remaining budget.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(ios): drop stale swiped-row identity on structural refresh
A structural update invalidates the row identity captured at swipe start;
keeping editedItemID could defer a reload against a row that no longer
exists.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): align drop fixture with connection chrome
* fix: return validated restore argument
* test(ios): port drop tests to the status-line WorkspaceListTable API
Main's drop tests (from #8602) still passed connectionRecoveryFailed,
isRecoveringConnection, and retryConnectionRecovery, which this branch's
status-line rework removed from WorkspaceListTable; the package no longer
compiled on the merged tree.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): drop superseded relayPolicyRetrySchedule test
The cause-aware relayPolicyRetrySchedule(for:) API this test pinned was
replaced by the shared foreground reconnect-backoff ladder during the
connection-supervisor cross-merge (see the scheduleRelayPolicyRefresh
comment); the symbol exists nowhere, so cmuxFeatureTests did not compile.
The fast-auth-retry concern lives in the ladder's own coverage.
Co-Authored-By: Claude Fable 5 <[email protected]>
* test(ios): drop superseded relay schedule assertion
* test(ios): identify inherited group menu actions
* test(ios): lock group menu action order
* test(iroh): expose truncated registration discovery
* fix(iroh): distrust truncated registration discovery
* test(connectivity): expose truncated sync snapshots
* fix(connectivity): prove complete sync snapshots
* test(connectivity): expose discovery revision races
* fix(connectivity): snapshot routes atomically
* test(ios): expose discovery blocking saved reconnect
* fix(ios): prioritize saved routes during recovery
* test(connectivity): expose endpoint recovery race
* fix(connectivity): await endpoint recovery before dialing
* fix(connectivity): fail closed on offline auth fallback
* Harden mobile group actions and reconnect readiness
* Include mobile debug registry source
* Fix group rename alert target lifetime
* Address workspace merge policy findings
* Scope reconnect policy to owning view
* Fix SSH retry test diagnostic compilation
* Align host refresh tests with auth recovery
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: cmux reload-cloud <[email protected]>
* Test isolated four-language SDK publishing
* Isolate and coordinate four SDK publishers
* Harden SDK release orchestration
* Make SDK publishing explicitly dispatched
* Enforce SDK release provenance
* Serialize coordinated SDK releases
* Test resumable SDK publishing
* Make SDK releases safely resumable
* Test ambiguous registry publish recovery
* Reconcile ambiguous registry publishes
* Test fully reproducible SDK preflights
* Complete reproducible SDK preflights
* Test SDK publisher security boundaries
* Secure reproducible SDK publishing
* Test usable registry release state
* Require usable registry release state
* Test pre-tag registry and Go gates
* Gate SDK tags on consumable releases
* Test final SDK release race guards
* Close final SDK release race windows
* Test SDK bootstrap and propagation recovery
* Make SDK bootstrap and propagation resilient
* Test release bootstrap and public Go verification
* Fail closed before coordinated SDK releases
* Run SDK surface gate after main validation
* Test Go probe polling without pipe reuse
* Poll Go verification without pipe reuse
* Test attested PyPI project bootstrap
* Reserve PyPI SDK name before release tags
* Test non-UTF-8 Go probe output
* Decode Go probe output defensively
* Test SDK registry ownership gates
* Require SDK registry ownership before tags
* Test registry ownership and monotonic recovery
* Reconcile registry ownership and release history
* Test publisher identity and reproducible recovery
* Test reproducible Python source archives
* Bind publisher identity and reproduce SDK artifacts
* Test registry error privacy and recovery placement
* Sanitize registry transport failures
* Test monotonic and attested release recovery
* Enforce monotonic attested release recovery
* Test current provenance and post-publish reconciliation
* Verify registry state after every publish
* Test prerelease recovery and registry index skew
* Recover prerelease and index propagation safely
* Test external SDK release authority
* Gate SDK release authority outside branch workflows
* Test repository-dispatched npm provenance
* Verify repository-dispatched npm attestations
* Test approval-fresh commit-bound release checks
* Revalidate release authority at tag creation
* Test least-exposure release credentials
* Limit SDK tag credentials to the atomic push
* Test credential-locked SDK bootstraps
* Harden SDK registry bootstraps
* Test isolated release authority and convergence
* Isolate SDK release credentials
* Test fresh recoverable SDK tag retries
* Make SDK tag retries fresh and recoverable
* Test isolated registry bootstrap credentials
* Isolate registry bootstrap credentials
* Test registry recovery identity binding
* Bind registry recovery to publisher identity
* Test publishing tool cancellation and Python pinning
* Harden publishing tool runtime behavior
* Test bounded registry publisher execution
* Bound registry publisher subprocesses
* Test multi-entry npm integrity metadata
* Verify multi-entry npm integrity metadata
* Test tag recovery after main advances
* Recover tag push after main advances
* Test rerun snapshot tag normalization
* Normalize rerun release tag snapshots
* Test crates.io access policy compliance
* Honor crates.io data access policy
* Test cross-process crates.io pacing
* Pace crates checks between processes
* Test PyPI bootstrap source revalidation
* Revalidate PyPI bootstrap source
* Test published SDK source identity
* Bind published SDKs to typed source
* Test multi-entry npm provenance SRI
* Accept multi-entry npm integrity metadata
* Test publisher artifact identity binding
* Bind publishers to validated artifacts
* Scope release artifacts to workflow attempts
* Test release artifact rerun identity
* Bind reruns to attempt artifacts
* Test publisher authority revalidation
* Revalidate publisher registry authority
* Test publisher verifier isolation
* Isolate PyPI publisher authority checks
* Route SDK jobs through runner controls
* Test npm provenance runner isolation
* Keep npm provenance on GitHub runner
* Add failing test that cmux ssh startup scripts parse under /bin/sh
Regression coverage for #9423.
* Fix cmux ssh startup script syntax error in no-progress retry loop
The reusable foreground-auth + SSH PTY attach path passed a compound 'if'
as the no-progress retry loop's attach command. That loop prefixes the
command with environment assignments, which POSIX only allows before a
simple command, so /bin/sh rejected the generated cmux-ssh-startup script
with 'syntax error near unexpected token then' and cmux ssh failed
immediately.
Wrap the attempt registration and the attach command in a shell function
and pass its name, matching SSHPTYAttachStartupCommandBuilder.
Fixes#9423
* Move #9423 regression coverage to Swift Testing
Cover the defect where it lives, in the shell generator, with a Swift
Testing case instead of an XCTest addition to the CLI integration suite.
Reverts the call-site-only workaround so the next commit fixes the
generator for every caller.
* Export the no-progress attach budget instead of prefixing the command
SSHPTYAttachExitCode.noProgressRetryLoopLines prefixed the caller's
command with environment assignments. POSIX only allows an assignment
prefix before a simple command, so the reusable foreground-auth attach
path, which passes a compound 'if ...; then ...; fi', generated a
cmux-ssh-startup script that /bin/sh rejected with 'syntax error near
unexpected token then'. cmux ssh failed immediately on 0.64.21.
Assign and export the budget on their own lines so any command shape is
legal and children still see the values.
Fixes#9423
* Add failing test for CLAUDE_SECURESTORAGE_CONFIG_DIR capture
Co-Authored-By: Claude Opus 5 <[email protected]>
* Allowlist CLAUDE_SECURESTORAGE_CONFIG_DIR in agent launch env capture
Co-Authored-By: Claude Opus 5 <[email protected]>
* Move Claude secure storage env tests to their own file
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
* Add failing regression test for bash shim noclobber error
Repros https://github.com/manaflow-ai/cmux/issues/9356: with `set -o noclobber`,
the bash integration's second shim write prints "cannot overwrite existing file"
and leaves the shim stale.
Co-Authored-By: Claude Opus 5 <[email protected]>
* Force-clobber cmux-owned generated files in bash/zsh shell integration
Under `set -o noclobber` the bash integration's per-surface CLI shim write
(`} >"$shim_path"`) is refused by the shell, printing
"cannot overwrite existing file" on every prompt and leaving the shim stale.
`2>/dev/null` cannot suppress it: the shell reports the redirect failure before
the compound command's stderr redirection applies.
Switch that write, and the remaining plain-`>` writes to cmux-owned generated
files in the bash integration (bg pid file, gh stderr capture, history temp
file, history-last marker) plus the zsh gh stderr capture, to the explicit
clobber operator `>|`, matching what the rest of both integrations already use.
Fixes#9356
Co-Authored-By: Claude Opus 5 <[email protected]>
---------
Co-authored-by: Claude Opus 5 <[email protected]>
Review claimed a vanished TTY could launder another terminal's failed
diagnostic into a complete scan. It cannot: non-ENOENT diagnostics never
remove a terminal from the retry set, so completeness requires either a
clean re-query or explicit ENOENT for every terminal. Pin the exact
mixed case: ENOENT for one TTY plus Permission denied for another
re-queries only the unreadable terminal and stays incomplete when its
diagnostic persists.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C
* Add failing tests: live sessions must survive equivalent route revisions
Two regressions captured from foreground telemetry on build 20260801001626:
1. equivalentRouteRevisionBumpKeepsTheLivePeerSession: a broker
connectivity sync that bumps the account route revision without
changing the peer's material route content (only last_seen_at moved)
tears down the live admitted session with runtimeReconfigured.
2. concurrentRedialCannotDisplaceAnInstalledLiveSession: two concurrent
connectedSession callers can both pass the installed-slot check across
the dead-on-arrival probe suspension, so the second install displaces
the first admitted session without closing it and records a second
established lifecycle event.
Both tests fail on current code; the fix lands in the next commit.
Co-Authored-By: Claude Fable 5 <[email protected]>
* Preserve live peer sessions across equivalent route revision bumps
The connectivity engine tore down every peer session whenever the
account route revision changed, even when the peer's route content was
identical. Broker registration heartbeats bump the revision while only
moving last_seen_at and path-hint freshness, so a foreground iOS client
lost its live control session every 10-90 seconds to a
runtimeReconfigured close followed by a full rediscover-dial-pair cycle.
The engine now derives CmxConnectivityRouteContent from each installed
snapshot: per-peer admission material (binding id, app instance, tag,
platform, identity generation, pairing flag, capabilities) plus
account-wide trust material (relay fleet, LAN rendezvous, grant
verification keys). On a revision change it invalidates only peers whose
material content differs. A changed endpoint identity keys the peer out
of the new content, a removed binding leaves it unrouted, and any
account-material change tears down all peers, so every security-relevant
change still invalidates. A missing baseline or a revision bump without
a replacement snapshot fails closed and keeps the old invalidate-all
behavior.
Also close the double-establish race in CmxConnectivityPeerSession: the
dead-on-arrival probe suspends the actor between clearing the pending
dial and installing it, so a concurrent caller could install its own
dial in that window and the late installer silently displaced the live
session while double-recording an established lifecycle. The installer
now rechecks the installed slot after the probe and adopts the winner,
closing its own redundant session.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* Show signed-out state on app pricing page with in-app sign-in
When the embedded /app-pricing webview has no authenticated session, the
page used to claim "Current plan: Free" (misleading for signed-out Pro
users, invites duplicate purchase) and offered no way to sign in.
Now a banner at the top says the user is not signed in and links to
sign-in, the current-plan badge is suppressed while unauthenticated, and
the Free card CTA becomes Sign in. The sign-in link runs the existing
native-sign-in handler flow inside the webview, so Stack cookies land in
the webview session and /handler/after-sign-in hands tokens to the app
via its <scheme>://auth-callback URL. BrowserNavigationDelegate now opens
the app's own auth-callback scheme via NSWorkspace (user-activated
main-frame links only) since WKWebView cannot open native schemes.
* Scope auth-callback intercept to the app web origin and split it into its own file
Two review-driven fixes to the new native auth-callback intercept:
1. Security (Codex/Greptile P1): the intercept accepted a user-clicked
<scheme>://auth-callback link from ANY page in the embedded browser.
Because HostBrowserSignInFlow accepts stateless callbacks, a malicious
page could hand attacker-chosen tokens to the app and swap the
signed-in account on one click. The predicate now also requires the
navigation's SOURCE frame origin to match AuthEnvironment.appWebOrigin
(the origin serving /handler/after-sign-in), reusing the normalized
BrowserWebAuthnSecurityOrigin comparison. Links from any other origin
fall through to the regular external-navigation handling.
2. workflow-guard-tests: BrowserNavigationDelegate.swift grew +36 lines,
past the 25-line incidental allowance over its 635-line budget. The
predicate and router now live in a dedicated collaborator,
BrowserAuthCallbackNavigationPolicy, matching the delegate's existing
pattern of small policy objects; the delegate is back to +20.
* Pin auth-callback intercept to this build's own callback scheme
Structured-review P1: AuthCallbackRouter accepts the built-in cmux,
cmux-nightly, and cmux-dev schemes plus the extra one, and the trusted
/handler/after-sign-in page can legitimately emit any allowed scheme as
native_app_return_to. Stable cmux would therefore auto-open a
token-bearing cmux-nightly://auth-callback link, handing this session's
tokens to whatever app registered that scheme (attacker-registerable
when Nightly is absent). The predicate now requires the destination
scheme to equal AuthEnvironment.callbackScheme before NSWorkspace.open;
other schemes fall through to regular external-navigation handling.
* Fail-closed auth-callback dispositions and in-process delivery
Two structured-review P1s on the intercept:
1. Not fail-closed: a rejected cmux://auth-callback link fell through to
the generic external-app prompt, so an untrusted page's attacker-token
link could still reach the app after one confirming click, and a
crafted cmux-nightly link from the trusted page could reach that
scheme's handler. The policy now returns a disposition: user-activated
main-frame auth-callback-shaped links that fail the scheme/origin
checks are cancelled outright (.block). Non-link-activated navigations
keep the browser's regular handling, same as every other custom scheme.
2. Token egress through LaunchServices: NSWorkspace.open routes the
token-bearing URL to whatever app currently claims the scheme. Accepted
callbacks are now delivered in-process through the app delegate's
application(_:open:) entrypoint (the exact path LaunchServices would
invoke), so the URL never leaves this process.
The disposition handling lives in a BrowserNavigationDelegate extension in
the policy file, keeping the delegate at +6 lines over its budget base.
* Fail closed on every auth-callback-shaped navigation; return webview to pricing after delivery
Extends 6c71e6b91d on review findings:
1. disposition() now blocks ALL auth-callback-shaped navigations that are
not the exact trusted flow (user-activated main-frame link, own scheme,
trusted source origin). JS redirects and subframe navigations previously
passed through to the generic external-app prompt, where one confirming
click could hand attacker-chosen tokens to the stateless callback path.
2. The popup/new-window path (BrowserPanel.createWebViewWith) applies the
same rule via shouldBlockExternalNavigation: auth-callback-shaped URLs
from window.open never reach the external-app prompt.
3. After a delivered callback, the embedded flow no longer strands the
webview on the 'Signed in to cmux' page: /app-pricing passes
web_return_to on the after-sign-in URL and the navigation delegate
navigates the webview back to it (same-origin relative path only), so
the pricing page reloads with the authenticated session and shows the
restored plan. The switch-account flow preserves the param.
* Add signed-out pricing regression coverage
* Complete embedded pricing sign-in safely
* Fail closed on targetless auth callbacks
* Split auth callback disposition policy
* Add auth callback recovery regression tests
* Complete auth callbacks across browser surfaces
First unit-target compile of this file (gate run) rejected internal methods
whose signatures use the private AppStoredShortcut typealias.
Co-Authored-By: Claude Fable 5 <[email protected]>
check-test-determinism.py --strict flagged the post-close sleep; poll for
selection and close instead.
Co-Authored-By: Claude Fable 5 <[email protected]>
Review asked to pin LC_ALL on the ps invocation because vanishedTTYNames
matches the English strerror(ENOENT) text. Darwin libc ships no localized
message catalogs, so ps emits this exact string under any locale (verified
empirically with LC_ALL=ja_JP.UTF-8); record that constraint instead of
widening the CommandRunning API for an unreachable failure mode.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PDYSKWqKSHrr6MAuMtou9C
One fix per mechanism the failing tests pin:
- `runPS` drops the terminals `ps` reports as ENOENT and retries with
the rest, bounded so a pty churning mid-scan cannot spin. Terminals
that are all gone report authoritative emptiness — a freed pty can
hold no process — so their stale badges clear too, while any other
diagnostic still yields incomplete, which retains ports rather than
dropping them on weak evidence. Vanished terminals are matched by
device name, so the two-device diagnostic form (`/dev/ttyX and
/dev/X`) and TTYs registered by full device path are recognized, and
"every terminal is gone" outranks the retry budget so authoritative
emptiness does not depend on which attempt the final pty closed
during.
- Birth identities are read through `sysctl(KERN_PROC_PID)` instead of
`proc_pidinfo`. It reports the same birth timestamp for any live
process regardless of owner, and still reports nothing for an exited
PID, so recycling detection keeps working and now covers privileged
PIDs it previously had to guess about. `SZOMB` is rejected
explicitly: sysctl also describes an exited-but-unreaped process, and
session restore treats a matching identity as proof the agent is
alive.
- `PIDPresence` routes through the same process-table read that
supplies birth identities, so liveness and identity cannot drift
apart, and an unreaped process reads as absent to every caller
weighing whether it might still own something.
`Workspace.agentPIDProcessIdentity(pid:)` reads through that single
reader as well instead of keeping its own `proc_pidinfo` copy.
The tests added here pin behavior this fix introduces rather than the
original bug: zombie identities reading as absent, diagnostic forms
that must not trigger the retry, and the retry budget staying
incomplete when a TTY never becomes scannable.
Fixes#9152.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
Sidebar port badges accumulate dead ports for the lifetime of the app
because three independent mechanisms each leave a panel's scan
permanently incomplete, and PortScanSnapshotReconciler treats incomplete
scans as non-evidence that only ever unions ports:
- BSD `ps` aborts an entire batched `-t` query when any listed terminal
device is gone, so one closed pty makes every panel's port scan look
incomplete.
- `proc_pidinfo` refuses any process whose effective UID differs from
ours, so the root-owned `/usr/bin/login` heading every terminal reads
as unidentifiable, files under `incompletePIDs`, and scores its panel
incomplete on every scan.
- A zombie answers `kill(pid, 0)` like a running process while holding
no readable identity — the same incompleteness, reachable through any
unreaped child, though a zombie has exited and can hold no socket.
An end-to-end test also drives the real scanner — registerTTY, kick,
coalesce, burst, reconcile, publish — and asserts a port is retired once
its process stops listening, since these bugs left every stage passing
its own unit test while the feature was fully broken. Identity and
presence deliberately stay on the real providers: an earlier draft
injected them and passed against the broken tree.
Covers github.com/manaflow-ai/cmux/issues/9152.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016pmrs1n9Z2UKquWcbXhmBe
The Ghostty goto_split:previous/next mirror in the shortcut dispatch now
yields to a bound Focus Back/Forward shortcut (matchConfiguredShortcut,
including shortcuts.when gating), so ⌘[ / ⌘] reach the focus-history branch
and drive the exact same TabManager.navigateBack()/navigateForward() path as
the titlebar arrow buttons: same history model, same closed-workspace
pruning, same enable conditions. Unbinding Focus Back/Forward hands the keys
back to the mirror, as the keyboard-shortcuts docs already promised.
Pane cycling stays available two ways: the Ghostty goto_split trigger on any
non-colliding key, and new cmux-owned rebindable actions focusPreviousPane /
focusNextPane (default unbound) that share the same cyclePaneFocus body, per
the shared-entrypoint policy. The window key-equivalent fallback route gets
the same yield so both dispatch layers agree.
The new actions follow the full shortcut policy: KeyboardShortcutSettings +
CmuxSettings ShortcutAction (defaults, display names, panes group), Settings
recorder rows, cmux.json shortcuts.bindings support, schema enum, web
keyboard-shortcuts page (en+ja), and the shortcut-actions reference. Labels
localized in Localizable.xcstrings for all catalog languages.
Co-Authored-By: Claude Fable 5 <[email protected]>
Ghostty's macOS defaults bind goto_split:previous/next to cmd+[ / cmd+], the
same keys as Focus Back/Forward. The shortcut dispatch mirrors those triggers
to cycle pane focus and checks the mirror before the focus-history branch, so
the keys cycle panes inside the current workspace (or do nothing) while the
titlebar arrow buttons navigate across workspaces.
Coverage added ahead of the fix so CI shows red then green:
- cmuxTests/FocusHistoryBracketShortcutRoutingTests: dispatches real ⌘[ / ⌘]
events through debugHandleCustomShortcut with the Ghostty mirror installed
via a new DEBUG seam; expects workspace focus-history navigation.
- cmuxUITests/FocusHistoryShortcutUITests: end-to-end over the control socket
(simulate_shortcut uses the same matcher as the app-level monitor); walks
back/forward across three workspaces and checks closed-workspace skipping.
- tests_v2/test_focus_history_shortcut_cross_workspace.py: local socket
verification against a tagged build.
Co-Authored-By: Claude Fable 5 <[email protected]>
ProcessInfo.processInfo.environment is captured once and does not reflect setenv calls
made after first access, so saving the prior value through it and restoring in a defer
writes back a stale snapshot whenever an earlier test changed the variable at runtime.
getenv reads the live value, which is also the level the setenv/unsetenv pair below
operates at.
BrowserPanelRemoteStoreTests pins the built-in default profile in setUp because the
selection is persisted in UserDefaults and a leftover profile from another test breaks
its store-scoping checks. Pinning without restoring commits the same offense in the
other direction: every suite that runs after this one inherits the built-in default.
Save the selection in setUp and put it back in tearDown.
KeyboardShortcutSettingsFileStoreTests asserted the older model where a shortcut
saved through Settings outranks one bound in cmux.json. The product deliberately
went the other way: a file-managed action is authoritative and read-only, with
file-first lookup, refused writes, and a Settings row that reports itself managed
instead of editing. The tests now pin that, which is what they were for.
RemoteTmuxMirrorFeedForwardTests, RenderableSystemSymbolTests and
TabManagerFocusedNotificationIndicatorTests were failing on their own fixtures.
Each run before and after on a macOS builder, on this branch with the sixteen
already here, so the whole set is verified together rather than in isolation.
MarkdownPanelTests fulfilled a one-shot XCTestExpectation twice: WebKit can
report a provisional failure and then a finish for a single load, and the second
fulfill() raises XCTest's API-violation NSException from inside a suspended
await fulfillment. That does not fail a test, it takes the shared app host down
and every suite batched with it, so this one was corrupting other suites'
results as well as its own. The load delegate now settles once.
CLIHookNoResponseTests and CmuxDurableDeepLinkRestoreTests were failing on their
own fixtures in the same way as the thirteen already in this branch.
Each verified on a macOS builder before and after, on the combined branch rather
than in isolation, so the three land on top of the existing thirteen with the
whole set re-run.
Thirteen suites in the pre-existing red set fail for reasons inside the tests,
not the product. Each one here was run before and after on a macOS builder and
goes from a failing verdict to a passing one; no product behavior changes and no
assertion is weakened or deleted.
The recurring shapes:
- Oracles that could be satisfied before the thing under test had happened, so
the wait returned early and the assertion read startup state. These now read
live state that only exists after the operation commits.
- Fixtures that could not reach the state their test described, so an assertion
waited on a condition that was unreachable rather than merely slow.
- Expectations pinned to a machine-dependent value (a resolver path, a config
directory, a bundled binary layout) instead of deriving it the way the product
does.
- Shared global state left behind for the next test in the same host.
Verified per suite rather than in bulk: applied to a clean tree off the CmuxGit
compile fix, then each suite run through the app host. All thirteen report a
passing suite verdict with a non-zero test count.
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.
- resolvedColor now checks the `#` prefix and 7-character length before
NSColor(hex:), which tolerates a missing prefix. Keeps the runtime in
step with the schema's `colorHexOrNull` (#RRGGBB, no alpha).
- colorRow takes a `fallback` preview color, defaulting to the existing
cmuxAccentColor(). Pane Flash passes systemBlue so the settings swatch
shows the color the ring actually renders when unset.
- Adds testFlashColorFallsBackWhenHexCarriesAlpha covering #RRGGBBAA.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Follow-up cleanup on the mock control-socket rework.
The accept loops had no way to stop. Closing the listener FD does not wake a
thread already parked in poll/accept on Darwin, so every server leaked its
thread for the life of the test process — worse than the old bounded loops,
which at least self-terminated once they had accepted their quota. The registry
now owns each loop: it pairs the listener with a private stop pipe, and
`stop(listenerFD:)`/`stopAll()` signal the loop and join it. Both suites reap
their loops in tearDown.
Registry hardening:
- Hold the lock across retire-old and register-new. Two concurrent starts on one
FD could each observe the same predecessor, each wait for it, then each spawn a
loop, putting two loops back on one listener — the stealing bug returning by
another door. The loop threads never take the lock (they only signal
completion), so holding it across the join can't deadlock.
- The registry, not the loop, owns the stop pipe for the loop's whole life, so a
stop byte can never land in an unrelated descriptor that reused the number.
- Check pipe(); without a stop pipe a loop would be unstoppable, so fail loudly
rather than start one. A loop that ignores its stop byte now fails the test
instead of being left running.
Consolidation:
- One `cliMockServeLineFramedConnection` reader replaces four copies of the
read/frame/respond loop, and one `cliMockWriteAll` replaces the duplicated
partial-write handling. `CLIMockOnceFlag` replaces the two identical latches.
- Drop `connectionCount`/`connectionLimit` from the servers that no longer bound
connections, along with the dead default, and rewrite the comments that still
described a fixed pool. Same-named helpers owned by other suites keep their
live parameters.
The CLI hook integration suites drive the bundled cmux helper as a subprocess
against a mock control socket. Headless (piped stdio, no controlling TTY) the
helper always falls back to a `system.top` agent-process lookup on a second,
dedicated control connection because caller-TTY resolution can't succeed. The
mocks accepted only one connection, so that extra connection was starved: hooks
stalled for the 2s socket timeout, resolution fell back to unresolved routing,
and assertions saw the wrong RPC sequence (or empty output after a 5s process
timeout).
Rework the mock accept path so every connection the helper opens is serviced:
- A single poll-based accept loop per listener FD dispatches each connection to
its own handler, and a new server on the same FD supersedes (stops and joins)
the previous one so a leftover loop can't steal the next hook's connection and
fulfill the wrong expectation.
- The loops run on raw threads instead of GCD queues. A blocking accept() parked
on a GCD worker ties it up for the whole test; a suite that opens a server per
hook drained the shared GCD pool that runProcess needs for its stdout/stderr
readers and exit waiter, which looked exactly like the helper hanging.
Also:
- Bind the tmux-compat-env test's control socket under a short /tmp path. The
AF_UNIX sun_path limit is 104 bytes and this machine's temp dir alone overflows
a socket nested under it.
- Update the default-freestyle vm-new tests to expect vm.attach_info: `vm new`
uses forceSSH:false, which resolves through vm.attach_info (already covered by
the SSH startup suites), not the older vm.ssh_info path.
The pane attention flash and unread notification ring were hardcoded to
systemBlue via a single-case accent enum, so the one element that signals
"this pane needs input" could not be themed while everything around it
(workspace badge, selection highlight, terminal theme) could.
Add `notifications.paneFlashColor`, a nullable hex reusing the existing
`colorHexOrNull` schema def and `parseNullableHex` validation. Null keeps
the built-in systemBlue, so default appearance is unchanged.
Because every consumer resolves through `presentation.accent.strokeColor`,
resolving the accent from settings covers the notification ring, the flash,
the tmux pane overlay and the SwiftUI ring view in one place.
Surfaced in Settings under Workspace Colors, directly below Notification
Badge, using the existing colorRow helper.
Refs #8560
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Addresses bot-review findings on #7459.
- Type the split `orientation` as a two-case enum (horizontal|vertical)
instead of an unvalidated String. An orientation outside the wire
contract now fails closed at the conversion boundary (node -> nil ->
layout: null), the same path as an unparseable pane UUID. Emitted wire
JSON is unchanged for the two valid values (rawValue == the wire string).
- Fix the layout-conversion doc comment to describe what the code does: a
single unparseable leaf nils the ENTIRE workspace layout (deliberate
fail-closed; consumers fall back to the flat panes array), not just the
affected subtree. No behavior change.
- Replace the fixed time.sleep(1.0) in test_cli_tree_layout with a bounded
poll on `cmux --json tree` until the created workspaces materialize
(racy under CI load); reuses the existing _workspace_from_tree predicate.
Co-Authored-By: Claude Fable 5 <[email protected]>
`system.tree`'s `panes` array is flat — it lists a workspace's panes but
discards how they're arranged: which splits are vertical vs horizontal,
their ratios, and their nesting. That geometry is live in the workspace's
bonsplit controller (`treeSnapshot()`) but never reached the wire, so a
consumer that recreates a saved layout (e.g. an external snapshot/restore
tool) could only guess — multi-pane restores came out flat/horizontal.
Add an additive `layout` field to each workspace in the `system.tree`
payload: the split tree in the shape `--layout` already accepts
({direction, split, children} for a split; a {pane: {id, ref}} leaf
otherwise), so the tree cmux emits and the layout it ingests are one
schema read two ways. Pane leaves carry the same id/ref as the flat
`panes` array and participate in --id-format (refs/uuids/both). The field
is `null` when unavailable, so existing consumers are unaffected.
- New ControlSystemTreeLayoutNode (keeps the socket package free of any
Bonsplit dependency; the app maps ExternalTreeNode into it at capture)
- Capture treeSnapshot() at the existing serializer call site
- Serialize in systemTreeWorkspacePayload; the CLI relays it unchanged
(treeApplyMarkers passes unknown keys through)
- tests/test_cli_tree_layout.py: single-leaf + nested H-over-V round-trip
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 08:27:38 -04:00
3261 changed files with 429032 additions and 44881 deletions
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR}" \
--notes "Pre-built GhosttyKit.xcframework for commit ${{ steps.ghostty-sha.outputs.sha }} with crash-report-subdir=${GHOSTTYKIT_CRASH_REPORT_SUBDIR} and sentry=false" \
# Main-controlled broker. This workflow runs only from refs/heads/main and
# hydrates only main, so no candidate branch can edit the guards that run
# before begin-testbox exposes its auth token, and no candidate build script
# executes inside this token-bearing job. A candidate revision reaches the
# Testbox later, through `blacksmith testbox run`, which synchronizes a
# maintainer's local worktree onto the already-warm VM.
on:
workflow_dispatch:
inputs:
testbox_id:
description:"Testbox session ID supplied by blacksmith testbox warmup"
required:true
type:string
permissions:{}
concurrency:
# A Testbox is a mutable shared workspace. Serialize every request for the
# same ID so two dispatches cannot corrupt one cache.
group:cmux-tui-testbox-${{ inputs.testbox_id }}
cancel-in-progress:false
jobs:
cmux-tui-rust:
name:cmux-tui Rust setup
runs-on:blacksmith-32vcpu-ubuntu-2404
environment:
# Configure this environment with required reviewers, no secrets, and a
# deployment branch rule of exactly `main`. Approval is evaluated before
# the first step, so it precedes begin-testbox.
name:blacksmith-testbox-trusted
permissions:
contents:read
# Hydration plus three sequential 20-minute bounded remote builds happen
# after setup. Keep the GitHub job alive long enough for all stages and
# cleanup; the Testbox itself still has its separate idle timeout.
timeout-minutes:120
steps:
# Every check here is main-controlled. `blacksmith testbox warmup`
# resolves both this file and the hydrated source from the same --ref, so
# refusing any ref other than refs/heads/main is what keeps a candidate
# branch outside the trust boundary.
- name:Validate broker ref
env:
DISPATCH_REF:${{ github.ref }}
EVENT_NAME:${{ github.event_name }}
REPOSITORY:${{ github.repository }}
TESTBOX_ID:${{ inputs.testbox_id }}
shell:bash
run:|
set -euo pipefail
[[ "$REPOSITORY" == "manaflow-ai/cmux" ]] || {
echo "::error::this Testbox lane is only valid for manaflow-ai/cmux" >&2
exit 1
}
[[ "$EVENT_NAME" == "workflow_dispatch" ]] || {
echo "::error::Testbox setup must be dispatched manually, never from a PR event" >&2
exit 1
}
[[ "$DISPATCH_REF" == "refs/heads/main" ]] || {
echo "::error::this broker lane runs only from refs/heads/main, got $DISPATCH_REF; warm up with --ref main and sync the candidate through blacksmith testbox run" >&2
# The conventions lint (free-function ban, namespace-type rule, ...)
# covers every package, so it runs for any Packages/ change too.
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
if grep -Eq '^(ios/|Packages/|Sources/Mobile/|vendor/stack-auth-swift-sdk-prerelease/|scripts/lint-ios-package-conventions\.sh$|scripts/lint-ios-package-conventions-baseline\.txt$|scripts/lint-namespace-types-baseline\.txt$)' /tmp/changed-files.txt; then
echo "should_lint=true" >> "$GITHUB_OUTPUT"
else
echo "No package-owned files changed; skipping conventions lint."
@@ -99,13 +99,15 @@ jobs:
# namespace-enums) plus the repo-wide namespace-type rule (no
# all-static "namespace" types in any package). Exits non-zero on any
# unjustified ERROR; sanctioned exceptions carry a lint:allow /
# TRANSITIONAL / carve-out marker, and pre-existing namespace-type
# debt is grandfathered in scripts/lint-namespace-types-baseline.txt.
# TRANSITIONAL / carve-out marker, and pre-existing debt is
# grandfathered in the lint baseline files under scripts/.
- Fix a crash seconds after launch on Intel Macs; cmux is now the only process-wide crash handler, and embedded GhosttyKit no longer links Ghostty's native Sentry initializer ([#9436](https://github.com/manaflow-ai/cmux/pull/9436))
- Fix `cmux ssh <host>` failing immediately with a shell syntax error from the generated startup script ([#9425](https://github.com/manaflow-ai/cmux/pull/9425)) -- thanks @KousukeUchiyama for the report!
- Clear Dock notifications when you focus the pane that raised them ([#9418](https://github.com/manaflow-ai/cmux/pull/9418))
- Keep a restored Claude agent on its own account instead of falling back to the ambient one ([#9419](https://github.com/manaflow-ai/cmux/pull/9419)) -- thanks @seanyoungberg for the report!
- Stop bash shell integration printing `cannot overwrite existing file` on every prompt under `set -o noclobber` ([#9420](https://github.com/manaflow-ai/cmux/pull/9420)) -- thanks @8bit-void for the report!
- Fail closed when `close` or `respawn-pane` is given an explicit `--surface` that no longer exists, instead of acting on a different live surface ([#9422](https://github.com/manaflow-ai/cmux/pull/9422)) -- thanks @PhilipPinckaers for the report!
- Native iPhone and iPad Simulator panes, with their own commands and automation ([#7857](https://github.com/manaflow-ai/cmux/pull/7857))
- First-class Mosh transport for remote workspaces ([#8442](https://github.com/manaflow-ai/cmux/pull/8442))
- Workspace-wide terminal font zoom on Cmd+Ctrl+= / Cmd+Ctrl+- / Cmd+Ctrl+0 ([#8791](https://github.com/manaflow-ai/cmux/pull/8791)), and per-tab zoom now persists across restarts ([#8543](https://github.com/manaflow-ai/cmux/pull/8543))
- Cmd+Shift+T reopens the last closed item ([#9132](https://github.com/manaflow-ai/cmux/pull/9132))
- Cmd+[ and Cmd+] traverse global workspace focus history, and pane cycling becomes rebindable ([#9329](https://github.com/manaflow-ai/cmux/pull/9329)) -- thanks @azooz2003-bit! -- alongside a workspace-only focus history setting ([#8654](https://github.com/manaflow-ai/cmux/pull/8654))
- Move active surfaces between panes with automatic directional splits ([#8764](https://github.com/manaflow-ai/cmux/pull/8764)); `goto_split:previous` and `goto_split:next` cycle through every pane with wrapping ([#2639](https://github.com/manaflow-ai/cmux/pull/2639)) -- thanks @mykmelez!
- Dock panes persist across session restore ([#8690](https://github.com/manaflow-ai/cmux/pull/8690)), with full Dock surface runtime parity ([#8782](https://github.com/manaflow-ai/cmux/pull/8782))
- Reopen closed workspaces with sticky repo identity ([#8841](https://github.com/manaflow-ai/cmux/pull/8841))
- Target browser profiles from the CLI ([#8874](https://github.com/manaflow-ai/cmux/pull/8874)), and Command-clicked HTML files render in browser panes ([#9096](https://github.com/manaflow-ai/cmux/pull/9096))
- Sidebar account and mobile pairing controls ([#8354](https://github.com/manaflow-ai/cmux/pull/8354)); sidebar metadata renders Markdown links ([#8663](https://github.com/manaflow-ai/cmux/pull/8663)) -- thanks @djova!
- Notification feed read state is a leading swipe with mark-unread ([#8868](https://github.com/manaflow-ai/cmux/pull/8868)) -- thanks @azooz2003-bit!
- Idle background agents hibernate under critical memory pressure even when routine Agent Hibernation is off ([#9090](https://github.com/manaflow-ai/cmux/pull/9090))
-`cmux restore` runs without a shell ([#9265](https://github.com/manaflow-ai/cmux/pull/9265))
- iOS (beta): stream Mac browser panes to the phone, interactive and pixel-perfect, with dialogs mirrored ([#8298](https://github.com/manaflow-ai/cmux/pull/8298)) -- thanks @azooz2003-bit!
- iOS (beta): haptic feedback setting ([#8797](https://github.com/manaflow-ai/cmux/pull/8797)), Open Folders on Tap ([#8524](https://github.com/manaflow-ai/cmux/pull/8524)), unified animated toasts ([#8376](https://github.com/manaflow-ai/cmux/pull/8376)), and workspace identity customization ([#8636](https://github.com/manaflow-ai/cmux/pull/8636)) -- thanks @azooz2003-bit!
### Changed
- Workspace initial commands launch through your login shell ([#8801](https://github.com/manaflow-ai/cmux/pull/8801)) -- thanks @azooz2003-bit! -- and auto-resume uses the normal terminal shell ([#8837](https://github.com/manaflow-ai/cmux/pull/8837))
- iOS (beta): the phone-to-Mac transport is rebuilt on one connectivity authority, with authenticated discovery, named disconnect reasons, and relay-credential rollover ([#9284](https://github.com/manaflow-ai/cmux/pull/9284), [#8840](https://github.com/manaflow-ai/cmux/pull/8840), [#8716](https://github.com/manaflow-ai/cmux/pull/8716), [#8494](https://github.com/manaflow-ai/cmux/pull/8494)) -- thanks @azooz2003-bit!
- iOS (beta): terminal scrolling is local and smooth on screen-anchored render grids ([#8860](https://github.com/manaflow-ai/cmux/pull/8860)) -- thanks @azooz2003-bit!
- iOS (beta): state sync v2 replaces the invalidate-and-refetch loop with per-record deltas ([#8284](https://github.com/manaflow-ai/cmux/pull/8284)) -- thanks @azooz2003-bit!
- iOS (beta): onboarding is rebuilt around a live agent handoff ([#8418](https://github.com/manaflow-ai/cmux/pull/8418)), as a swipeable tour ([#9158](https://github.com/manaflow-ai/cmux/pull/9158)) with a Game of Life backdrop on every page ([#8880](https://github.com/manaflow-ai/cmux/pull/8880)) -- thanks @azooz2003-bit!
- iOS (beta): removing a Mac from a phone hides it for that phone only, instead of deleting it everywhere ([#8760](https://github.com/manaflow-ai/cmux/pull/8760), [#8778](https://github.com/manaflow-ai/cmux/pull/8778)) -- thanks @azooz2003-bit!
### Fixed
- Fix leaked `openThread` loops burning ~90% of cmux idle CPU ([#8851](https://github.com/manaflow-ai/cmux/pull/8851))
- Fix workspace-switch renderer freezes ([#8793](https://github.com/manaflow-ai/cmux/pull/8793)), reclaim hidden Ghostty renderer memory ([#8998](https://github.com/manaflow-ai/cmux/pull/8998)), and fix the Vault sidebar beachball at large session counts ([#8680](https://github.com/manaflow-ai/cmux/pull/8680))
- Fix Vim Mode cursor and selection rendering ([#8995](https://github.com/manaflow-ai/cmux/pull/8995))
- Fix TextBox IME composition rendering ([#8688](https://github.com/manaflow-ai/cmux/pull/8688))
- Fix zsh prompt wrap spacer lines by letting Ghostty own prompt layout ([#8964](https://github.com/manaflow-ai/cmux/pull/8964))
- Fix Settings and main window zombies under AeroSpace ([#8513](https://github.com/manaflow-ai/cmux/pull/8513)) -- thanks @fml09!
- Fix a Debug-build crash on macOS 26.5 from non-finite sidebar divider coordinates ([#9156](https://github.com/manaflow-ai/cmux/pull/9156)) -- thanks @oscarbrey!
- Fix Mermaid diagrams double-scaling under viewer zoom ([#8914](https://github.com/manaflow-ai/cmux/pull/8914)), restore the focused-read indicator after a surface-scoped mark-read ([#8927](https://github.com/manaflow-ai/cmux/pull/8927)), keep Pi launch arguments when resuming a restored session ([#8912](https://github.com/manaflow-ai/cmux/pull/8912)), and import appearance at Settings store init instead of live-applying it ([#8913](https://github.com/manaflow-ai/cmux/pull/8913)) -- thanks @ejc3!
- Notify only after the Pi agent settles ([#8574](https://github.com/manaflow-ai/cmux/pull/8574)) -- thanks @mrohan-sq!
- Tear down remote daemon PTY sessions once ([#8643](https://github.com/manaflow-ai/cmux/pull/8643)) -- thanks @ejc3! -- and support `respawn-pane` in the Go relay tmux compatibility layer ([#8660](https://github.com/manaflow-ai/cmux/pull/8660)) -- thanks @bencollins2!
- Stop the sidebar PR poller from re-downloading every repo's full PR list on each poll ([#8521](https://github.com/manaflow-ai/cmux/pull/8521)) -- thanks @joshfree!
- Restore Codex ([#9370](https://github.com/manaflow-ai/cmux/pull/9370)), Kimi Code ([#8584](https://github.com/manaflow-ai/cmux/pull/8584)), Grok ([#9382](https://github.com/manaflow-ai/cmux/pull/9382)), and Pi ([#9399](https://github.com/manaflow-ai/cmux/pull/9399)) sessions across relaunch, and stop duplicate agent resumes ([#8619](https://github.com/manaflow-ai/cmux/pull/8619))
- ssh-tmux: fix focus after single-pane promotion ([#9020](https://github.com/manaflow-ai/cmux/pull/9020)), named-key encoding for the remote `TERM` ([#9273](https://github.com/manaflow-ai/cmux/pull/9273)), and terminal replies leaking into reattached panes ([#9272](https://github.com/manaflow-ai/cmux/pull/9272)); fix workspace shortcuts from hosted tmux terminals ([#8621](https://github.com/manaflow-ai/cmux/pull/8621))
- Fix SSH relay deadlock after app restart ([#9105](https://github.com/manaflow-ai/cmux/pull/9105)), stale SSH workspace connection status ([#9085](https://github.com/manaflow-ai/cmux/pull/9085)), remote PTY `PATH` inherited from cmuxd ([#8677](https://github.com/manaflow-ai/cmux/pull/8677)), and login-shell resolution before terminal spawn ([#8681](https://github.com/manaflow-ai/cmux/pull/8681))
- Fix sidebar reopen cutoff render ([#8626](https://github.com/manaflow-ai/cmux/pull/8626)), row clipping during height-changing reorder ([#9189](https://github.com/manaflow-ai/cmux/pull/9189)), idle layout livelock ([#8532](https://github.com/manaflow-ai/cmux/pull/8532)), and status URL clicks ([#8528](https://github.com/manaflow-ai/cmux/pull/8528))
- Fix Dock paste routing to the selected terminal ([#9112](https://github.com/manaflow-ai/cmux/pull/9112)), Dock terminal working-directory inheritance ([#8691](https://github.com/manaflow-ai/cmux/pull/8691)), and Cmd-click link opening in Dock terminals ([#8594](https://github.com/manaflow-ai/cmux/pull/8594))
- Browser: fix navigation for terminal-wrapped URL pastes ([#8601](https://github.com/manaflow-ai/cmux/pull/8601)), automation recovery after load failures ([#8548](https://github.com/manaflow-ai/cmux/pull/8548)), partial blank screenshots ([#9281](https://github.com/manaflow-ai/cmux/pull/9281)), and blurred Google Sheets canvas rendering ([#8697](https://github.com/manaflow-ai/cmux/pull/8697))
- Fix inline code escaping in the Markdown viewer ([#9274](https://github.com/manaflow-ai/cmux/pull/9274)) and composer attachment thumbnail re-rasterization ([#8817](https://github.com/manaflow-ai/cmux/pull/8817))
- Fix renderer presentation for background-created surfaces ([#8540](https://github.com/manaflow-ai/cmux/pull/8540)) and stale semantic prompts duplicating inline TUI frames ([#9275](https://github.com/manaflow-ai/cmux/pull/9275))
- Fix workspace group anchor numbering ([#9176](https://github.com/manaflow-ai/cmux/pull/9176)); closing a group's anchor keeps the group instead of scattering its members to the root ([#8925](https://github.com/manaflow-ai/cmux/pull/8925))
- Preserve workspace IDs across session restore ([#8695](https://github.com/manaflow-ai/cmux/pull/8695)) and restored resume workspace titles ([#8687](https://github.com/manaflow-ai/cmux/pull/8687)); fit same-display restored windows to visible bounds ([#8675](https://github.com/manaflow-ai/cmux/pull/8675))
- Fix a `DispatchWorkItem` chain stack overflow ([#8615](https://github.com/manaflow-ai/cmux/pull/8615)) and subprocess pipe descriptor leaks ([#9187](https://github.com/manaflow-ai/cmux/pull/9187))
- iOS (beta): preserve terminal input ordering under fast typing ([#8682](https://github.com/manaflow-ai/cmux/pull/8682)), scroll position across mid-stream verified replays ([#9032](https://github.com/manaflow-ai/cmux/pull/9032)), and keyboard focus after the photo picker ([#9287](https://github.com/manaflow-ai/cmux/pull/9287)) -- thanks @azooz2003-bit!
- iOS (beta): fix a startup crash from sentry-init racing environ mutation ([#9238](https://github.com/manaflow-ai/cmux/pull/9238)) and TestFlight crash paths ([#9034](https://github.com/manaflow-ai/cmux/pull/9034))
- iOS (beta): fix workspace-list scroll stutter from live updates ([#9139](https://github.com/manaflow-ai/cmux/pull/9139)), and make the notification feed scroll fast with thousands of items ([#9141](https://github.com/manaflow-ai/cmux/pull/9141)) -- thanks @azooz2003-bit!
@@ -17,6 +17,10 @@ A tag gives the app its own name, bundle ID, socket, and derived data path, so i
Other variants: `reloadp.sh` (Release), `reloads.sh` (Release as isolated "cmux STAGING"), `reload2.sh --tag <tag>` (both).
## Shared Mac fleet capacity
Every healthy slot in the canonical Mac fleet is general-purpose. Builds, iOS archives, tests, profiling, simulator and UI verification, and any other resource-intensive workload may use any available slot. Do not wait for an AWS-only builder or infer capacity from a workload label. Use the shared lease state and slot-isolated paths supplied by the fleet tooling.
Compile-only check, no launch:
```bash
@@ -44,15 +48,33 @@ The helper refuses to run without `CMUX_TAG`, targets `/tmp/cmux-debug-<tag>.soc
## iOS builds open on the iPhone by default
Any work verified by opening the iOS app installs BOTH an isolated-simulator build AND the same build on the user's iPhone. Never stop at simulator-only. Use `ios/scripts/reload-cloud.sh --tag <tag>` (or `ios/scripts/reload.sh --tag <tag>`); with a default iPhone configured (`CMUX_IPHONE_DEVICE_ID` or `~/.config/cmux/iphone-device-id`) the device leg is automatic, and `--device-id <id>` still overrides (`xcrun devicectl list devices`). Auto sign-in and auto-pair apply as usual; launch the app so it is immediately open on the phone. The simulator leg uses the tag's own isolated device `cmux-dev-<slug>`, created on demand; do not target a shared or user-visible simulator.
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`). Physical iPhone builds always select the `personal` auth profile. Agent-driven Simulator verification always selects `agent`. Both named profiles live in `~/.secrets/cmuxterm-dev.env`; neither may fall back to the other. The simulator leg uses the tag's own isolated device `cmux-dev-<slug>`, created on demand; do not target a shared or user-visible simulator.
**Every phone install MUST be authenticated before handoff. Installed-but-signed-out is a failed install.** A tagged bundle id can retain an older account, so every authenticated launch clears that tagged session, signs both surfaces into the selected profile, verifies the exact tagged Mac account through `auth status`, then mints the pairing ticket. The iPhone auth gate passes only after the same-account host accepts the phone RPC and emits `mobile.rpc.ready`. `scripts/verify-iphone-auth.sh --tag <tag> [--device-id <id>]` repeats the Mac-account check, relaunches the phone without credentials, and passes only when persisted phone state reconnects. Never install with raw `devicectl device install app`, and never pass `--no-sign-in`/`--no-attach`/`--no-setup` for a dogfood build. The scripts refuse those device paths unless a human sets `CMUX_ALLOW_UNAUTHENTICATED_INSTALL=1`. If setup fails, report the gate reason and exact retry command.
Every phone build requires the same-tag Mac dev build (the iOS app is unusable without its Mac). The reload scripts build the Mac tag first when it is missing and refuse to ship a phone-only build if that fails; do not bypass this with `CMUX_IOS_SKIP_MAC_BUILD_CHECK` in normal work.
If the iPhone is unreachable at build time, the reload still completes: the signed build is parked in the offline install queue (`scripts/iphone-install-queue.sh`, persistent under `~/Library/Application Support/cmux-dev/iphone-install-queue`), and a LaunchAgent auto-installs and launches it within seconds of the phone being plugged back in or reappearing on the network, then sends a `cmux notify` with the installed tags. The LaunchAgent is a one-time per-Mac setup: `scripts/install-iphone-queue-agent.sh install`; it runs a stable copy of the queue script, so re-run the installer after changing that script. In the handoff, report the queued state (`scripts/iphone-install-queue.sh list`) instead of treating an unreachable phone as a failure; `drain` retries manually,`clear` abandons a queued build.
If the iPhone is unreachable at build time, the signed build is parked in `scripts/iphone-install-queue.sh`. Each entry stores the chosen profile, normalized account, and credentials-file path. Drain revalidates that snapshot before device mutation and uses installed stable copies of the launcher and auth helpers, so an old or pruned feature worktree cannot change policy. Install or refresh that control plane with `scripts/install-iphone-queue-agent.sh install`. Report `scripts/iphone-install-queue.sh list` in the handoff; `drain` retries delivery and`clear` abandons a queued build.
## All fleet slots are general-purpose
Agent verification, macOS/iOS builds, archives, tests, profiling, and any other work too resource-intensive for the local Mac use the same Mac fleet. A slot is not a "build slot" or a "verify slot". From the cmuxterm-hq checkout that owns this worktree, every workload leases the canonical `~/.config/macfleet/hosts.json` inventory and shared `maclease` state.
Before waiting for a builder, run `scripts/macfleet-doctor.sh report --probe` from that hq checkout. If it reports `needs-sync`, run `scripts/macfleet-doctor.sh sync --apply`; it backs up the canonical manifest and merges legacy `hosts-verify.json` entries by SSH endpoint. Refresh the hq checkout before diagnosing capacity. Do not infer capacity from a stale checkout, one pool tag, or a remembered host list.
Agent verification runs on the fleet, not on the local Mac. `scripts/verify-remote.sh` leases a general-purpose slot, pushes the tagged build to the leased Mac, drives it there (per-lease uniquely named simulator for iOS; console launch with debug-socket and computer-use evidence for macOS), and fetches screenshots, recordings, and logs back into the hq `artifacts/verify-remote/` directory:
Boot a local simulator only when all-purpose `capacity` reports no free slot, and keep at most 3 local sims booted. Scripted XCUITests go through the hosted `test-e2e.yml` lane when appropriate. The physical-iPhone signing/install leg stays local via the install queue; its archive build may use any healthy fleet slot. Verify leases carry a description and TTL, so a crashed agent frees its slot automatically; see `skills/infra/macfleet/references/verify-remote.md` in cmuxterm-hq for the shared-pool contract and host onboarding.
## iOS dev auth
`ios/scripts/reload.sh` and `scripts/mobile-dev-launch.sh` auto-sign-in from `~/.secrets/cmuxterm-dev.env`. If the phone lands on the login screen or the helper reports missing credentials, do not ask the user to authenticate every build. Tell them to run `scripts/setup-team-dev.sh` once; it verifies their Stack login and writes the file chmod 600. Manual fallback: create it with `CMUX_DOGFOOD_STACK_EMAIL=...` and `CMUX_DOGFOOD_STACK_PASSWORD=...`.
`~/.secrets/cmuxterm-dev.env` is the only mobile dev credential file. `CMUX_DOGFOOD_STACK_*` is the `personal` profile for physical iPhone dogfood. `CMUX_UITEST_STACK_*` is the `agent` profile for isolated Simulators. Run `scripts/setup-team-dev.sh` once to verify and merge the personal pair without deleting the agent pair. Use `scripts/mobile-dev-launch.sh --check-auth-contract --auth-profile personal` or `--auth-profile agent` for a mutation-free preflight. Never substitute one profile when the requested profile is incomplete.
## Regression test commits
@@ -109,3 +131,7 @@ Detailed contributor rules live in `skills/`. Use the task-specific skill before
-`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, release assets.
- Blacksmith Testbox (remote Linux builds for cmux-tui): warm your own box before any cmux-tui Rust or Zig
build, and never compile cmux-tui on the Mac. The skill lives in cmuxterm-hq at
`skills/infra/blacksmith-testbox/SKILL.md`; the workflows, `scripts/blacksmith-*.sh`, and the
defaultValue:"cmux could not create the hooks directory: a file exists at %@. Remove or rename the conflicting file, then run `cmux hooks setup` again."
defaultValue:"cmux could not create the hooks directory: a file exists at %@; remove or rename the conflicting file and re-run `cmux hooks setup`"
defaultValue:"cmux could not create the hooks directory: a file exists at %@. Remove or rename the conflicting file, then run `cmux hooks setup` again."
letstatus=String(localized:"cli.ssh.autoReconnect.status",defaultValue:"[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).",bundle:bundle)
letstopHint=String(localized:"cli.ssh.autoReconnect.stopHint",defaultValue:"[cmux] close this pane or press Ctrl-C to stop reconnecting.",bundle:bundle)
letstatus=String(localized:"cli.ssh.manualReconnectPrompt.status",defaultValue:"[cmux] ssh exited with status %s.")
letdetail=String(localized:"cli.ssh.manualReconnectPrompt.detail",defaultValue:"[cmux] the remote VM may have been paused, destroyed, or lost network.")
letprompt=String(localized:"cli.ssh.manualReconnectPrompt.prompt",defaultValue:"[cmux] press Enter to close this pane. Press r then Enter to reconnect.")
letstatus=String(localized:"cli.ssh.manualReconnectPrompt.status",defaultValue:"[cmux] ssh exited with status %s.",bundle:bundle)
letdetail=String(localized:"cli.ssh.manualReconnectPrompt.detail",defaultValue:"[cmux] the SSH connection ended; the remote session may still be running.",bundle:bundle)
letprompt=String(localized:"cli.ssh.manualReconnectPrompt.prompt",defaultValue:"[cmux] press Enter to close this pane. Press r then Enter to reconnect.",bundle:bundle)
letstatus=String(localized:"cli.ssh.manualReconnectPrompt.status",defaultValue:"[cmux] ssh exited with status %s.",bundle:bundle)
letdetail=String(localized:"cli.ssh.manualReconnectPrompt.detail",defaultValue:"[cmux] the SSH connection ended; the remote session may still be running.",bundle:bundle)
letprompt=String(localized:"cli.ssh.terminalExitPrompt.prompt",defaultValue:"[cmux] press Enter to close this pane.",bundle:bundle)
" if [ \"$cmux_ssh_status\" -eq 0 ]; then break; fi",
" cmux_ssh_reset_terminal_modes",
" case \"$cmux_ssh_status\" in \(retryableStatusPattern)) ;; *) break ;; esac",
]
ifretryPTYAttachStatus{
@@ -436,6 +449,7 @@ extension CMUXCLI {
scriptLines.append(retryLimitCondition)
scriptLines+=[
" cmux_ssh_retry=$((cmux_ssh_retry + 1))",
"\(backoffBuilder.terminalInputModeResetLine)",
" 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\"",
]
scriptLines+=backoffBuilder.waitLines
@@ -448,8 +462,18 @@ extension CMUXCLI {
"trap - EXIT HUP INT TERM",
"cmux_ssh_session_end",
"if [ \"$cmux_ssh_status\" -ne 0 ]; then",
"printf '\\n\\033[31m[cmux] ssh exited with status %s.\\033[0m\\n\\033[2m[cmux] the remote VM may have been paused, destroyed, or lost network.\\033[0m\\n\\033[2m[cmux] press Enter to close this pane.\\033[0m\\n' \"$cmux_ssh_status\" >&2 || true",
terminalText.text=String(localized:"terminal.sample",defaultValue:"Last login: Fri Aug 7 19:45:12 on ttys006\n\n~/cmux git:(feat/keyboard-pinning-lab)\n❯")
Invariant: during interrupted rapid keyboard show and hide cycles, the Shortcut bar and Composer bar remain one rigid dock. The dock bottom stays coincident with the keyboard top, with no transient gap, overlap, lag, or snap-back.
Scenario: iPhone 17 Pro Max simulator on iOS 26.5, dark appearance, keyboard initially shown, 20 first-responder reversals at 135 ms intervals, then a final focused state. This interval is shorter than a normal keyboard transition and forces animation interruption.
Result: the live layout diagnostic remained `PIN GAP 0.0 pt`. The final run was sampled at 15 fps, including steady, partial-hide, hidden, partial-show, reversed, and final frames. The invariant held in every sampled frame.
Durable evidence is stored at `cmux-assets/feat-keyboard-pinning-lab/rapid-toggle-final/` in the cmuxterm-hq checkout. The directory contains the raw recording, annotated frames, contact sheet, and manifest with the success criterion.
This standalone iOS app isolates the Workspace Detail keyboard geometry from cmux state and networking.
The Composer and Shortcut bars live in one `ComposerDockView`. Its bottom edge is constrained directly to `UIKeyboardLayoutGuide.topAnchor`. UIKit therefore owns the keyboard frame, the dock frame, and interrupted animation timing in one constraint graph.
The circular-arrow header button reverses first-responder state every 135 ms to stress interrupted keyboard transitions. The header reports the live constraint gap. Green `PIN GAP 0.0 pt` means the dock and keyboard guide are coincident in the current layout pass.
Tapping the terminal canvas focuses the same Composer text field, so terminal tap, direct Composer tap, the keyboard button, and the stress control all exercise one keyboard ownership path.
Generate the Xcode project with `xcodegen generate`, then build the `KeyboardPinningLab` scheme.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.