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]>
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
2321 changed files with 227196 additions and 23801 deletions
# 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
@@ -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.