Compare commits

...
Author SHA1 Message Date
Lawrence Chen 3fad5abf1d Merge pull request #10424 from manaflow-ai/feat/coderouter-status-session-counts
Expose per-account session counts and cooldowns in account listing
2026-08-19 04:13:14 -07:00
Lawrence Chen 34f0e1eeca Expose per-account session counts and cooldowns in account listing
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.
2026-08-19 03:59:50 -07:00
Lawrence Chen 882ab10e75 Merge pull request #10402 from manaflow-ai/feat/coderouter-free-tier
Make hosted coderouter free for up to 3 accounts
2026-08-18 23:44:59 -07:00
Lawrence Chen 9ad361ed37 Make hosted coderouter free for up to 3 accounts
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.
2026-08-18 23:21:33 -07:00
Lawrence Chen ae7d761e03 Merge pull request #10387 from manaflow-ai/fix/coderouter-codex-session-spread
Spread Codex placements and pin sessions to accounts
2026-08-18 21:57:20 -07:00
Austin Wang c15d665f65 Merge pull request #10363 from manaflow-ai/issue-10103-copy-mode-selection-bleed
Fix terminal Copy Mode selection scope across split panes
2026-08-18 21:46:09 -07:00
Lawrence Chen 425f2d7efe Keep sticky sessions through in-flight credential refreshes
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.
2026-08-18 21:43:42 -07:00
Lawrence Chen 7d79a858f6 Degrade to legacy routing while the session table migration is pending
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.
2026-08-18 21:38:20 -07:00
Lawrence Chen 18ed976a1b Spread Codex placements and pin sessions to accounts
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.
2026-08-18 21:34:14 -07:00
Abdulaziz Albahar 9d815638c7 Merge pull request #10381 from manaflow-ai/fix-legacy-beta-namespace-fallback
fix(iroh): let pre-namespace legacy iOS bindings pair with default and nightly Macs
2026-08-18 20:59:38 -07:00
Lawrence Chen 34483ba969 Merge pull request #10382 from manaflow-ai/chore-testbox-skill-moved
chore: move the Testbox skill to cmuxterm-hq
2026-08-18 20:43:39 -07:00
Lawrence Chen bc0dcaa8b9 chore: move the Testbox skill to cmuxterm-hq
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.
2026-08-18 20:40:08 -07:00
Abdulaziz Albahar 540bf2b9e4 Merge pull request #10310 from manaflow-ai/feat-conn-method-diagnostics
feat(ios): state connection method and live transport in diagnostics reports
2026-08-18 20:29:53 -07:00
Abdulaziz Albahar 2048eebfc9 Merge pull request #10294 from manaflow-ai/feat-tsonly-secondary-gate
fix(ios): Tailscale-only must gate secondary-Mac and discovery Iroh dials
2026-08-18 20:29:44 -07:00
Abdulaziz Albahar 65d4870c00 Merge pull request #10313 from manaflow-ai/fix-gallery-picker-simstream
fix(ios): pass selectSimulatorStream in the surface gallery preview
2026-08-18 20:29:39 -07:00
Abdulaziz AlbaharandClaude Fable 5 3f2b88f307 refactor(ios): map connection method to its diagnostics enum exhaustively
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]>
2026-08-18 20:29:20 -07:00
Abdulaziz AlbaharandClaude Fable 5 58416dcbd4 test(ios): pin the runtime clock in the Tailscale-only pool tests
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]>
2026-08-18 20:26:47 -07:00
Lawrence Chen bee0e6c546 Merge pull request #10304 from manaflow-ai/feat-tab-identity-single-owner
Make the topology own tab identity
2026-08-18 20:25:19 -07:00
Lawrence Chen 76418e4592 Merge pull request #10299 from manaflow-ai/feat-startup-orphan-terminal-projection
Fix startup wedge when several terminal hosts are unadoptable
2026-08-18 20:25:01 -07:00
Abdulaziz AlbaharandClaude Fable 5 5bc3fee2c8 fix(iroh): let pre-namespace legacy iOS bindings pair with default and nightly Macs
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]>
2026-08-18 20:15:24 -07:00
Abdulaziz AlbaharandClaude Fable 5 d856159ce8 test(iroh): legacy default-lane iOS bindings must reach default and nightly Macs
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]>
2026-08-18 20:08:29 -07:00
Austin Wang 11684e5064 Merge pull request #10359 from manaflow-ai/issue-10102-drop-ghosted-regression
Fix terminal file drops being ghosted after pane teardown
2026-08-18 18:12:07 -07:00
austinpower1258 485d627042 fix: complete drop result and isolate fixtures 2026-08-18 16:43:22 -07:00
austinpower1258 d4a2021b1f test: cover multiple promised image items 2026-08-18 16:40:01 -07:00
austinpower1258 94f35f8e5a fix: read each promised drop item 2026-08-18 16:39:08 -07:00
austinpower1258 b7d1c230e2 fix: handle optional drag pasteboard types 2026-08-18 16:35:24 -07:00
austinpower1258 5129950f7e fix: make transient image drops durable 2026-08-18 16:34:02 -07:00
austinpower1258 b372e09f9f fix: avoid redundant pasteboard type fallbacks 2026-08-18 15:04:32 -07:00
austinpower1258 167eb677e6 fix: expose pasteboard temporary root to drop planner 2026-08-18 14:57:53 -07:00
austinpower1258 c8f202201b fix: preserve promised image drop files 2026-08-18 14:19:45 -07:00
austinpower1258 6e56e6ff14 fix: cancel copy mode when pane deactivates 2026-08-18 14:16:33 -07:00
austinpower1258 58a0400a6e fix: recognize unix temporary drop paths 2026-08-18 14:14:07 -07:00
austinpower1258 a1a5ef6c39 fix: restore terminal file drops and retain transient images 2026-08-18 14:12:16 -07:00
austinpower1258 b0948c0341 fix: cancel copy mode on responder loss 2026-08-18 14:11:59 -07:00
austinpower1258 d3e3abd367 fix: scope terminal copy mode to focused pane 2026-08-18 14:01:01 -07:00
austinpower1258 9f556f118e test: require copy mode to end on focus loss 2026-08-18 14:00:14 -07:00
Abdulaziz Albahar 786a35d099 Show model loading and model-specific effort controls (#10098)
* test: cover pending model catalogs in shared picker

* Show model catalog loading in shared picker

* test: expose buried model loading state

* Make pending model picker unmistakable

* test: require model-specific effort picker

* Use model-specific effort metadata

* test: require native model effort picker

* test: isolate native effort picker regression

* test: isolate iOS UI test plan

* feat: add native model effort picker

* test: verify native effort choices

* test: cover all native effort choices

* test: require shared readable picker scroller

* fix: keep task pickers readable in one scroller

* test: measure visible picker labels accurately

* test: cover default model effort picker

* fix: source efforts for the default model

* fix: keep default efforts through draft restore

* test: cover unpinned default model efforts

* test: define semantic OpenCode effort ordering

* fix: order OpenCode effort variants semantically

* test: cover task model discovery failures

* fix: surface task model discovery errors

* fix: keep model discovery errors readable

* fix: resolve provider loading review findings
2026-08-18 13:58:00 -07:00
austinpower1258 c7e3d94b66 test: prevent inactive terminal drop target shadowing 2026-08-18 13:50:10 -07:00
Abdulaziz AlbaharandClaude Fable 5 7589f52b81 Isolate every iOS build by bundle identity (#9183)
* test: require iOS build isolation

* fix: isolate iOS builds by bundle identity

* fix: compile namespaced Mac backup publisher

* chore: remove shared app group example

* test: require isolated iOS OAuth cookies

* fix: isolate iOS OAuth browser cookies

* fix: close autoreview namespace gaps

* fix: complete iOS namespace isolation

* refactor: satisfy namespace isolation policy

* fix: close namespace migration review gaps

* fix: preserve isolated Iroh and OAuth admission

* test: cover isolated iOS rollout paths

* fix: authenticate isolated iOS rollout paths

* test: cover authenticated management recovery

* fix: authorize isolated management operations

* test: cover cached proof and Mac forget

* fix: preserve proof across isolated lifecycle paths

* fix: satisfy Iroh API package policy

* test: cover autoreview isolation regressions

* fix: close isolated rollout review gaps

* test: cover target and trust boundary regressions

* fix: enforce isolated pairing boundaries

* test: cover legacy tombstone migration

* fix: preserve tombstones across backup migration

* test: cover migration precedence and discovery privacy

* fix: enforce migration and discovery precedence

* test: cover push targeting and migration bounds

* fix: close push and migration isolation gaps

* test: prevent entitlement dumps on signing failures

* fix: redact signed entitlements on upload failure

* fix: make backup scope provider sendable

* fix: return localized pairing target names

* fix: drain legacy namespaced revocations

* test: track push token policy limits

* test: cover post-revocation fallback paths

* fix: refresh authority after binding revocations

* test: cover paired-Mac migration boundaries

* fix: bound and scope legacy paired-Mac migration

* fix: capture migration account without async coalescing

* fix: keep legacy migration and keychain deletion safe

* chore: refresh pull request head

* fix: bound legacy backup reconciliation

* test: cover conditional paired Mac migration

* fix: make paired Mac migration conditional

* test: cover pairing migration recovery races

* fix: serialize identity persistence recovery

* test: cover migration team and pairing lanes

* fix: pin migration team and pairing channel

* test: await development identity storage

* test: cover pairing emission and keychain scope

* fix: fail closed on app and keychain scope

* test: cover bundle-derived Mac namespaces

* fix: namespace Mac bindings by bundle

* test: cover legacy sign-out binding namespaces

* fix: revoke bindings in their stored namespace

* test: expect APNs bundle in send outcome

* test: cover untagged debug pairing identity

* fix: preserve untagged debug pairing identity

* test: cover rate-limited pending revocations

* fix: drain revocations after rate-limited registration

* test: cover safe pending revocation reconciliation

* fix: preserve active binding during revocation recovery

* fix: preserve broker proof through client wrappers

* test: retain authorization during rate-limited recovery

* fix: preserve retained binding during rate-limit recovery

* test: cover stale binding cleanup authorization

* fix: authorize stale binding cleanup

* docs: document binding authorization helpers

* test: type stale cleanup response

* fix(macos): restore compilable cleanupSurfaceState call

https://github.com/manaflow-ai/cmux/pull/10072 changed this call site to
pass workspaceID: but never landed that overload, so the macOS target
has not compiled since it merged (CI is dispatch-only and did not catch
it). Restore the existing signature; the native-mobile-surface
preservation intent needs to re-land together with its implementation.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix: wire mobile surface artifact integration

* fix: require explicit trust broker namespace

* fix: require explicit management revocation routes

* fix: reject ambiguous iOS bundle namespaces

* fix: bound identity waits and scope legacy auth

* fix: cancel queued identity operations

* fix: modernize browser change handlers

* fix: scope legacy token deletion

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-18 13:37:30 -07:00
Abdulaziz Albahar 029d652972 docs: define shared fleet capacity (#10312)
* docs: define shared fleet and messaging policy

* docs: keep messaging policy global
2026-08-18 12:34:01 -07:00
Lawrence Chen c821be8dfc Merge pull request #10306 from manaflow-ai/fix-testbox-push-requirement
fix: require a pushed commit for Testbox benchmarks, and lead the skill with the workflow
2026-08-17 23:31:24 -07:00
Lawrence Chen 0c18281d4e fix: the demo script leaked the runner it told you to worry about
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.
2026-08-17 23:24:28 -07:00
Lawrence Chen 8dca3436f8 fix: cargo test needs umask 022 on a Testbox, and I shipped it without
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.
2026-08-17 23:17:11 -07:00
Lawrence Chen 39410cd265 fix: identify your run by set difference, not a time window
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.
2026-08-17 23:08:35 -07:00
Lawrence Chen 2765225598 docs: cover tests and lints, and move the threat model out of the way
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.
2026-08-17 22:42:28 -07:00
Lawrence Chen b6a6403506 docs: stop the exit trap crying wolf on a clean run
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.
2026-08-17 22:38:59 -07:00
Lawrence Chen 93cba89d64 fix: the poll guard I added never ran, and stranded a box
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.
2026-08-17 22:21:26 -07:00
Lawrence Chen 9ebfec987c fix: put the approval in the code block that needs 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.
2026-08-17 21:58:45 -07:00
Lawrence Chen 8d3edf5b28 fix: stop the approval guard from aborting a warmup that was fine
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.
2026-08-17 21:37:36 -07:00
Lawrence Chen f41a1ee183 Verify R2 nightly bytes before redirect (#10317) 2026-08-17 21:32:22 -07:00
Lawrence Chen 307a2acb28 docs: fix the last three defects, including a second clock that was never captured
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.
2026-08-17 21:17:43 -07:00
Lawrence Chen 0a0d41243d Support signed R2 nightly assets on web downloads (#10309) 2026-08-17 21:06:57 -07:00
Lawrence Chen ae6a0f143e fix: pin before the stages, and prove a binary came out
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.
2026-08-17 20:57:34 -07:00
Abdulaziz AlbaharandClaude Fable 5 bd8d313383 fix(ios): pass selectSimulatorStream in the surface gallery preview
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]>
2026-08-17 20:40:47 -07:00
Lawrence Chen 4e54c420a2 docs: fix the evidence-pack defects a third trial agent found
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.
2026-08-17 20:38:01 -07:00
Lawrence Chen 9e841fc193 fix: repair the five defects a second trial agent hit
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.
2026-08-17 20:20:13 -07:00
Abdulaziz AlbaharandClaude Fable 5 3a15a3e053 fix(ios): honor Tailscale-only for secondary Macs and Iroh discovery
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]>
2026-08-17 19:56:09 -07:00
Abdulaziz AlbaharandClaude Fable 5 6e27c776ac test(ios): Tailscale-only must not dial Iroh for secondary Macs
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]>
2026-08-17 19:56:09 -07:00
Abdulaziz AlbaharandClaude Fable 5 a0c9ba476c feat(ios): state connection method and live transport in diagnostics
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]>
2026-08-17 19:54:11 -07:00
Lawrence Chen 1e4592dd90 fix: unbreak receipt-bound Testbox cleanup, and close the doc gaps a test agent hit
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.
2026-08-17 19:27:31 -07:00
Lawrence Chen 8238cecf5c feat: drop the demo script's confirmation prompt
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.
2026-08-17 19:10:24 -07:00
Lawrence Chen f9aaa2c77d docs: point the skill at the tour script and explain the cancelled run state 2026-08-17 18:51:56 -07:00
Lawrence Chen 57984fa578 feat: add a runnable Testbox tour script
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.
2026-08-17 18:43:46 -07:00
Lawrence Chen d0f14053a2 docs: make blacksmith-testbox a skill an agent acts on, not a manual
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.
2026-08-17 18:39:51 -07:00
Lawrence Chen 3e377bba84 fix: require and enforce a pushed commit for Testbox benchmarks
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.
2026-08-17 18:19:07 -07:00
Lawrence Chen c8d2cac0eb Merge pull request #10305 from manaflow-ai/feat-testbox-guard-workflow
ci: run the Testbox broker guard on every pull request
2026-08-17 18:12:07 -07:00
Lawrence Chen 86f14c0997 ci: name both shellcheck codes for the trap-invoked helpers
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.
2026-08-17 18:10:36 -07:00
Lawrence Chen 8ebd307452 ci: run the Testbox broker guard on every pull request
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.
2026-08-17 18:02:25 -07:00
Lawrence Chen 2562c5ecfe Merge pull request #10303 from manaflow-ai/feat-testbox-main-broker
ci: warm cmux-tui Testboxes from a main-controlled broker
2026-08-17 18:00:08 -07:00
Lawrence Chen d28e3199fe docs: describe the broker ref guard instead of the removed reviewed pins 2026-08-17 17:21:50 -07:00
Lawrence Chen 7105bc46e4 test: guard the Testbox broker trust boundary in CI
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.
2026-08-17 17:20:37 -07:00
Lawrence Chen f4222dec61 ci: warm cmux-tui Testboxes from a main-controlled broker
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.
2026-08-17 17:20:31 -07:00
Lawrence Chen 70f2e14f44 Make the topology own tab identity
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.
2026-08-17 17:15:07 -07:00
Austin Wang 0fc34d7067 Merge pull request #10198 from manaflow-ai/issue-10189-claude-teams-panel-path-title
Fix Claude Teams panel PATH and agent names
2026-08-17 16:39:22 -07:00
Austin Wang 33a43031e2 Merge pull request #10211 from manaflow-ai/issue-10204-reload-build-perf
Speed up Blacksmith reload builds with warm caches
2026-08-17 16:39:09 -07:00
Lawrence Chen aa046ab0c7 Project restored tabs from durable identity, not live surfaces
`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.
2026-08-17 16:33:18 -07:00
Lawrence Chen e276c42ede Add failing test for two dead hosts behind one pane
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.
2026-08-17 16:25:27 -07:00
austinpower1258 04576b88f5 Bound cached products and preserve cmuxd behavior 2026-08-17 16:17:49 -07:00
Austin Wang 11c4556b1d Merge pull request #10224 from manaflow-ai/issue-10210-light-chrome-white-controls
Fix browser chrome appearance authority across focus transitions
2026-08-17 15:58:00 -07:00
austinpower1258 3950e833ad Fix shim tilde expansion and OS cache isolation 2026-08-17 15:56:48 -07:00
Austin Wang af1788ca18 Merge pull request #10234 from manaflow-ai/fix-nightly-main-compile
Fix nightly macOS Release compile on main
2026-08-17 15:52:27 -07:00
Abdulaziz Albahar 12b646bf56 iOS: remove New Task Composer beta toggle (#10291)
* test(ios): cover task composer beta toggle removal

* fix(ios): remove task composer beta setting

* test(ios): use stable settings scroll anchor

* test(ios): retain retired toggle coverage

* test(ios): narrow retired beta toggle coverage
2026-08-17 15:47:00 -07:00
austinpower1258 0274b638d4 Harden Ghostty fallback and cache architecture keys 2026-08-17 15:44:30 -07:00
Abdulaziz AlbaharandClaude Fable 5 e27b734c53 fix(ios): implement selectedMacSurfaceID store selection, unhide effectiveConnectionStatus (#10295)
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]>
2026-08-17 15:39:24 -07:00
austinpower1258 dd954ccfc1 Harden reload timing and Ghostty download budgets 2026-08-17 15:27:56 -07:00
Abdulaziz AlbaharandClaude Fable 5 240c978340 fix(ios): public import for MobileWorkspacePreview.ID in capabilities API (#10290)
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]>
2026-08-17 15:15:10 -07:00
austinpower1258 aab8ceccb7 fix(macOS): forward workspace identity during surface cleanup 2026-08-17 14:57:53 -07:00
austinpower1258 4113149d01 fix(macOS): reconcile mobile surface APIs on main 2026-08-17 14:57:53 -07:00
Abdulaziz AlbaharandClaude Fable 5 21c48914dd fix(ios): make artifact failure and scope switches exhaustive (#10287)
* 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]>
2026-08-17 14:44:11 -07:00
Abdulaziz Albahar e2fedffd0f Merge pull request #10112 from manaflow-ai/fix-ios-push-toggle-off
Fix iOS push alerts opt-out toggle
2026-08-17 11:49:17 -07:00
Abdulaziz Albahar 9c5f7d072f fix(ios): stop replay scroll drain watchdog loop (#10186)
* test(ios): bound verified replay scroll drain ownership

* fix(ios): bound verified replay scroll ownership
2026-08-17 11:27:31 -07:00
Austin Wang 7f9af0f900 Merge pull request #10223 from manaflow-ai/issue-10222-double-open-preferred-editor
Fix double-open: deliver each terminal open event to exactly one handler
2026-08-16 12:57:06 -07:00
austinpower1258andClaude Fable 5 aa025fa30a review: settle-window open-url assertion; drop debug payload field
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]>
2026-08-15 20:13:39 -07:00
austinpower1258 f0da2a5262 fix: preserve inherited appearance for WebKit refresh 2026-08-15 19:21:17 -07:00
austinpower1258 f3a2eb940c fix: keep browser chrome on resolved surface appearance 2026-08-15 19:15:41 -07:00
austinpower1258andClaude Fable 5 e8ad112051 fix: deliver each open event to exactly one handler (#10222)
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]>
2026-08-15 19:08:07 -07:00
austinpower1258 d93e73b3d7 Scope reload caches to checkout paths 2026-08-15 19:03:44 -07:00
austinpower1258andClaude Fable 5 e1b1c00bea test: regression coverage for double-open with preferredEditor (#10222)
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]>
2026-08-15 19:01:54 -07:00
austinpower1258 9d67c5c624 test: cover browser chrome authority across focus 2026-08-15 19:00:25 -07:00
austinpower1258 3fd79f1928 Reuse exact SPM state on warm reloads 2026-08-15 18:57:44 -07:00
Austin Wang 77adc69121 Merge pull request #10149 from manaflow-ai/issue-10146-dock-theme-mismatch
Fix Dock chrome theme authority mismatch
2026-08-15 18:36:09 -07:00
Abdulaziz Albahar ec4389e94e fix(ios): pin dogfood auth identity end to end (#10185)
* 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
2026-08-15 17:02:30 -07:00
Abdulaziz Albahar 52e1b9c9a1 Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off 2026-08-14 21:27:21 -07:00
austinpower1258 843a9ac2be Harden reload cache fallbacks 2026-08-14 21:20:48 -07:00
Abdulaziz Albahar 04ff18eea6 Remove iOS todo row sparkles and preserve native mobile surfaces
Remove iOS todo row sparkles and preserve native mobile surfaces
2026-08-14 21:18:27 -07:00
austinpower1258 152ec20140 Make reload DerivedData cache reusable 2026-08-14 20:54:51 -07:00
Abdulaziz Albahar 8677b43be3 fix(push): await bounded opt-out cleanup 2026-08-14 20:53:58 -07:00
Austin Wang 5734a451cc Merge pull request #10207 from manaflow-ai/issue-10155-browser-default-zoom
Add configurable default browser zoom level
2026-08-14 20:47:28 -07:00
austinpower1258 4407564458 fix: retain Claude launch path dependency 2026-08-14 20:43:02 -07:00
austinpower1258andClaude Fable 5 6b955a4b1c test: snapshot persisted defaults, not resolved values
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]>
2026-08-14 20:42:42 -07:00
Austin Wang 21fac23d5f Merge pull request #10193 from manaflow-ai/issue-10191-claude-teams-docs
Fix Claude Teams shim and panel diagnostics docs
2026-08-14 20:35:27 -07:00
austinpower1258 14f33ab531 fix: validate versioned Claude title metadata 2026-08-14 20:34:22 -07:00
austinpower1258 1a9453a3bc fix: initialize Claude teammate panel titles 2026-08-14 20:33:11 -07:00
Abdulaziz Albahar 869b9929c5 fix(push): preserve ambiguous cleanup state 2026-08-14 20:29:37 -07:00
austinpower1258 5a6d422034 Speed up Blacksmith reload builds 2026-08-14 20:28:00 -07:00
austinpower1258andClaude Fable 5 dd8ab01ba1 Add configurable default browser zoom
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]>
2026-08-14 20:16:11 -07:00
Abdulaziz Albahar 8fa502120b fix(push): bound recovery workers 2026-08-14 20:15:33 -07:00
austinpower1258 218eb4dc69 test: cover configurable browser zoom 2026-08-14 20:12:21 -07:00
austinpower1258 67d408bfca test: cover initial Claude teammate panel title 2026-08-14 20:10:16 -07:00
Abdulaziz Albahar b4ff55127a fix(push): close notification intent races 2026-08-14 19:51:57 -07:00
austinpower1258 f438cdbd85 fix: recognize versioned Claude teammate launches 2026-08-14 19:47:07 -07:00
austinpower1258 564541f02b test: cover versioned Claude teammate executables 2026-08-14 19:45:59 -07:00
Abdulaziz Albahar d1ef41b535 fix(push): make cleanup migration transactional 2026-08-14 19:36:47 -07:00
austinpower1258 9e920b4373 fix: preserve agent title through spinner frames 2026-08-14 19:20:57 -07:00
austinpower1258 d6b75fe6ee fix: snapshot agent titles at runtime callback boundary 2026-08-14 19:15:36 -07:00
Abdulaziz Albahar d212b57054 fix(push): index durable cleanup obligations 2026-08-14 19:13:06 -07:00
austinpower1258 a4941d13a4 Complete Claude Teams locale parity 2026-08-14 19:11:32 -07:00
Abdulaziz Albahar 514684743a fix(push): preserve generation through cleanup recovery 2026-08-14 18:58:21 -07:00
austinpower1258 6b5c869114 fix: restore login PATH and names for agent panels 2026-08-14 18:55:57 -07:00
austinpower1258 7d0687b431 test: cover claude teams panel shell and title regressions 2026-08-14 18:55:57 -07:00
austinpower1258 58b475529f Clarify Claude Teams shim directory root 2026-08-14 18:54:22 -07:00
Abdulaziz Albahar 8dc9926c38 fix(push): preserve cleanup intent across retries 2026-08-14 18:48:30 -07:00
Abdulaziz Albahar d3841ca493 fix(push): supersede direct cleanup retries 2026-08-14 18:39:34 -07:00
austinpower1258 bb0733e104 Complete Claude Teams localization coverage 2026-08-14 18:36:24 -07:00
Abdulaziz Albahar 3a25ecf3d1 fix(push): continue paged cleanup drains 2026-08-14 18:33:45 -07:00
Abdulaziz Albahar 0c443b5520 fix(push): discard stale cleanup index entries 2026-08-14 18:25:39 -07:00
Austin Wang 970184b961 Merge pull request #10177 from manaflow-ai/issue-4250-omnibar-file-paths
Fix browser omnibar absolute local file paths
2026-08-14 18:22:07 -07:00
Abdulaziz Albahar 3f6a681ba2 fix(push): page durable cleanup overflow 2026-08-14 18:19:38 -07:00
austinpower1258 ced18fc9e2 Fix Claude Teams diagnostics documentation 2026-08-14 18:14:52 -07:00
Abdulaziz Albahar d7186080b8 Expose iOS Networking controls and reset (#10184)
* Add iOS networking diagnostics controls

* Harden networking settings reset
2026-08-14 18:11:34 -07:00
Lawrence ChenandAbdulaziz Albahar fb2b512e1a Add Keep Mac Awake control on Mac and iOS (#7564)
* 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]>
2026-08-14 18:09:35 -07:00
Abdulaziz Albahar 48730ca18d Merge origin/main into fix-ios-push-toggle-off 2026-08-14 18:08:05 -07:00
Abdulaziz Albahar cd37b220f6 Preserve migrated Tailscale routes during Iroh refresh (#10188)
* test: preserve tailscale grant during iroh refresh

* fix: retain migrated tailscale grants during route refresh
2026-08-14 18:06:42 -07:00
Abdulaziz Albahar 708bc6b89b fix(push): preserve overflow cleanup obligations 2026-08-14 18:05:56 -07:00
Austin Wang 7418867200 Merge pull request #10175 from manaflow-ai/issue-10156-fork-session-id
Make Claude SessionStart the sole owner of fork session identity
2026-08-14 18:02:56 -07:00
austinpower1258andClaude Fable 5 1f637ec007 Address CodeRabbit review findings
- 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]>
2026-08-14 17:47:50 -07:00
Abdulaziz Albahar 8033c260b7 Fix iOS diagnostics share link hit targets (#10180)
* test: cover iOS diagnostics share targets

* fix: restore iOS diagnostics share link taps
2026-08-14 17:33:33 -07:00
Austin Wang 5979603a89 Merge pull request #10130 from manaflow-ai/issue-10128-pi-hook-timeout-telemetry
fix(pi): diagnose configurable hook timeouts
2026-08-14 17:20:03 -07:00
Abdulaziz AlbaharandClaude Fable 5 b89cdb16e8 fix(ios): distinct group glyph in task composer group row (#10176)
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]>
2026-08-14 16:55:54 -07:00
Abdulaziz Albahar ad4da8fb87 fix: bound native auth request fan-out
Merge authorized by user.
2026-08-14 16:32:11 -07:00
austinpower1258 a1f2b605f1 Merge remote-tracking branch 'origin/main' into issue-10156-fork-session-id
# Conflicts:
#	Sources/DockSplitStore+SurfaceResume.swift
#	Sources/Workspace.swift
2026-08-14 15:52:21 -07:00
Austin Wang ac018861d9 Merge pull request #10133 from manaflow-ai/issue-9756-sidebar-git-watch-lstat-storm
fix: bound sidebar git status watching
2026-08-14 15:50:50 -07:00
Austin Wang 601f1d3630 Merge pull request #7459 from owenjohnson/feat-system-tree-layout-field
feat(control-socket): emit split-layout geometry in `system.tree`
2026-08-14 15:50:20 -07:00
austinpower1258 1af355d7d5 fix: classify omnibar absolute paths as file URLs 2026-08-14 15:46:41 -07:00
austinpower1258 5a2a3098b1 test: reproduce omnibar absolute file paths 2026-08-14 15:43:55 -07:00
cmux reload-cloudandClaude Fable 5 20a9d78a79 fix(pi): bound lifecycle backlog, respect feed deadline
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 14:16:15 -07:00
cmux reload-cloudandClaude Fable 5 c8c1225ac6 test(pi): shed feed work behind stalled hooks
Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 14:13:30 -07:00
austinpower1258andClaude Fable 5 3131545160 Make Claude SessionStart the sole owner of fork session identity
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]>
2026-08-14 13:10:19 -07:00
Austin Wang c742541432 Merge pull request #10100 from manaflow-ai/issue-9629-codex-resume-binding-verification
fix: verify Codex resume binding ownership
2026-08-14 13:00:48 -07:00
Abdulaziz Albahar 4364c2d23f fix(push): bound pending cleanup state 2026-08-14 03:15:56 -07:00
Abdulaziz Albahar c32c6311b7 test(push): bound pending cleanup storage 2026-08-14 03:14:54 -07:00
Abdulaziz Albahar d9a4ea5747 fix(push): authorize before backend reconciliation 2026-08-14 03:08:06 -07:00
Abdulaziz Albahar d34c247866 fix(push): let opt-outs bypass stale work 2026-08-14 02:54:04 -07:00
Abdulaziz Albahar 9800bddc17 test(push): require immediate opt-out cleanup 2026-08-14 02:52:47 -07:00
Abdulaziz Albahar 1fa42306cf refactor(push): own intent reconciliation 2026-08-14 02:37:40 -07:00
Abdulaziz Albahar 3607cc8f69 fix(ios): reconcile started push mutations 2026-08-14 02:21:29 -07:00
Abdulaziz Albahar c9be5246a7 fix(ios): supersede stale push intents 2026-08-14 02:15:50 -07:00
Abdulaziz Albahar 9aae5b34b8 test(ios): cover superseded push intent 2026-08-14 02:08:49 -07:00
Abdulaziz Albahar 38ffb32c68 fix(ios): honor resolved push state 2026-08-14 01:53:47 -07:00
Abdulaziz Albahar e6d722d5a8 fix(ios): preserve queued push intent 2026-08-14 01:49:42 -07:00
Abdulaziz Albahar 71818d7367 fix(ios): serialize push toggle mutations 2026-08-14 01:40:58 -07:00
Abdulaziz Albahar 1834813593 test(ios): isolate push fixture state 2026-08-14 01:36:12 -07:00
Abdulaziz Albahar 2f0e084e10 test(ios): bound pending push fixture 2026-08-14 01:35:46 -07:00
Abdulaziz Albahar 253e6707fd fix(ios): keep push toggle interactive 2026-08-14 01:34:57 -07:00
Abdulaziz Albahar dc379323d2 test(ios): signal pending push mutation 2026-08-14 01:29:30 -07:00
Abdulaziz Albahar ed6e5e0297 fix(ios): update push toggle optimistically 2026-08-14 01:20:18 -07:00
Abdulaziz Albahar 4666aa7b75 test(ios): cover pending push opt-out 2026-08-14 01:20:09 -07:00
Abdulaziz Albahar c9cc7c1309 refactor(ios): reduce push toggle fix scope 2026-08-14 01:18:46 -07:00
Abdulaziz Albahar 8a982c134a Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off 2026-08-14 01:18:32 -07:00
Abdulaziz Albahar 8a8fb0b835 fix(ios): recover timed-out push cleanup 2026-08-14 01:09:28 -07:00
Abdulaziz Albahar d7ebaae630 test(ios): recover timed-out push opt-out 2026-08-14 01:03:57 -07:00
Abdulaziz Albahar c33317abdf refactor(push): use one reconciliation worker 2026-08-14 00:52:45 -07:00
Abdulaziz Albahar e599af99e5 fix(push): order sign-out with newer registration 2026-08-14 00:14:36 -07:00
Abdulaziz Albahar 86c89f5515 test(push): protect newer registration from stale sign-out 2026-08-14 00:11:59 -07:00
Abdulaziz Albahar 6cf7f5fc27 Fix legacy Tailscale route activation after Iroh upgrade
Preserve one pre-Iroh Tailscale fallback when the registry refresh is Iroh-only, keep registry-published routes authoritative, and serialize forced method-switch reconnects.
2026-08-13 23:51:34 -07:00
Abdulaziz Albahar 890b6b9531 refactor(push): isolate intent worker type 2026-08-13 23:51:33 -07:00
Abdulaziz Albahar 9b0f659a9e fix(push): bound cached registration recovery 2026-08-13 23:50:29 -07:00
Abdulaziz Albahar 61b1afca7a test(push): require fresh bounded recovery after timeout 2026-08-13 23:47:52 -07:00
Abdulaziz Albahar 601904189a test(push): wait for same-lane recovery state 2026-08-13 23:39:00 -07:00
Abdulaziz Albahar 01fdd8ef3a fix(push): bound same-lane recovery and startup cleanup 2026-08-13 23:33:37 -07:00
Abdulaziz Albahar 5528198cc1 test(push): cover startup drain and same-lane recovery 2026-08-13 23:30:29 -07:00
austinpower1258 b9aefd296f test: reject Claude fork parent fallback identity 2026-08-13 23:25:12 -07:00
austinpower1258 1c0e24f22c test: cover authoritative Claude fork identity 2026-08-13 23:21:38 -07:00
cmux reload-cloud ac370042d6 fix(pi): bound diagnostics and Feed timeouts 2026-08-13 23:18:39 -07:00
Abdulaziz Albahar 6832304b12 fix(push): fence late intents and timeout races 2026-08-13 23:18:38 -07:00
cmux reload-cloud 346e9c3399 test(pi): bound diagnostic and feed queues 2026-08-13 23:16:27 -07:00
austinpower1258 eae15a5a49 Merge remote-tracking branch 'origin/main' into issue-9629-codex-resume-binding-verification 2026-08-13 23:14:05 -07:00
austinpower1258 914373fef8 fix: close Codex resume review gaps 2026-08-13 23:13:45 -07:00
Abdulaziz Albahar 0cfa6daac2 test(push): cover late intent and timeout winner races 2026-08-13 23:11:01 -07:00
cmux reload-cloud 7a9cb46c64 fix(pi): reject unsafe diagnostic files 2026-08-13 23:06:55 -07:00
cmux reload-cloud 7da2028258 test(pi): reject symlinked diagnostic paths 2026-08-13 23:05:57 -07:00
austinpower1258 44bca39683 test: cover Codex review regressions 2026-08-13 23:05:44 -07:00
Austin Wang 96454b0fc1 Merge pull request #10140 from manaflow-ai/issue-10138-ssh-bootstrap-upload-timeout
Fix SSH daemon bootstrap on slow links and wedged masters
2026-08-13 23:03:23 -07:00
Abdulaziz Albahar 6e98cee5e1 fix(push): bound timeout recovery lanes 2026-08-13 23:02:12 -07:00
Abdulaziz Albahar 1f9c96da50 test(push): require bounded timeout recovery 2026-08-13 23:00:13 -07:00
Abdulaziz Albahar ea1d0ad48e Fix white tint for custom terminal icons (#10153)
* Fix white tint for custom terminal icons

* Avoid retaining terminal input view from icon tint

* Define active icon tint in accessory styling
2026-08-13 22:59:54 -07:00
austinpower1258 ef536b04af fix: avoid deprecated browser theme observer 2026-08-13 22:58:48 -07:00
cmux reload-cloud f12c7410e7 fix(pi): trust successful child exits 2026-08-13 22:50:56 -07:00
Abdulaziz Albahar faa340e657 fix(push): commit intents before bounded reconciliation 2026-08-13 22:50:16 -07:00
Abdulaziz Albahar ee553fb180 Keep iOS Simulator streams live in long sessions (#9886)
* Test simulator stream long-session recovery

* Keep simulator streams live under backpressure

* Test simulator replay recovery races

* Fix simulator recovery lifecycle races

* Fix simulator image task result inference

* Preserve simulator image preparation failures

* Test simulator selection survives pane unmount

* Keep simulator selection across pane remounts

* Test simulator stream navigation ownership

* Stop simulator streams when workspace route exits

* Scope simulator route visibility to shell

* Test simulator teardown resolves workspace row ID

* Resolve simulator teardown workspace identity

* Test simulator reader attachment retries

* Retry simulator reader attachment until ready

* Test simulator frame demand reconciliation

* Make simulator capture demand explicit

* Test framebuffer demand reconciliation

* Reannounce active simulator frame transport

* Test hidden simulator stream recovery

* Reconcile hidden simulator stream ownership
2026-08-13 22:49:39 -07:00
cmux reload-cloud 2ed3ed3123 test(pi): cover successful stdin pipe closure 2026-08-13 22:48:25 -07:00
cmux reload-cloud 2587929c69 test(pi): make FIFO diagnostics deterministic 2026-08-13 22:47:11 -07:00
Abdulaziz Albahar f3b831f81d test(push): require timed-out intent to reach service 2026-08-13 22:44:01 -07:00
austinpower1258 b8fa006c2c fix: require resolved sidebar appearance 2026-08-13 22:39:37 -07:00
austinpower1258 0a6a77feb6 Merge remote-tracking branch 'origin/main' into issue-9629-codex-resume-binding-verification 2026-08-13 22:38:27 -07:00
austinpower1258 a45252b833 fix: fail closed on unclassified Codex ownership 2026-08-13 22:37:10 -07:00
austinpower1258 22c6439147 test: pass exact Codex IDs to hook fixtures 2026-08-13 22:35:00 -07:00
Abdulaziz Albahar 4f0f2c7cb3 Revert "Revert "Fix iOS 27 keyboard re-open animation (#10006)" (#10151)" (#10152)
This reverts commit 897b4dfda3.
2026-08-13 22:30:20 -07:00
Abdulaziz Albahar 1130e64a88 refactor(push): keep intent lane state flat 2026-08-13 22:29:36 -07:00
cmux reload-cloud 02a928402a Merge remote-tracking branch 'origin/main' into issue-10128-pi-hook-timeout-telemetry 2026-08-13 22:27:20 -07:00
Abdulaziz Albahar 25ba6fd6a6 fix(push): retain bounded mutation lanes until completion 2026-08-13 22:27:09 -07:00
cmux reload-cloud 72e6ec57b0 fix(pi): avoid blocking on diagnostic pointers 2026-08-13 22:27:01 -07:00
cmux reload-cloud 6f634e5b8e test(pi): cover nonblocking diagnostic pointer reads 2026-08-13 22:24:30 -07:00
Abdulaziz Albahar c1458ba3d9 Refresh iOS artifact loaders after reconnect (#10121)
* test: cover artifact loader source replacement

* fix: refresh mobile artifact loaders after reconnect

* fix: isolate artifact caches across reconnects

* fix: guard canceled artifact thumbnail tasks
2026-08-13 22:24:02 -07:00
Austin Wang d22a611ab6 Merge pull request #10142 from manaflow-ai/issue-10129-tmux-two-client-cpu-spin
Mitigate redundant tmux PTY resize churn
2026-08-13 22:22:10 -07:00
austinpower1258 67e9cd25cd refactor: make resolved window chrome authoritative 2026-08-13 22:20:34 -07:00
Abdulaziz Albahar 897b4dfda3 Revert "Fix iOS 27 keyboard re-open animation (#10006)" (#10151)
This reverts commit e9fb69b4f3.
2026-08-13 22:19:16 -07:00
Abdulaziz Albahar 87c0033eb3 test(push): cover stalled intent and timeout worker ownership 2026-08-13 22:18:33 -07:00
austinpower1258 8517c1d5e0 test: cover Codex resume ownership gaps 2026-08-13 22:17:31 -07:00
austinpower1258 89338bf22d test: fix appearance matrix argument order 2026-08-13 22:07:56 -07:00
austinpower1258 1ebd0e7ab6 Merge remote-tracking branch 'origin/main' into issue-9629-codex-resume-binding-verification 2026-08-13 22:05:49 -07:00
austinpower1258 842d94b782 fix: close remaining Codex resume review gaps 2026-08-13 22:05:32 -07:00
Abdulaziz Albahar 75937e4297 fix(push): store boolean settings mutation task 2026-08-13 22:02:49 -07:00
Abdulaziz Albahar 8546b3dbd7 fix(push): reject unproven legacy cleanup credentials 2026-08-13 21:59:22 -07:00
Abdulaziz Albahar eb29f93ebb test(push): seed cleanup owner in queued sign-out 2026-08-13 21:58:22 -07:00
Abdulaziz Albahar c9721f902d Add workspace group selection to iOS task composer (#10123)
* 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
2026-08-13 21:56:57 -07:00
Abdulaziz Albahar a441e85c73 fix(push): require persisted owner for opt-out cleanup 2026-08-13 21:54:15 -07:00
austinpower1258 c21f052db1 fix: repair theme chrome compile errors 2026-08-13 21:53:30 -07:00
Abdulaziz Albahar 015f35393b test(push): fence opt-out cleanup to persisted owner 2026-08-13 21:52:18 -07:00
Abdulaziz Albahar 197fb04942 fix(push): fence direct registration commits and bound enables 2026-08-13 21:44:11 -07:00
austinpower1258 2557bb703b fix: resolve Dock startup chrome from theme authority 2026-08-13 21:40:25 -07:00
Abdulaziz Albahar 62a72a7f3c test(push): cover stale registration and public enable timeout 2026-08-13 21:37:27 -07:00
Abdulaziz Albahar f9a268144b Explain mobile connection methods in empty states
Merged after green required checks, tagged Mac rebuild, and focused iOS verification dispatch.
2026-08-13 21:35:17 -07:00
Abdulaziz Albahar 84bd0cc51f fix(push): persist sign-out cleanup before mutation gate 2026-08-13 21:24:48 -07:00
Abdulaziz Albahar f1582d5c35 test(push): preserve queued sign-out cleanup on cancellation 2026-08-13 21:21:56 -07:00
Abdulaziz Albahar e9fb69b4f3 Fix iOS 27 keyboard re-open animation (#10006)
* test(ios): cover stale keyboard completion race

* fix(ios): preserve keyboard rise animation

* fix(ios): harden keyboard transition ownership

* test(ios): cover delayed keyboard will race

* fix(ios): reject superseded keyboard will notifications

* fix(ios): bind keyboard transition legs to toggle intent

* ci(ios): build arm64 simulator artifacts

* fix(ios): fence scroll before verified replay reveal

* fix(ios): wait for in-flight scroll before replay reveal

* test(ios): require terminal edge to track keyboard dock

* fix(ios): pin terminal presentation to keyboard dock

* fix(ios): expose replay scroll fence across modules

* fix(ios): rebase dock before keyboard reversal

* fix(ios): rebase only interrupted keyboard motion

* fix(ios): bind toast settings toggle

* Fix iOS 27 keyboard toggle focus handoff
2026-08-13 21:20:26 -07:00
austinpower1258 e0336cc307 fix: wire sidebar model initializers and browser theme refresh 2026-08-13 21:18:42 -07:00
austinpower1258 27abb60c15 fix: resolve AppKit semantic colors under theme authority 2026-08-13 21:08:10 -07:00
Abdulaziz Albahar 7c6ef3c047 test(push): isolate mutation timeout generations 2026-08-13 21:05:23 -07:00
Abdulaziz Albahar 72d7ee68b4 fix(push): reconcile late authorization and cancel workers 2026-08-13 21:02:17 -07:00
Abdulaziz Albahar 81f03ee161 test(push): cover late prompt and worker cancellation 2026-08-13 21:02:08 -07:00
cmux reload-cloud 43a313271a fix(pi): claim stale sessions before logging 2026-08-13 21:01:52 -07:00
cmux reload-cloud 517e0965d1 test(pi): cover concurrent stale failure diagnostics 2026-08-13 21:00:48 -07:00
austinpower1258 8f6a9d4638 fix: unify dock chrome with resolved terminal theme 2026-08-13 20:57:04 -07:00
Abdulaziz Albahar 15f23ddb43 fix(push): permit retry after settings timeout 2026-08-13 20:38:25 -07:00
Abdulaziz Albahar fa2d641624 test(push): retry timed out enable intent 2026-08-13 20:37:04 -07:00
austinpower1258 83c1adc600 test: fix Codex resume regression compilation 2026-08-13 20:35:56 -07:00
Abdulaziz Albahar 86311258d8 chore(push): isolate timeout outcome type 2026-08-13 20:28:36 -07:00
Abdulaziz Albahar cb1914233c fix(push): bound settings mutation lifetime 2026-08-13 20:25:39 -07:00
Abdulaziz Albahar 59ded957b5 test(push): bound stalled settings mutation 2026-08-13 20:25:05 -07:00
Abdulaziz Albahar c2c06d7b2f test(push): fix cancellation regression setup 2026-08-13 20:14:14 -07:00
Abdulaziz Albahar a9d3eecc7a fix(push): recover cancelled registration attempts 2026-08-13 20:13:58 -07:00
Abdulaziz Albahar bf861abda8 test(push): recover cancelled queued registration 2026-08-13 20:12:17 -07:00
austinpower1258 93bd3af288 chore: keep Codex verifier within file budget 2026-08-13 20:06:59 -07:00
Abdulaziz Albahar be494245e2 fix(push): recheck cancellation at gate handoff 2026-08-13 20:05:37 -07:00
austinpower1258 36fd0e3e29 fix: close Codex resume binding review gaps 2026-08-13 20:03:15 -07:00
austinpower1258 01a6b04386 test: cover dock theme authority mismatch 2026-08-13 20:01:52 -07:00
Abdulaziz Albahar abe6ac92a5 fix(push): accept generation zero startup intent 2026-08-13 19:56:12 -07:00
Abdulaziz Albahar 18c558420d test(push): accept initial coordinator generation 2026-08-13 19:55:20 -07:00
Abdulaziz Albahar 886a28cdb2 fix(push): drop cancelled mutation waiters 2026-08-13 19:48:03 -07:00
Abdulaziz Albahar 845883cac1 test(push): cancel queued mutation waiter 2026-08-13 19:47:01 -07:00
cmux reload-cloud 9711c2bf7e fix(pi): await safe diagnostic appends 2026-08-13 19:46:00 -07:00
austinpower1258 7028786dfb fix: close sidebar git watcher review gaps 2026-08-13 19:43:34 -07:00
austinpower1258 76da0e05dc fix(ssh): make daemon bootstrap slow-link safe
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.
2026-08-13 19:42:54 -07:00
cmux reload-cloud fa452a4e2a fix(pi): make diagnostic writes nonblocking 2026-08-13 19:42:41 -07:00
cmux reload-cloud 3365e09ec1 test(pi): tolerate loaded hook harnesses 2026-08-13 19:42:21 -07:00
Abdulaziz Albahar e8a3df177b chore(push): isolate intent kind type 2026-08-13 19:40:53 -07:00
cmux reload-cloud 476eb388d8 test(pi): cover resilient diagnostic log writes 2026-08-13 19:34:59 -07:00
austinpower1258 beb8e01177 fix: make resize policy surface ownership explicit 2026-08-13 19:34:50 -07:00
Abdulaziz Albahar b93b7e06a4 fix(push): unify preference mutation ordering 2026-08-13 19:33:51 -07:00
austinpower1258 edb0008287 fix: address sidebar git watcher review 2026-08-13 19:33:13 -07:00
austinpower1258 9e3005ccff fix: coalesce redundant PTY grid resizes 2026-08-13 19:25:45 -07:00
austinpower1258 70f37e00c1 test: reproduce stable-grid tmux resize churn 2026-08-13 19:23:12 -07:00
Abdulaziz Albahar 6bdd586e0d fix(push): propagate denied enable generation 2026-08-13 19:16:59 -07:00
austinpower1258 a48abbd1f7 test(ssh): cover slow daemon upload recovery 2026-08-13 19:15:53 -07:00
Abdulaziz Albahar f5c966427e test(push): preserve denied reenable generation 2026-08-13 19:13:52 -07:00
Abdulaziz Albahar 9c94426350 fix(push): require explicit opt-out for startup cleanup 2026-08-13 19:07:45 -07:00
Abdulaziz Albahar 66b9763e37 test(push): preserve absent preference on startup 2026-08-13 19:06:07 -07:00
austinpower1258 f57a5fe5ce fix: preserve bounded large-repo git events 2026-08-13 19:05:31 -07:00
Abdulaziz Albahar 5f2c42b6d3 fix(push): let intent drains outlive caller cancellation 2026-08-13 18:58:49 -07:00
Abdulaziz Albahar 15270dcd88 test(push): preserve enable after caller cancellation 2026-08-13 18:56:39 -07:00
Abdulaziz Albahar cdffcba67e fix(push): submit every current enable generation 2026-08-13 18:48:07 -07:00
Abdulaziz Albahar d5818e9124 test(push): cover reenable generation during cleanup 2026-08-13 18:46:15 -07:00
cmux reload-cloud 3668dd2415 fix(pi): harden hook failure diagnostics 2026-08-13 18:38:00 -07:00
austinpower1258 4ae94b0ecf fix: bound sidebar git status watching 2026-08-13 18:36:54 -07:00
Abdulaziz Albahar 0e9a3fa33e fix(push): fence in-flight cleanup and stale snapshots 2026-08-13 18:33:20 -07:00
Abdulaziz Albahar 7c7f86f967 test(push): cover startup ownership and stale snapshots 2026-08-13 18:29:46 -07:00
cmux reload-cloud 917e18ee07 fix(pi): diagnose configurable hook timeouts 2026-08-13 18:21:32 -07:00
Abdulaziz Albahar 87da95bb3a refactor(push): align service package conventions 2026-08-13 18:20:48 -07:00
austinpower1258 478f8739e9 test: cover bounded sidebar git watching 2026-08-13 18:17:47 -07:00
Abdulaziz Albahar 9177de8354 fix(push): atomically commit coordinator intent 2026-08-13 18:11:59 -07:00
Abdulaziz Albahar 0b2802b5e4 test(push): persist opt-out before cleanup 2026-08-13 18:11:23 -07:00
cmux reload-cloud 6880530009 test(pi): cover hook timeout diagnostics 2026-08-13 18:10:01 -07:00
Abdulaziz Albahar ebcdf1ca61 fix(push): recover interrupted opt-out cleanup 2026-08-13 18:03:13 -07:00
Abdulaziz Albahar 88b75b085b test(push): recover disabled startup cleanup 2026-08-13 18:02:01 -07:00
austinpower1258 f95c3e317e Strengthen tree layout handle assertions 2026-08-13 17:58:52 -07:00
austinpower1258 245d272486 Request IDs for Dock tree regression 2026-08-13 17:54:19 -07:00
Abdulaziz Albahar d86f774bf5 fix(push): coalesce pending notification intents 2026-08-13 17:52:26 -07:00
austinpower1258 cc01478ea8 Make Dock regression cleanup authoritative 2026-08-13 17:51:28 -07:00
Austin Wang 1329f5a187 Merge pull request #10117 from sjiang647/claude-attach-passthrough
Include `claude attach` in `claude_builtin_command_name`
2026-08-13 17:41:40 -07:00
Abdulaziz Albahar 4379d55394 fix(push): coalesce repeated registration intents 2026-08-13 17:41:30 -07:00
Abdulaziz Albahar 9f86b86b51 test(push): deduplicate same-generation activation 2026-08-13 17:40:16 -07:00
austinpower1258 ba04a229e5 Cover dock and nested layout invariants 2026-08-13 17:36:00 -07:00
Abdulaziz Albahar 63007514af fix(push): make latest notification intent authoritative 2026-08-13 17:32:24 -07:00
Abdulaziz Albahar 8bde795686 test(push): cover latest intent ordering 2026-08-13 17:32:09 -07:00
austinpower1258 66a3f54d69 Fix tree layout handling for docks and missing refs 2026-08-13 17:21:12 -07:00
austinpower1258 52a05e561d Merge origin/main into feat-system-tree-layout-field 2026-08-13 17:12:57 -07:00
Abdulaziz Albahar 90a97c1921 fix(push): serialize notification intent mutations 2026-08-13 17:05:23 -07:00
Abdulaziz Albahar 4a5301de45 test(push): cover ordered opt-out intents 2026-08-13 17:05:18 -07:00
Abdulaziz Albahar 09868c8c42 test(push): require serialized opt-out mutation 2026-08-13 16:46:53 -07:00
Abdulaziz Albahar 5bc3c10d76 fix(ios): preempt stale push registration work 2026-08-13 16:36:49 -07:00
Abdulaziz Albahar e679e16b52 fix(ios): own push preference mutation in coordinator 2026-08-13 16:12:57 -07:00
chatmux-connections[bot]andchatmux-connections[bot] <chatmux-connections[bot]@users.noreply.github.com> fb2a058a5c fix(coderouter): use npx coderouter@latest add <tool> and show command text (#10108)
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>
2026-08-13 15:51:02 -07:00
Abdulaziz Albahar 57bef7d12b fix(ios): serialize push retries after reconciliation 2026-08-13 15:49:15 -07:00
Abdulaziz Albahar 4492cf1eb3 fix(ios): serialize timed out push mutations 2026-08-13 15:37:38 -07:00
Abdulaziz Albahar 5f4c064122 fix(ios): bound push readiness reconciliation 2026-08-13 15:29:03 -07:00
Abdulaziz Albahar 7f4f7d3526 Merge remote-tracking branch 'origin/main' into fix-ios-push-toggle-off
# Conflicts:
#	Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift
2026-08-13 15:22:58 -07:00
Abdulaziz Albahar 6b698958e7 fix(ios): surface uncertain push toggle outcomes 2026-08-13 15:22:01 -07:00
Abdulaziz Albahar a162e51119 refactor(ios): isolate push preview mutation gate 2026-08-13 15:09:33 -07:00
Abdulaziz Albahar 653a43d98a fix(ios): bound push toggle mutations 2026-08-13 15:04:00 -07:00
Abdulaziz Albahar 12cf0c540b fix(ios): tie push toggle mutation to view lifecycle 2026-08-13 14:55:45 -07:00
Austin Wang 57476721af Merge pull request #10101 from manaflow-ai/issue-10060-mosh-bootstrap-misreport
Fix mosh bootstrap staging: real argv for mosh-server, stage-specific errors, proxy address fallback
2026-08-13 14:42:51 -07:00
austinpower1258andClaude Fable 5 84f676a422 chore: address PR review feedback
- 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]>
2026-08-13 14:41:14 -07:00
Abdulaziz Albahar 63ed21a31a fix(ios): initialize task composer setting 2026-08-13 13:25:11 -07:00
Abdulaziz Albahar 207491f365 fix(ios): remove retired toast settings toggle 2026-08-13 13:11:05 -07:00
Abdulaziz Albahar 952586ecc5 fix(ios): restore toast settings binding 2026-08-13 13:08:01 -07:00
Abdulaziz Albahar 4e4df0382f Merge pull request #10119 from manaflow-ai/feat-iroh-product-final
Complete Iroh diagnostics and connection recovery
2026-08-13 12:17:43 -07:00
Abdulaziz Albahar 99d1ea7502 merge: add bounded Iroh dial diagnostics 2026-08-13 12:12:30 -07:00
Abdulaziz Albahar 57ec73a950 fix(ios): keep live Iroh sessions healthy during probe timeouts 2026-08-13 12:10:58 -07:00
Abdulaziz Albahar b4ca1a9e82 fix(ios): complete diagnostic event presentation 2026-08-13 12:01:05 -07:00
Abdulaziz Albahar a0d3af8fc0 merge: port actionable Iroh recovery diagnostics onto main 2026-08-13 11:59:16 -07:00
Abdulaziz Albahar 2f5ba2d7d7 Merge pull request #9775 from manaflow-ai/task-ios-admission-revalidation
Preserve admitted Iroh sessions across auth refresh blips
2026-08-13 11:56:32 -07:00
Abdulaziz Albahar 7e00b41dfb Merge pull request #10113 from manaflow-ai/incident-relay-token
fix(relay): contain Stack Auth throttles before relay minting
2026-08-13 11:53:12 -07:00
Abdulaziz Albahar 26c94df813 Merge pull request #10094 from manaflow-ai/fix-iroh-compat-warning
Fix false compatibility warning for Iroh pairing
2026-08-13 11:53:09 -07:00
Abdulaziz Albahar 815ca6d626 Merge pull request #10004 from manaflow-ai/verify-iroh-aug11
Fix Iroh release settings and Tailscale skew gate
2026-08-13 11:48:33 -07:00
Abdulaziz Albahar be076d30dd fix(iroh): rotate production gate Mac credentials 2026-08-13 11:24:32 -07:00
Abdulaziz Albahar 856cb762a0 test(iroh): rotate production Mac credentials 2026-08-13 11:24:10 -07:00
Lawrence Chen c89290f808 Add CodeRouter CLI compatibility metadata (#10116)
* Add CodeRouter CLI compatibility metadata

* Cover legacy CLI config fields
2026-08-13 10:52:00 -07:00
Abdulaziz Albahar dcdf865948 fix(iroh): keep release gate simulator-only 2026-08-13 10:42:23 -07:00
Abdulaziz Albahar 3dd9bac553 test(iroh): isolate release gate from default iPhone 2026-08-13 10:42:04 -07:00
Sjiang647andClaude Fable 5 815724872a Pass the hidden claude attach subcommand through without hook injection
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]>
2026-08-13 10:36:23 -07:00
Sjiang647andClaude Fable 5 0ed5925de1 Add failing regression test: hidden claude attach subcommand gets hook injection
`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]>
2026-08-13 10:36:23 -07:00
Lawrence Chen 3ee68badea Add cmux CodeRouter CLI passthrough aliases (#10109)
* Add cmux CodeRouter CLI passthrough aliases

* Harden CodeRouter CLI dispatch

* Preserve CodeRouter launch diagnostics

* Use bundled CLI localization for aliases

* Verify localized CodeRouter alias output
2026-08-13 09:57:04 -07:00
Abdulaziz Albahar 4b4d479b14 fix(ios): preserve ticket-authenticated attach startup 2026-08-13 09:45:42 -07:00
Abdulaziz Albahar d2d527a54e test(ios): make push toggle timing assertion deterministic 2026-08-13 09:44:13 -07:00
Abdulaziz Albahar 46edb3c92c fix(ios): give startup one scoped connection owner 2026-08-13 09:39:47 -07:00
Abdulaziz Albahar af722e3427 test(ios): keep account scope from owning startup dial 2026-08-13 09:38:00 -07:00
Abdulaziz Albahar 12bbdf210b fix(relay): bound auth error classification 2026-08-13 09:23:01 -07:00
Abdulaziz Albahar 814d3e165f fix(relay): contain auth throttles and gate ingress 2026-08-13 09:08:53 -07:00
Abdulaziz Albahar e7c9a6a7b4 fix(ios): update push toggle optimistically 2026-08-13 09:08:27 -07:00
Abdulaziz Albahar b4625c423e test(ios): cover pending push opt-out 2026-08-13 09:08:19 -07:00
Abdulaziz Albahar f8f24726c8 fix(ios): wait for account scope before reconnect 2026-08-13 09:06:38 -07:00
Abdulaziz Albahar 534acb2ff9 test(ios): gate reconnect on complete auth bootstrap 2026-08-13 09:06:00 -07:00
Abdulaziz Albahar d1ab6098a8 test(relay): cover Stack Auth throttle response 2026-08-13 09:03:15 -07:00
Abdulaziz Albahar 55e89ecffd fix(ios): retain startup attach across root rebuilds 2026-08-13 08:24:32 -07:00
Abdulaziz Albahar c942dc15e6 test(ios): keep startup attach across root rebuilds 2026-08-13 08:23:31 -07:00
Abdulaziz Albahar ef219f430c fix(ios): allow relay-scale pairing deadline 2026-08-13 07:52:15 -07:00
Abdulaziz Albahar 2001978fc1 test(ios): cover relay-scale pairing deadline 2026-08-13 07:51:31 -07:00
Abdulaziz Albahar 10bb1d84bf fix(cli): restore macOS 14 compiler support 2026-08-13 07:26:11 -07:00
Abdulaziz Albahar 2ca5588c4f fix(iroh): keep control session authoritative 2026-08-13 06:40:08 -07:00
Abdulaziz Albahar 2eb729055a fix(iroh): tolerate transient path transitions 2026-08-13 06:05:13 -07:00
Abdulaziz Albahar ee2f58670f test(iroh): preserve sessions across transient path loss 2026-08-13 06:04:09 -07:00
Abdulaziz Albahar 37854b926d ci(ios): recognize Swift Testing success 2026-08-13 06:00:09 -07:00
Abdulaziz Albahar a092a26701 ci(ios): keep focused dispatches focused 2026-08-13 05:56:07 -07:00
Abdulaziz Albahar dbebb16053 ci(iroh): run compatibility and transport gates concurrently 2026-08-13 05:52:30 -07:00
Abdulaziz Albahar 81476dcb56 ci(iroh): preserve host gate diagnostics 2026-08-13 05:35:52 -07:00
Abdulaziz Albahar c1d21c83c0 test(ios): construct diagnostic event explicitly 2026-08-13 05:35:51 -07:00
Abdulaziz Albahar 44793dabb2 test(ios): import NSNumber support 2026-08-13 05:21:42 -07:00
Abdulaziz Albahar 37a31806d1 test(ios): use scoped viewport generation API 2026-08-13 05:11:46 -07:00
Abdulaziz Albahar 2355a0164c refactor(iroh): scope relay origins to URL sequences 2026-08-13 05:08:48 -07:00
Abdulaziz Albahar 0ef91393a7 ci(iroh): track expanded compatibility suite 2026-08-13 04:54:47 -07:00
Abdulaziz Albahar 39fc67ab6d test(iroh): report release-gate failure stage 2026-08-13 04:51:34 -07:00
Abdulaziz Albahar f971bd0573 ci(iroh): serialize shared-account transport gates 2026-08-13 04:40:13 -07:00
Abdulaziz Albahar 3d2b359a64 test(iroh): scope framed RPC admission fixture 2026-08-13 04:26:42 -07:00
Abdulaziz Albahar 06727d662e test(iroh): classify RPC inventory failures 2026-08-13 04:10:07 -07:00
chatmux-connections[bot]andchatmux-connections[bot] <chatmux-connections[bot]@users.noreply.github.com> d0b0fb22a6 feat(dashboard): iOS sidebar section + coderouter add commands (#10107)
- 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>
2026-08-13 03:26:23 -07:00
Abdulaziz Albahar dc0b0d8b54 Initialize mobile task composer setting 2026-08-13 03:15:17 -07:00
Abdulaziz Albahar 992ab0698e Remove stale mobile toasts setting 2026-08-13 03:12:46 -07:00
Abdulaziz Albahar ffb91863c9 Fix toast binding in mobile settings 2026-08-13 03:09:46 -07:00
Abdulaziz Albahar b1ec086f1a ci(iroh): cap release gate build parallelism 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 32905873b1 ci(iroh): reap cancelled hosted builds 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 6601f297c6 ci(iroh): bound hosted build logs 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 489108796d ci(iroh): reject stale gate verdicts 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 1760e5211c fix(ios): import release-gate RPC owner 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar f5f584d4d6 fix(iroh): keep transport overrides in debug UI 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 737fa823cd fix(ios): scope Iroh gate RPC inventory 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar ec9696da01 fix(iroh): preserve release-gate readiness phase 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 657b6ff0ba test(iroh): classify readiness deadline failures 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 08d2567f57 fix(iroh): let release gate own readiness 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 77e890e0f8 fix(ios): await auth bootstrap before startup attach 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar c084c0c2cd ci(iroh): keep optimized gate builds observable 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 10773bc6d0 ci(iroh): allow cold session readiness 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 3194b50384 fix(iroh): normalize retired relay-only preference 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar ba34381558 test(iroh): verify full mobile RPC inventory 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 75c935e81c ci(iroh): stabilize optimized gate builds 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 858dd89026 fix(iroh): retire release relay-only state 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 3e322d3661 test(iroh): reject retired release relay-only state 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar a2943d692d fix(iroh): keep relay-only in debug builds 2026-08-13 02:40:18 -07:00
Abdulaziz Albahar 8e03abb930 Restore Xcode 16 simulator package compatibility (#10009)
* fix(simulator): require sendable mutation results

* fix(simulator): use macOS 14 spawn chdir API

* fix(simulator): support Xcode 16 button labels

* ci(macos): use available Sonoma Intel runner

* ci(macos): split Sonoma and Intel compatibility

* fix(compat): support Xcode 16 localized controls

* fix(compat): localize empty simulator state on Xcode 16

* test(compat): avoid nested actor compiler crash

* fix(compat): support localized controls on Xcode 16

* fix(compat): localize inspector highlight control

* fix(compat): support Swift 6.0 declarations

* Fix Xcode 16 timer destruction isolation

* Allow Intel Simulator cold boots to finish

* Fix Xcode 16 cancellation timer isolation

* Restore Xcode 16 type isolation syntax

* ci(macos): allow compatibility matrix to finish

* ci: run focused Iroh checks on every compatibility host

* ci: serialize Intel Iroh simulator tests

* ci: serialize Iroh simulator transport tests

* test: await diagnostic tap delivery

* fix(diagnostics): preserve relay-free bootstrap semantics

* fix(compat): support diagnostic correlation on Swift 6.0

* fix(compat): support app sources on Swift 6.0

* test(iroh): await admission before capacity assertion
2026-08-13 02:40:06 -07:00
chatmux-connections[bot]andpi 0d76b35154 web(dashboard): add iOS app card and drop the Stack team picker (#10099)
- 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]>
2026-08-13 02:04:54 -07:00
austinpower1258andClaude Fable 5 b7f128b015 fix: pass staged bootstrap to mosh-server as real argv and prefer proxy fallback
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]>
2026-08-13 01:20:17 -07:00
austinpower1258andClaude Fable 5 d4b95fe202 test: prove staged bootstrap must survive mosh-server execvp
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]>
2026-08-13 01:16:38 -07:00
Austin Wang db743dc329 Merge pull request #10059 from manaflow-ai/issue-10054-ambient-cli-socket-fallback
Recover ambient CLI from stale dev socket
2026-08-13 01:07:37 -07:00
austinpower1258 4f10c3b525 fix: verify Codex resume binding ownership 2026-08-13 00:56:09 -07:00
austinpower1258 802ede81f2 test: cover Codex resume binding provenance 2026-08-13 00:53:31 -07:00
austinpower1258 06f231373a fix: keep Stack handler request-bound 2026-08-13 00:51:49 -07:00
Abdulaziz Albahar f8fc57e316 Initialize mobile task composer preference 2026-08-12 23:57:59 -07:00
Abdulaziz Albahar 814bd0e06f Keep disabled toast policy buildable on iOS 2026-08-12 23:54:13 -07:00
Abdulaziz Albahar 1ee4f0925f fix(ios): normalize route analyzer evidence 2026-08-12 23:44:48 -07:00
Abdulaziz Albahar 7a0123e786 test(ios): cover analyzer route variants 2026-08-12 23:44:31 -07:00
Abdulaziz Albahar 977c91303e Preserve canonical bootstrap diagnostic names 2026-08-12 23:40:51 -07:00
Abdulaziz Albahar 0363a6303b Fix close attribution diagnostic assertion 2026-08-12 23:36:17 -07:00
austinpower1258 9cc2485771 fix: restore web typecheck after base sync 2026-08-12 23:32:16 -07:00
austinpower1258 64a45bdf8d Merge remote-tracking branch 'origin/main' into issue-10054-ambient-cli-socket-fallback 2026-08-12 23:25:31 -07:00
Abdulaziz Albahar 5c7457deb2 Merge remote-tracking branch 'origin/main' into task-ios-connection-reliability
# Conflicts:
#	Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventCode.swift
#	Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift
#	Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohClientSession.swift
#	Packages/Shared/CmuxIrohTransport/Sources/CmuxIrohTransport/CmxIrohRegistryContextProvider.swift
2026-08-12 23:22:39 -07:00
Abdulaziz Albahar f7cb0fe057 fix(ios): bound reconnect dials and expose route failures 2026-08-12 23:13:31 -07:00
Abdulaziz Albahar 0d796790e1 test(ios): cover reconnect route diagnostics 2026-08-12 23:13:18 -07:00
austinpower1258 105f4f2b53 Merge remote-tracking branch 'origin/main' into issue-10054-ambient-cli-socket-fallback
# Conflicts:
#	CLI/cmux.swift
#	cmuxTests/TerminalAndGhosttyTests.swift
2026-08-12 23:11:39 -07:00
Abdulaziz Albahar db58d79ca6 Promote iOS terminal Files chip (#10083)
* Promote iOS terminal Files chip

* Fix terminal Files flag access

* Keep the canonical iOS artifact chip kill switch

* Own mobile feature flag refresh lifecycle
2026-08-12 23:03:47 -07:00
Lawrence Chen 22846c0b0d Make CodeRouter account setup CLI-first (#10095)
* Render CLI auth confirmation dynamically

* Use CLI-first CodeRouter account setup
2026-08-12 23:00:10 -07:00
Austin Wang e9887cf373 Merge pull request #10089 from manaflow-ai/issue-10085-sidebar-leading-edge-clip
Fix sidebar leading-edge clipping after resize
2026-08-12 22:57:56 -07:00
Austin Wang 01b142add4 Merge pull request #10058 from manaflow-ai/issue-10052-restore-socket-rebind
Fix stale control-socket rebind and restore startup race
2026-08-12 22:57:42 -07:00
austinpower1258 a70386fd88 fix: quote staged bootstrap launcher for login shells 2026-08-12 22:50:38 -07:00
austinpower1258 d688b760c9 fix: force POSIX shell for staged restore launch 2026-08-12 22:48:54 -07:00
austinpower1258 31c445e98f refactor: simplify socket connect errors 2026-08-12 22:44:20 -07:00
Lawrence Chen bec630020e Render CLI auth confirmation dynamically (#10093) 2026-08-12 22:41:48 -07:00
Austin Wang 10bf707504 Merge pull request #10074 from manaflow-ai/issue-10051-intel-wake-sigbus
Fix native sidebar row ownership during layout
2026-08-12 22:40:05 -07:00
Abdulaziz Albahar 9e3173e90d Release mobile terminal viewport when iOS deactivates
Release the Mac terminal viewport lease when the iOS scene deactivates, clear stale local viewport metadata, and restore fresh geometry on reactivation.
2026-08-12 22:36:56 -07:00
austinpower1258 59357fce29 test: remove real-time attachment staging waits 2026-08-12 22:32:33 -07:00
austinpower1258 76a0d5a6e6 fix: retry transient relay startup connects 2026-08-12 22:19:06 -07:00
Abdulaziz Albahar 98ea04d735 iOS: permanently disable mobile toasts (#10087)
* test(ios): keep legacy toast preference disabled

* fix(ios): permanently disable mobile toasts

* docs(ios): document toast presenter policy
2026-08-12 22:18:54 -07:00
austinpower1258 f97c3c3282 test: cover delayed relay restore startup 2026-08-12 22:14:23 -07:00
Abdulaziz Albahar 4c05363d69 Add relay-free bootstrap diagnostics and Never Use Relays
Merged with explicit user authorization.
2026-08-12 22:12:49 -07:00
Abdulaziz Albahar 5a2f8b88c2 Fix mobile task model discovery
Discover mobile task models from provider-owned local catalogs and gate Task Composer through the remote feature flag.
2026-08-12 22:09:52 -07:00
Abdulaziz Albahar 3b3e417149 CLAUDE.md: run verification on fleet by default
Document fleet-first verification defaults and local fallback guidance.
2026-08-12 22:03:53 -07:00
Abdulaziz Albahar d907a2d087 Fix dev auth precedence and env scrubbing (#10091) 2026-08-12 22:01:45 -07:00
austinpower1258 dd3c86a4dc Fix native sidebar row ownership during layout 2026-08-12 21:52:38 -07:00
austinpower1258 f7807bb655 test: cover native sidebar rows across resize 2026-08-12 21:52:38 -07:00
Abdulaziz Albahar 08246ae369 fix(ios): skip compatibility warning for endpoint-only Iroh QR 2026-08-12 21:47:13 -07:00
austinpower1258 b840281212 fix: bound socket wakeups and reject stale restore metadata 2026-08-12 21:46:40 -07:00
Abdulaziz Albahar 22cf4112fc test(ios): preserve unspecified Iroh QR compatibility 2026-08-12 21:46:23 -07:00
austinywang a581b1b9c8 test: wait for current sidebar geometry 2026-08-12 21:44:00 -07:00
Abdulaziz Albahar c25b64cadd Merge remote-tracking branch 'origin/main' into task-ios-connection-reliability 2026-08-12 21:41:58 -07:00
Lawrence Chen 09efbcd1b8 Add comprehensive privacy-safe CodeRouter analytics (#10088)
* Add privacy-safe CodeRouter analytics coverage

* Document fixed analytics endpoint contract

* Harden analytics ingestion and coverage
2026-08-12 21:35:42 -07:00
austinywang b0459f73a3 chore: normalize test project wiring 2026-08-12 21:34:10 -07:00
Abdulaziz Albahar c231a72d49 test(ios): fix throwing transport diagnostic double 2026-08-12 21:31:34 -07:00
austinywang 4bbc803c33 fix: keep sidebar overflow anchored to the leading edge 2026-08-12 21:29:07 -07:00
austinywang 30c6803f1d test: cover sidebar leading-edge anchor during resize 2026-08-12 21:27:40 -07:00
Abdulaziz Albahar 015459b42e Merge remote-tracking branch 'origin/main' into task-ios-connection-reliability 2026-08-12 21:20:08 -07:00
Abdulaziz Albahar 8d4810326c Retain iOS app and network log history (#10082)
* test(ios): cover retained log generations

* fix(ios): retain app and network log history

* test(ios): order retained logs by generation stamp

* fix(ios): harden retained log discovery
2026-08-12 21:15:00 -07:00
Abdulaziz Albahar e85329c990 fix(ios): complete connection diagnostics integration 2026-08-12 21:12:35 -07:00
austinpower1258 b980f3f207 fix: clear pre-existing sidebar Swift warning 2026-08-12 21:06:36 -07:00
austinpower1258 3586c7dedb fix: narrow restore handoff and stabilize focus setup 2026-08-12 20:55:32 -07:00
Abdulaziz Albahar 99f0e20080 fix(ios): refresh stale Iroh discovery before redial 2026-08-12 20:50:45 -07:00
Abdulaziz Albahar 01b070d7ac feat(ios): make transport recovery traces actionable 2026-08-12 20:50:35 -07:00
Abdulaziz Albahar eeff5ceb90 Recover unverified email-code sign-in on iOS (#10029)
* test(ios): expose password-account sign-in gap

* fix(ios): sign in existing password accounts

* test(ios): isolate sign-in fixture from onboarding

* fix(ios): tighten password sign-in metadata

* test: reject password-only email recovery

* fix: recover unverified email sign-in

* fix: keep IPv6 verification callbacks local

* fix: bound stack email verification rendering

* fix: harden email verification recovery
2026-08-12 20:38:07 -07:00
Abdulaziz Albahar 95eb30963f feat(ios): add privacy-safe Iroh bootstrap diagnostics 2026-08-12 20:25:56 -07:00
Abdulaziz Albahar d55eed16ac test(ios): cover explicit startup recovery replacements 2026-08-12 20:24:39 -07:00
Abdulaziz Albahar 812f7592f5 fix(ios): coalesce recovery during startup dial 2026-08-12 20:24:39 -07:00
Abdulaziz Albahar 0f8505b5cf test(ios): cover presence during startup reconnect 2026-08-12 20:24:38 -07:00
austinpower1258 aae3632aa0 test: cover address probe edge cases 2026-08-12 20:18:24 -07:00
austinpower1258 cf4ffee5fc fix: preserve newer restore binding data 2026-08-12 19:57:07 -07:00
Abdulaziz Albahar 9a63ba837b fix(ios): import shell status in release UI (#10067) 2026-08-12 19:55:32 -07:00
austinpower1258 d09bfdfdc7 test: wire socket resolver into unit target 2026-08-12 19:42:49 -07:00
Abdulaziz Albahar ccbf7e5853 fix(ios): keep release diagnostics symbols available (#10065) 2026-08-12 19:30:22 -07:00
austinpower1258 710c6246f0 fix: align reload markers and repair vault test compile 2026-08-12 19:21:44 -07:00
Abdulaziz Albahar b71e48880b Fix iOS artifact failure messaging (#9961)
* test: reject false artifact reachability errors

* fix: preserve artifact failure meaning

* docs: clarify artifact load failure contract

* test: reveal missing chat attachment before tapping

* fix: preserve attachment button semantics

* fix: keep native attachment button trait

* test: keep rich fixture lookup role agnostic

* test: tap attachment by visible identity

* fix: preserve loader across artifact navigation

* test: expose lossy artifact failure handling

* fix: preserve artifact fetch failure reasons

* fix: scope artifact storage classification

* fix: return terminal artifact failure view

* test: harden artifact failure UI coverage

* feat: add mobile artifact failure scenario picker

* docs: clarify artifact load failure
2026-08-12 19:15:31 -07:00
Abdulaziz AlbaharandLawrence Chen 83beac0f39 Restore canonical iOS task composer pills (#9892)
* Add failing canonical task composer UI regression

* Ship canonical iOS task composer controls

* Fix task options duplicate assertions

* Restore task options model import

* Centralize task composer prompt lookup

* Add failing accessibility pill row regression

* Keep task composer edge controls visible

* Make composer navigation assertion title agnostic

* test(ios): harden task composer UI coverage

* test(ios): wait for composer submission state

* test(ios): cover populated composer edge controls

* test(ios): enable connected task composer coverage

* Test task composer attachment capability handshake

* Test task composer size after pairing

* ci: build arm64 iOS reload simulator app

* Persist paired Mac state across UI-test relaunch

* Reuse manual-pair fixture across UI-test relaunch

* Key relaunch fixture by full loopback port

* Test iPad composer keyboard attachment

* Attach iPad task composer to keyboard

* Isolate manual pairing fixtures across runs

* Pin iPad composer to keyboard guide

* Open manual pairing in composer fixture

* Keep task composer mounted across size changes

* Make composer edge fixture deterministic

* Log task composer dock geometry in debug builds

* Make task composer geometry logging passive

* Measure task composer dock through accessibility

* Expose composer bar as an accessibility container

* Expose task composer dock geometry to UI tests

* Test iPad composer against keyboard assistant strip

* Test dynamic task model catalogs

* Discover task models dynamically

* Export model catalog transport types

* Verify model catalog source priority

* Make backend catalog parsing explicit

* Render refreshed task models immediately

* Restore task model CPU icon

* Add rejected model catalog regressions

* Validate backend model catalogs before serving

* Use connected composer fixture for model discovery test

* Enable attach tickets in model discovery fixture

* Stabilize host model discovery fixture

* Wait for visible host model in UI test

* Wait for discovered model menu state

* Preserve restored model draft identity

* test(ios): cover staged attachment previews

* feat(ios): preview staged task attachments

* fix(ios): refresh models when host capability arrives

* test(ios): trace task model host request

* test(ios): probe host models with stale capability hint

* fix(ios): probe host model catalog directly

* test(ios): trace model discovery routing

* test(ios): cover slow host model discovery

* fix(ios): stabilize dynamic model discovery

* test(ios): keep model refresh owner across Mac switch

* fix(ios): preserve model refresh across Mac switch

* fix(ios): retry model discovery after connection settles

* fix(ios): decouple model probes from connection retries

* debug(ios): trace task model routing

* test(ios): cover read-only model connection routing

* fix(ios): route model reads through live connections

* fix(ios): retry model discovery when connection appears

* debug(ios): expose task model refresh state

* test(ios): keep mock host event probe alive

* fix(ios): preserve presented model selections

* chore(ios): remove task model diagnostics

* test(ios): scope mock event subscriptions by client

* test(ios): preserve connected launch environment

* test(mac): reject hidden Codex cache models

* fix(mac): honor Codex model visibility

* test(ios): wait for backend model submit readiness

* test(ios): settle notification prompt before composer

* Fix task composer UI test permission setup

* Settle notification prompt before workspace checks

* Expand attachment removal hit targets

* Tolerate subpixel attachment hit frames

* test(ios): close task composer evidence gaps

* test(ios): await model request before unwrap

* test(ios): make composer evidence deterministic

* fix(ios): retain selected model across refresh

* fix(macOS): tolerate nil AppKit subview hooks

* test(iOS): verify model snapshot after menu dismissal

* test(iOS): await model pill repaint

* test(iOS): assert visible snapshot selection

* test(iOS): reacquire presented model item

* test(iOS): gate one presented model menu

* fix(iOS): snapshot model menu presentation

* test(iOS): require compact leading task controls

* fix(iOS): tighten task utility spacing

* test(iOS): require clearer attachment previews

* fix(iOS): clarify task attachment previews

* test(iOS): cover OpenCode cold discovery and seed deletion

* fix(iOS): load OpenCode models and protect task seeds

* test(iOS): exercise Shell template protection

* fix(iOS): neutralize task template row tint

* test(iOS): reproduce deleted built-in migration

* fix(iOS): reconcile built-in task template ownership

* chore: remove unrelated portal hook change

* Fix model selection diagnostic correlation after main merge

* Fix display settings initialization after main merge

* Fix tagged mobile discovery peer matching

* test(ios): reject model pill submit overlap

* fix(ios): compress composer pills within viewport

---------

Co-authored-by: Lawrence Chen <[email protected]>
2026-08-12 19:00:00 -07:00
Abdulaziz Albahar 1de40b8870 fix(ios): preserve Tailscale migration setup flow 2026-08-12 18:48:56 -07:00
austinpower1258 c0afdaaa21 fix: bound reload probes and preserve offline CLI commands 2026-08-12 18:48:28 -07:00
austinpower1258 aea1eddb67 test: cover reload liveness and offline themes 2026-08-12 18:42:19 -07:00
austinpower1258 2e61187f41 fix: make mosh bootstrap shell-safe and report stages 2026-08-12 18:21:00 -07:00
Abdulaziz Albahar 3166828491 fix(ios): make Tailscale setup banner dismissible
* test(ios): cover dismissing Tailscale setup banner

* fix(ios): make Tailscale setup banner dismissible

* fix(ios): order disconnected shell inputs

* refactor(ios): share Tailscale banner dismissal

* fix(ios): keep Tailscale recovery action available

* fix(ios): keep tailscale setup banner available after migration

* fix(ios): defer tailscale pairing until authorization resolves
2026-08-12 18:10:30 -07:00
austinpower1258 dc7916c507 fix: serialize ambient CLI pointer ownership 2026-08-12 18:07:45 -07:00
austinpower1258 1f145ce9ed fix: keep socket fallback instance-safe 2026-08-12 18:01:49 -07:00
austinpower1258 40885d8fb0 test: cover mosh bootstrap stage and address fallback 2026-08-12 17:51:53 -07:00
austinpower1258 2292391d61 fix: expose socket cleanup callback across source files 2026-08-12 17:47:52 -07:00
austinpower1258 b4b7a761cc fix: harden socket discovery ownership and reload fallback 2026-08-12 17:35:19 -07:00
austinpower1258 853aceeb79 fix: preserve socket retries without kqueue 2026-08-12 17:14:51 -07:00
austinpower1258 9b6008e533 refactor: centralize control socket startup waiting 2026-08-12 17:03:07 -07:00
austinpower1258 c0cbe56f27 fix: keep socket cleanup warning-free 2026-08-12 16:49:25 -07:00
austinpower1258 7cae83007c fix: recover ambient CLI from stale dev socket 2026-08-12 16:34:02 -07:00
austinpower1258 13ce7a24f3 fix: qualify CLI descriptor closes 2026-08-12 16:09:23 -07:00
austinpower1258 9dadda2243 fix: align baseline test and SwiftUI APIs 2026-08-12 15:55:15 -07:00
austinpower1258 8d140de85a fix: harden restore socket readiness 2026-08-12 15:55:09 -07:00
Abdulaziz Albahar a028ba24e1 Fix multi-Mac connection lifecycle
Merge authorized by the user. Focused Swift package tests passed on the merged tree.
2026-08-12 15:49:03 -07:00
Abdulaziz Albahar 359651390c Hide notification rows for deleted workspaces (#10055)
* test(ios): cover orphaned notification feed rows

* fix(ios): hide notification rows without live targets

* test(ios): wait for feed preview projection

* test(ios): reject ambiguous notification targets

* fix(ios): reject ambiguous notification targets

* refactor(ios): split notification target index types
2026-08-12 15:45:59 -07:00
austinpower1258 959f38a4c3 fix: reclaim stale sockets and wait for restore startup 2026-08-12 15:25:49 -07:00
Abdulaziz Albahar 3473b9bca4 Fix disabled iOS controls after forgetting a computer (#10010)
* test(ios): reproduce orphaned modal host after forgetting computer

* fix(ios): retain modal owner shell through computer deletion

* test(ios): cover controls after forgetting final computer

* test(ios): harden final computer deletion flow

* test(ios): follow disconnected add-device path

* Fix iOS deletion UI test build

* test(ios): isolate final computer deletion flow

* test(ios): isolate post-forget toolbar checks

* Keep iOS root modal presenter mounted across shell changes

* fix(ios): root-host Computers modal
2026-08-12 15:19:00 -07:00
Abdulaziz Albahar 1943f9e3f2 Add comprehensive privacy-safe iOS app diagnostics (#9977)
* Add comprehensive iOS app diagnostics

* Inject diagnostic correlation helper

* Complete diagnostic failure handling

* Add diagnostics correctness regressions

* Fix iOS diagnostics correctness gaps

* Handle new diagnostic failures in settings

* Fix task recovery diagnostic pattern

* Test presence diagnostic count tracking

* Keep presence diagnostics constant time

* Restore foreground route reservation

* Assert foreground transport ordering

* Test attachment staging ownership

* Fix iOS diagnostics task ownership

* Fix iOS diagnostics compilation

* Fix relay diagnostics test payloads

* Test chat diagnostics race ownership

* Fix chat diagnostics race ownership

* Import mobile analytics contracts in app delegate

* Test null portal subview mutation crash

* Fix null portal subview launch crash

* Isolate null portal subview regression

* Repair iOS diagnostics after main merge
2026-08-12 15:08:07 -07:00
austinpower1258 3902d0faae test: cover ambient dev CLI socket fallback 2026-08-12 14:03:09 -07:00
austinpower1258 e4acad6d8d test: cover stale socket rebind and restore startup wait 2026-08-12 13:58:18 -07:00
Austin Wang 1237f6e71c Merge pull request #10038 from manaflow-ai/issue-10033-tab-drag-wide-tabs
Fix pane tab drags silently never starting (#10033)
2026-08-12 13:22:13 -07:00
cmux reload-cloud b590e721cd Merge remote-tracking branch 'origin/main' into issue-10033-tab-drag-wide-tabs
# Conflicts:
#	Sources/BrowserPaneDropTargetView.swift
#	Sources/DockSplitStore.swift
#	Sources/TerminalPaneDropTargetView.swift
#	Sources/Workspace.swift
#	vendor/bonsplit
2026-08-12 13:12:32 -07:00
cmux reload-cloud bd222b33b3 Advance Bonsplit to single-active-pane fix 2026-08-12 13:08:33 -07:00
cmux reload-cloud 76be87b248 Finish accepted cross-container tab drags 2026-08-12 12:52:05 -07:00
Austin Wangandcmux reload-cloud 0136f66285 Unify Vault session pane-drop routing (#10032)
* test: cover Vault drops across pane targets

* fix: unify pane transfer drop routing

* fix: restore drop routing module import

* fix: return browser pane drag operation

* test: cover repeated Vault row drags

* fix: preserve repeated Vault row drag identity

* test: cover live Vault drags through browser portal

* fix: require live pane transfer sources

* test: cover Vault pane transfer lifecycle

* test: cover repeated Vault drag cleanup

* fix: preserve accepted Vault pane transfers

* test: satisfy Vault fixture isolation

* fix: end pane drags without invalid super dispatch

* test: cover pane drag completion cleanup

* refactor: share pane drop destination mapping

* test: cover repeated native Vault row drags

* test: cover repeated multi-click Vault drags

* fix: make Vault row drags native pane transfers

* test: update Vault popover drag fixture

* test: require row-owned Vault drag sources

* fix: make each Vault row own its drag source

* refactor: inject Vault drag registry

* test: cover native Vault portal pasteboard handoff

* test: cover hosted duplicate Vault row recycling

* fix: publish live Vault pane transfer capability

* fix: return Vault drag start result

* test: require Vault drags to publish pane capabilities

* fix: route Vault drags through Bonsplit capabilities

* fix: disambiguate Bonsplit Vault tab metadata

* fix: isolate Vault pane capability registration

* fix: construct pane registries on the main actor

* fix: share pane registry during app composition

* fix: unwrap app pane registry during composition

* fix: adopt the initial pane transfer registry

* test: require Vault capability portal routing

* fix: resolve pane drops through shared capabilities

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-08-12 11:43:14 -07:00
austinpower1258andClaude Fable 5 75adf5be7c Restore 0.64.22 pane drop targets for tab drags
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]>
2026-08-12 03:16:58 -07:00
austinpower1258andClaude Fable 5 3aa145e9a8 Advance Bonsplit to drag return-animation fix
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]>
2026-08-12 02:49:40 -07:00
austinpower1258andClaude Fable 5 a79ae2c264 Advance Bonsplit to second-click tab drag fix
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]>
2026-08-12 02:47:05 -07:00
austinpower1258 26a5acfe2a Advance Bonsplit for reliable tab drags 2026-08-12 02:46:14 -07:00
Austin Wang b17c260b61 Fix intermittent dragging for wide tabs (#10035)
* Fix wide-title tab drag hit testing

* Advance Bonsplit through current main lineage

* Advance Bonsplit to merged wide-tab drag fix
2026-08-11 23:47:00 -07:00
austinpower1258 7b6758316e Merge remote-tracking branch 'origin/main' into issue-10033-tab-drag-wide-tabs 2026-08-11 23:43:46 -07:00
austinpower1258 5d62505a2d Advance Bonsplit to merged wide-tab drag fix 2026-08-11 23:43:46 -07:00
AvoChangandClaude Opus 5 a42efb2fdf Drop into a collapsed group by releasing on its header row (#9992)
* 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]>
2026-08-11 23:38:47 -07:00
austinpower1258 3a0db46f7e Advance Bonsplit through current main lineage 2026-08-11 23:32:09 -07:00
austinpower1258 9465641e04 Fix wide-title tab drag hit testing 2026-08-11 23:23:03 -07:00
Austin Wang 13932332be Preserve focus when closing an unfocused pane (#10021)
* Bump Bonsplit for close-pane focus preservation

* Advance Bonsplit focus-close coverage

* Retarget Bonsplit fix to tracked baseline
2026-08-11 20:06:17 -07:00
Abdulaziz Albahar 80633f61e1 Restore Tailscale pairing with cmux 0.64.17 (#9956)
* test(ios): expose legacy Tailscale migration dead ends

* fix(ios): restore authorized legacy Tailscale pairing

* fix(ios): make Tailscale readiness durable

* fix(ios): expose cached Tailscale readiness internally

* test(ios): cover persisted migration upgrade state

* fix(ios): enforce fitted migration sheet detent

* fix(ios): guard optional Tailscale recovery action

* test(ios): make authorization fixture deterministic
2026-08-11 19:48:14 -07:00
Abdulaziz AlbaharandClaude Fable 5 cba80c402e iPhone auth gate: hard-fail unauthenticated dogfood installs (#10011)
* 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]>
2026-08-11 19:41:38 -07:00
Austin Wang c8b9afc23d Fix false SSH host-death verdict and remote diagnostics (#9971)
* test(ssh): cover false remote death recovery

* ci: exercise SSH regression baseline

* fix(ssh): preserve remote PTY recovery

* fix(ssh): harden recovery diagnostics

* fix(remote): keep log routing portable

* test: follow renamed resume command field

* test: cover recovery preservation edge cases

* fix: preserve recovery state through inconclusive probes

* test: exercise terminal teardown through app fixture

* test(remote): cover closed daemon output descriptors

* fix: harden recovery review edge cases

* test: substitute pinned SSH in teardown fixture

* test: cover final recovery review edge cases

* fix: preserve recovery through final reconciliation edges

* test: distinguish automatic and manual PTY recovery

* fix: report PTY recovery state accurately

* test: make reconciliation parameter type explicit

* test: require reconciliation acknowledgement contracts

* Fix SSH startup fixtures after main merge

* Test confirmed PTY end cleanup failure

* Finalize cleanup for confirmed PTY exit

* Keep SSH auth fixtures off live network

* Fix SSH tests after reconnect merge

* Test terminal reset before no-progress reattach

* Reset terminal modes before no-progress reattach

* Run no-progress reset regression in package tests

* Fix SSH test fixtures after reconnect merge
2026-08-12 00:43:25 +00:00
Frank QingandFrank Qing 16c3e69e8b Fix sidebar reorder breaking Mission Control (#9807)
* test(sidebar): cover reorder drop destination isolation

* fix(sidebar): isolate workspace reorder destination

* test(sidebar): cover reorder target rearming

* fix(sidebar): reset reorder drop lifecycle

---------

Co-authored-by: Frank Qing <[email protected]>
2026-08-11 17:39:21 -07:00
Austin Wang 8952b5e843 Fix Claude Teams teammate respawn environment (#9731)
* test: cover claude-teams respawn launcher environment

* fix: preserve claude-teams respawn environment

* refactor: package claude-teams respawn transport

* Fix restore argument return for Xcode 27

* test: cover claude-teams restore capture

* fix: capture claude-teams sessions for restore

* test: handle capability-wrapped claude-teams fixtures
2026-08-11 17:14:10 -07:00
Austin Wang 407a935362 Revert portal hierarchy mutation tracker launch crash (#10018)
* Revert "Fix portal mutation tracker launch crash (#10008)"

This reverts commit a161d16941.

* Revert "Portal: invalidate the split-divider hit-test cache on nested subview insertion (#8580)"

This reverts commit bcfb2d7b0b.
2026-08-11 17:09:54 -07:00
Austin Wang 5ff45e39c9 Docs: explain sandbox constraints for sidebar extensions (#9104)
* docs: explain sidebar extension sandbox constraints

* docs: clarify sidebar sandbox authorization boundaries

* docs: distinguish development tool paths from distribution

* docs: refresh sidebar sandbox guidance
2026-08-11 17:03:19 -07:00
Austin Wang 31fd3b9c77 Keep screenshot paste preparation off the main thread (#8838)
* test: require image pasteboard reads off main

* fix: prepare composer image pastes off main

* test: preserve composer selection during async paste

* fix: make pending composer pastes undo-safe

* fix: parse pasted HTML without AppKit importer

* refactor: make HTML paste parser an instance service

* refactor: satisfy paste review policy

* test: cover malformed composer HTML paste

* fix: parse pasted HTML with Foundation tokenizer

* refactor: remove blocking image prepare overload

* Revert "refactor: remove blocking image prepare overload"

This reverts commit 36f65f6b02.

* test: cover async paste caret and preformatted HTML

* fix: preserve async paste caret and preformatted text

* test: cover edits during async paste

* fix: preserve composer edits during async paste

* fix: bound asynchronous paste preparation

* fix: preserve paste ordering under blocking providers

* test: cover paste worker exhaustion and reservation sync

* fix: isolate paste preparation in killable worker

* test: cover paste binding and encoded HTML regressions

* fix: preserve composer state during async paste

* test: cover overlapping paste and undo ordering

* test: model separate undo events during pending paste

* fix: keep pending paste edits undo-stable

* test: cover image paste with whitespace HTML

* fix: preserve image detection and unique test wiring

* test: cover repeated and large text pastes

* fix: preserve input and bulk paste ordering

* fix: hop clipboard confirmation to main actor

* test: bound bulk paste text transport

* fix: bound bulk paste text transport

* refactor: inject paste file operations

* refactor: simplify clipboard input routing

* test: preserve existing file URLs through paste worker

* test: document pending paste typing order

* test: cover clipboard admission ordering race

* fix: admit clipboard reads before main actor hop

* refactor: isolate clipboard callbacks on main actor

* test: bound HTML paste parsing

* fix: bound rich clipboard parsing

* docs: explain clipboard input overflow policy

* fix: satisfy strict paste concurrency checks

* fix: resolve paste pipeline CI compilation

* Make screenshot recovery test deterministic

* Document paste worker cancellation contract

* Fix paste test helper imports

* Preserve hidden HTML templates during paste

* Align HTML normalizer with package policy

* Use public AppKit attachment character in test

* Test self-closing HTML templates during paste

* Preserve text after self-closing templates

* Test HTML entities from data paste input

* Preserve non-ASCII entities in HTML paste

* Test UTF-16 HTML pasteboard data

* Decode BOM-marked HTML before paste normalization

* Test bounded HTML nesting during paste

* Bound HTML paste traversal depth

* Test temporary image destination validation

* Validate temporary image adoption destination

* Reject stale clipboard confirmation callbacks

* Fix merged main actor default warning

* Fence async paste completion to surface lifetime

* Test paste rollback and rejected HTML fallback

* Harden paste failure and worker lifetimes

* Expose clipboard callback context across extension files

* Test pending paste publication edge cases

* Fix pending paste publication edge cases

* Test pointer ordering during terminal paste

* Harden asynchronous paste sequencing

* Test stale terminal callback identity

* Fence paste callbacks to native surface lifetimes

* Test paste sequencing and content fidelity edges

* Close paste sequencing and fidelity edge cases

* Test bounded generation-aware paste input

* Bound paste input by runtime generation

* Test hidden HTML and runtime clipboard teardown

* Test atomic runtime clipboard ownership

* Close clipboard preparation and teardown races

* Test pre-admission overflow and CSS visibility

* Preserve overflow input and visible HTML descendants

* fix: make clipboard reservation sendable

* fix: expose callback identity dependency

* fix: express overflow path as conditional

* fix: type isolated workspace lookup

* test: cover paste deadline and overflow ordering

* test: fix weak reference declaration

* fix: close paste deadline and overflow races

* fix: bound runtime clipboard admission

* test: cover remaining paste lifecycle regressions

* fix: close remaining paste preparation gaps

* test: fix terminal image concurrency target compilation

* test: cover admission-scoped paste deadlines

* fix: enforce paste deadlines from admission

* test: align clipboard pointer fixture epoch

* test: stabilize clipboard input sequencing fixture

* fix: publish one clipboard completion per read

* fix: distinguish invalid paste image files

* fix: keep clipboard overflow cancellation sendable

* test: discard pre-admission input during teardown

* test: cover programmatic input during clipboard reads

* test: publish orphaned pending paste commits

* fix: close clipboard ordering races

* fix: preserve clipboard rollback admission

* fix: compile strict clipboard test targets

* test: cover clipboard review regressions

* test: cover isolated clipboard rollback capture

* fix: isolate clipboard rollback capture

* fix: export pasteboard snapshot AppKit dependency

* test: cover cancellation before mutation publication

* fix: compile strict pasteboard lane paths

* test: expose clipboard overflow replay inversion

* fix: prevent deferred input replay inversion

* test: compile async clipboard readiness assertions

* fix: keep rollback ownership across cancellation

* fix: preserve overflow handler actor isolation

* test: compile terminal clipboard package coverage

* refactor: split clipboard concurrency coverage

* test: cover abandoned clipboard restore failure

* fix: retry abandoned clipboard restoration

* test: preserve adopted image on permission failure

* fix: roll back image adoption on permission failure

* test: preserve content after self-closing raw tags

* test: align callback fixture with agent shims

* fix: close self-closing raw-text elements

* test: preserve RTF after rejected image HTML

* fix: preserve RTF fallback after rejected HTML

* fix: preserve unquoted slash template values

* test: align clipboard fixtures with runtime validation

* test: await textbox paste admission

* test: align restored snapshot resume assertion

* test: await remaining textbox paste bindings

* test: hide iframe fallback paste text

* fix: hide iframe fallback paste text

* test: discard superseded clipboard write

* fix: discard stale coalesced clipboard writes

* test: preserve RTFD rejected HTML fallback

* fix: preserve RTFD rejected HTML fallback

* test: cover clipboard cancellation and whitespace fallback

* fix: report admitted clipboard writes accurately

* test: cover mobile click and quoted raw text ordering

* fix: keep mobile clicks and raw text boundaries intact
2026-08-11 17:02:36 -07:00
Austin Wang 66e44fd0e0 Keep browser portal refreshes below SwiftUI (#9774)
* test: reproduce browser portal reentrant layout

* fix: defer browser portal layout to AppKit

* fix: remove deprecated notification change handlers

* test: reproduce stale browser portal geometry

* fix: refresh deferred browser portal geometry

* test: reproduce browser portal geometry reentrancy

* fix: keep browser portal sync out of SwiftUI layout

* test: keep browser host instrumentation test-only

* test: isolate browser portal layout synchronization

* test: cover browser portal presentation ownership

* fix: keep browser portal refreshes below SwiftUI
2026-08-11 17:01:53 -07:00
Austin Wang 5b4eba1a67 Give SidebarRowTextView ownership of link styling so selected-row description links stay readable (#9613)
* 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
2026-08-11 16:58:28 -07:00
Austin Wang 766dee33c4 Fix Vault restore admission and chat persistence (#9964)
* test: preserve snapshot resume command coverage

* test: require Vault resume chat rebinding

* fix: admit Vault restores before terminal startup

* test: require explicit Vault runtime admission

* fix: explicitly admit Vault terminal startup

* test: preserve queued Vault restores across relaunch

* fix: persist in-flight Vault resume intent

* test: require topology-safe restore admission

* fix: admit restores after authoritative topology

* fix: close Vault restore persistence regressions

* refactor: centralize terminal startup restore commit

* test: align relaunch restore expectations

* test: model admitted restore lifecycle

* test: cover queued restore admission regressions

* fix: bind queued restores to structured startup work

* test: cover Dock startup restore transaction transfer

* fixup! test: cover Dock startup restore transaction transfer

* fix: transfer staged restores into Dock ownership

* test: cover pending restore cancellation cleanup

* test: preserve foreign claims during restore cancellation

* fix: cancel staged restores without leaking claims

* test: cover restore admission failure cleanup

* fix: gate and clean failed restored startup work

* test: preserve queued Vault restore identity

* fix: preserve queued Vault restore identity

* test: cover restore identity and teardown ownership

* fix: retain staged restore lifecycle ownership

* fix: keep restored lifecycle updates panel-scoped

* test: reject mismatched Vault restore liveness

* fix: match restore liveness to session identity

* test: preserve snapshotless restore completions

* fix: preserve snapshotless restore completions

* refactor: split restore transaction types
2026-08-11 16:43:06 -07:00
Austin Wang 1780ecd9d8 Merge pull request #9870 from manaflow-ai/issue-9869-dock-chromeless-browser
Add chromeless Dock browser controls
2026-08-11 16:41:50 -07:00
Lawrence Chen 71d80faac0 Merge pull request #9840 from manaflow-ai/task-cli-lifecycle-grammar
feat(tui): add local server lifecycle commands
2026-08-11 14:54:44 -07:00
Abdulaziz Albahar a161d16941 Fix portal mutation tracker launch crash (#10008)
* fix(portal): retain inserted views through hooks

* fix(portal): track insertion before AppKit consumes child
2026-08-11 13:27:36 -07:00
Lawrence Chen 3cdaf444e3 fix(tui): accept transport close after stop ack 2026-08-11 13:17:24 -07:00
Lawrence Chen 1e3290c3b1 test(tui): cover transport close after stop ack 2026-08-11 13:17:05 -07:00
Lawrence Chen 8ba2f6a69e fix(tui): restore staged state after reset mismatch 2026-08-11 11:31:14 -07:00
Abdulaziz Albahar 7ffe71cc84 fix(iroh): preserve verified sessions across auth blips 2026-08-11 11:28:18 -07:00
Abdulaziz Albahar d93c5c338b test(iroh): expose premature admission disconnects 2026-08-11 11:28:17 -07:00
Abdulaziz Albahar 006d2c8375 Fix iOS workspace selection after Iroh reconnect (#9979)
* test(ios): preserve workspace selection across reconnect

* fix(ios): preserve workspace selection on reconnect
2026-08-11 11:08:48 -07:00
Abdulaziz AlbaharandClaude Fable 5 f06d00e4fd docs: require authentication on every installed iPhone build (#10001)
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]>
2026-08-11 10:44:42 -07:00
Abdulaziz Albahar b886b6c234 Hide Add Computer outside Tailscale (#9957)
* test(ios): hide manual pairing outside Tailscale

* fix(ios): gate Add Computer on Tailscale

* test(ios): cover automatic attach approval

* fix(ios): preserve automatic attach approval

* test(ios): exercise attach approval action

* fix(ios): hide unavailable push pairing repair

* test(ios): switch pairing capability in settings

* test(ios): verify onboarding connection actions

* fix(ios): refresh manual pairing capability

* fix(ios): model pairing availability as data

* fix(ios): observe pairing capability in shells

* refactor(ios): keep pairing gate at root

* test(ios): verify persisted pairing capability

* test(ios): seed connection method fixtures

* test(ios): isolate connection method launches

* test(ios): keep pairing gate test single launch
2026-08-11 10:40:06 -07:00
Abdulaziz Albahar 9d5b8d98f9 iOS: preserve workspace groups in Recent Activity (#9960)
* test(ios): preserve groups in recent activity

* fix(ios): keep recent workspace groups atomic

* perf(ios): reuse grouped workspace projection

* fix(ios): observe authoritative group reorder

* perf(ios): append group members in place

* perf(ios): reuse workspace lookup in grouped rows

* perf(ios): cache grouped workspace projection

* test(ios): make grouped scale coverage deterministic

* perf(ios): reuse grouped workspace snapshot

* test(ios): cover grouped workspace journeys

* fix(ios): align grouped workspace sort contracts

* test(ios): cover picker connection status accessibility

* fix(ios): preserve picker status accessibility

* test(ios): query sort tiles by identifier

* test(ios): stabilize workspace picker queries

* test(ios): scope computers before search journey

* test(ios): cover grouped filter transitions

* test(ios): seed grouped filter fixtures

* test(ios): sample grouped transitions densely
2026-08-11 10:33:22 -07:00
Lawrence Chen c5d5e41d3e fix(tui): classify deadline socket close 2026-08-11 10:30:35 -07:00
Lawrence Chen dcba51a2f3 style(cmux-tui): apply hosted rustfmt 2026-08-11 10:09:30 -07:00
Lawrence Chen 2f8eda988d fix(tui): accept terminal stop connection close 2026-08-11 10:04:33 -07:00
Lawrence Chen abfc0344af test(tui): complete Linux proc stat fixture 2026-08-11 10:04:24 -07:00
Lawrence Chen 7673659876 test(tui): synchronize stalled host timeout 2026-08-11 08:54:12 -07:00
Lawrence Chen f6ecd39c36 fix(tui): bind detach fence to smart renderer 2026-08-11 08:30:48 -07:00
Lawrence Chen 2696d60b2b style(tui): apply hosted rustfmt 2026-08-11 07:59:34 -07:00
Lawrence Chen 5561e45435 test(tui): require journal sensitivity fixtures 2026-08-11 07:54:31 -07:00
austinpower1258 ab6c8b5372 Fix SSH retry test zero-match counting 2026-08-11 07:45:03 -07:00
Lawrence Chen f28115d94f test(tui): opt in to sensitive journal tail 2026-08-11 07:16:49 -07:00
austinpower1258 55c544317e Restore web focus intent for chromeless panes 2026-08-11 06:29:46 -07:00
Lawrence Chen fa5e8bc5fe test(tui): avoid shutdown flush deadlock 2026-08-11 06:27:50 -07:00
austinpower1258 3656550fd2 Fix Dock close-others warning parity 2026-08-11 05:56:28 -07:00
austinpower1258 57dfb22ac4 Test Dock close-others clean-tab warning 2026-08-11 05:43:09 -07:00
Lawrence Chen 64aecd5833 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 05:34:21 -07:00
austinpower1258 498c1457f4 Merge remote-tracking branch 'origin/main' into issue-9869-dock-chromeless-browser
# Conflicts:
#	Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHTerminalExitPromptInputFilterTests.swift
2026-08-11 05:32:53 -07:00
austinpower1258 2cad7403a9 Fix mutating Swift Testing expectations 2026-08-11 05:15:59 -07:00
Austin Wang 93118b3b90 Merge pull request #9921 from manaflow-ai/issue-9920-workitem-chain-producer
Prevent recursive deferred-action release chains
2026-08-11 05:08:35 -07:00
Lawrence Chen 89b75e4c3d Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 04:33:55 -07:00
Lawrence Chen 22c82ea176 Merge pull request #9989 from manaflow-ai/fix-journal-bounded-replay-sensitivity
Fix bounded journal replay sensitivity fixture
2026-08-11 04:33:19 -07:00
austinpower1258 fe55b0bcdc Preserve main browser actions during sidebar focus 2026-08-11 04:11:13 -07:00
austinpower1258 baa99df7cc Merge remote-tracking branch 'origin/main' into issue-9920-workitem-chain-producer 2026-08-11 03:49:06 -07:00
austinpower1258 5405b0553d Fix Xcode 26.3 Swift Testing compilation 2026-08-11 03:49:03 -07:00
Lawrence Chen ebae1456ce test(tui): request sensitive workspace journal events 2026-08-11 03:32:12 -07:00
austinpower1258 209e13c858 Test main browser shortcuts during sidebar focus 2026-08-11 03:25:52 -07:00
Lawrence Chen dad25310f4 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 03:25:47 -07:00
Lawrence Chen bd910bfe99 Merge pull request #9987 from manaflow-ai/fix-tui-unused-shell-quote-helper
Remove obsolete TUI shell quote test helper
2026-08-11 03:25:19 -07:00
Lawrence Chen 8eea0590ed test(tui): remove obsolete shell quote helper 2026-08-11 03:11:51 -07:00
austinpower1258 b6ac6716e3 Merge remote-tracking branch 'origin/main' into issue-9869-dock-chromeless-browser
# Conflicts:
#	Sources/DockSplitStore+SessionRestore.swift
#	Sources/DockSplitStore+SurfaceTransfer.swift
#	Sources/DockSplitStore.swift
2026-08-11 03:09:02 -07:00
austinpower1258 8513cdd0bb Restore Dock beta fixture on early exits 2026-08-11 02:55:46 -07:00
Lawrence Chen 784cecf336 docs(tui): correct protocol 12 event count 2026-08-11 02:54:47 -07:00
austinpower1258 766c95ad36 Fix Dock unread and browser focus fallbacks 2026-08-11 02:52:37 -07:00
austinpower1258 cf311e0b79 Enable Dock beta in shortcut test harness 2026-08-11 02:52:31 -07:00
Lawrence Chen 8c74ab408d Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 02:51:53 -07:00
Lawrence Chen 7217b1cf41 Watch hosted TUI verification completion (#9942)
* ci: watch hosted TUI verification completion

* fix: restart hosted completion watcher

* fix: bound hosted watcher retries

* fix: bound hosted watcher cleanup

* Report hosted cancellation failures

* Install hosted cleanup before FIFO setup
2026-08-11 02:50:05 -07:00
Lawrence Chen 36cfc19d45 test(tui): freeze protocol 11 conformance baseline 2026-08-11 02:47:04 -07:00
Lawrence Chen 11acc48058 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 02:41:01 -07:00
austinpower1258 5e4007d3ff Fix Dock shortcut notification test harness 2026-08-11 02:37:52 -07:00
austinpower1258 fcdd0853ea Merge remote-tracking branch 'origin/main' into issue-9920-workitem-chain-producer 2026-08-11 02:26:49 -07:00
Austin Wang 50b1d2ac50 Cache and gate Ghostty macOS unified logging (#9368)
* Update Ghostty for cached macOS loggers

* Pin GhosttyKit for cached macOS logger

* Combine cached loggers with current Ghostty fixes

* Pin combined GhosttyKit artifact

* Remove unpublished GhosttyKit checksum mapping
2026-08-11 02:09:54 -07:00
Austin Wang df5889a515 Fix SSH wake reconnect aborts (#9966)
* test(ssh): cover wake reconnect input handling

* fix(ssh): preserve persistent wake reconnects

* fix(ssh): avoid polling closed prompt input

* fix(ssh): filter terminal prompt input continuously

* test(ssh): reject enter inside control input

* fix(ssh): keep embedded line endings filtered
2026-08-11 02:08:28 -07:00
Austin Wang d000289621 Fix file drag-and-drop in Dock sessions (#9778)
* 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
2026-08-11 02:08:16 -07:00
Lawrence Chen 4a509fcc6a Publish session coordinator lock availability (#9940)
* test: cover coordinator release publication

* fix: publish session coordinator availability

* test: prove coordinator availability wakeup

* test: observe coordinator publication directly

* fix: recheck coordinator lock after missed signal

* fix: clean interrupted coordinator registrations

* fix: make coordinator release signal reliable

* Revert "fix: make coordinator release signal reliable"

This reverts commit 53a519d396.

* fix: publish coordinator release through fifo

* fix: publish only ready coordinator fifos
2026-08-11 02:08:13 -07:00
Lawrence Chen 21224fe770 Replace Ghostty helper test timing polls (#9941)
* test: replace Ghostty helper timing polls

* fix: keep desktop probe cleanup within deadline

* test: observe process-group child exits

* test: tolerate unavailable pidfd observers

* test: report unsupported pidfd coverage
2026-08-11 01:49:57 -07:00
lawrencecchen 7ca4cec15d Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 01:47:15 -07:00
Lawrence Chen 6969b53560 Merge pull request #9982 from manaflow-ai/fix-journal-subject-head-marker
test(tui): preserve exact journal subject marker
2026-08-11 01:46:03 -07:00
austinpower1258 7e567e43f3 Test window Dock manual unread toggles 2026-08-11 01:36:59 -07:00
austinpower1258 31602bb377 Merge remote-tracking branch 'origin/main' into issue-9869-dock-chromeless-browser 2026-08-11 01:24:04 -07:00
lawrencecchen b46724685d Test journal subject head with preserved marker 2026-08-11 01:19:37 -07:00
lawrencecchen e2852c493d Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 01:14:47 -07:00
austinpower1258 6f88cbdf61 Unify Dock and workspace unread toggles 2026-08-11 01:08:52 -07:00
austinpower1258 3af875b9e7 Test notification-derived Dock unread toggles 2026-08-11 00:48:35 -07:00
Abdulaziz Albahar cf291a4ac2 Fix iOS 27 keyboard dock tracking (#9958)
* test(ios): reproduce iOS 27 keyboard dock regression

* fix(ios): bypass broken iOS 27 keyboard guide

* Fix iOS 27 keyboard verification paths
2026-08-11 00:48:05 -07:00
austinpower1258 46653127d2 test: fix restored agent snapshot command assertion 2026-08-11 00:26:48 -07:00
lawrencecchen 70d80b174e Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-11 00:23:53 -07:00
austinpower1258 d66c3c2807 Merge remote-tracking branch 'origin/main' into issue-9869-dock-chromeless-browser 2026-08-11 00:16:25 -07:00
lawrencecchen d2f146744c test(tui): retain detached exit receipt in snapshot 2026-08-11 00:11:42 -07:00
Austin Wang 6edd0bec51 Fix literal tildes in terminal-inserted paths (#9734)
* test: cover literal tilde in inserted shell paths

* fix: escape literal tildes in terminal paths
2026-08-11 00:07:11 -07:00
Austin Wang ddfe3dcbfc Fix reentrant browser portal layout poisoning (#9773)
* test(browser): reproduce restored portal layout reentrancy

* test(browser): observe portal slot layout reentrancy

* test(browser): observe WebKit layout during portal refresh

* test(browser): force the outer portal layout probe

* test(browser): observe the forced portal layout boundary

* fix(browser): defer hosted WebKit presentation layout

* test(browser): preserve inspector width after reattach

* test(browser): await deferred portal refresh turns

* fix(browser): restore inspector after deferred reattach

* test(browser): isolate portal layout probe

* test(browser): observe WebKit flush without debug hook

* test(browser): cover anchor geometry after reparent

* fix(browser): let portal anchor own geometry updates
2026-08-11 00:06:43 -07:00
bcfb2d7b0b Portal: invalidate the split-divider hit-test cache on nested subview insertion (#8580)
* 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]>
2026-08-11 00:05:53 -07:00
Austin Wangandcmux reload-cloud 2505c9bc5a Add group-scoped workspace cycle actions (#9352)
* test: cover group-scoped workspace cycling

* feat: add group-scoped workspace cycle actions

* test: compile mutating boundary assertions

---------

Co-authored-by: cmux reload-cloud <[email protected]>
2026-08-11 00:05:12 -07:00
Austin Wang 124917001f Keep cmux's bundled open shim first on PATH (#9781)
* 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
2026-08-11 00:04:24 -07:00
Austin Wang 4704a5b43e Fix sidebar workspace-switch layout reentry (#9854)
* test: reproduce workspace switch layout reentry (#9612)

* fix: keep terminal portal layout within owned hierarchy (#9612)

* ci: enforce workspace-switch layout regression (#9612)

* fix: stop workspace model from forcing window layout (#9612)

* debug: capture workspace-switch layout reentry stack (#9612)

* fix: reconcile terminal portals outside view callbacks (#9612)

* fix: isolate terminal portal coordinator to main actor (#9612)

* cleanup: remove disproven layout experiments (#9612)

* test: reproduce per-pane portal layout fanout (#9612)

* fix: coalesce portal bind layout convergence (#9612)

* fix: address portal reconciliation review feedback (#9612)

* fix: make layout log helpers explicitly nonisolated (#9612)

* fix: isolate portal vacancy retry trampoline (#9612)

* test: reproduce detached portal visibility drift (#9612)

* fix: preserve detached portal visibility intent (#9612)

* test: make layout log sentinel polling lossless (#9612)

* test: distinguish detached visibility from geometry hide (#9612)

* test: drain detached reconciliation before portal pruning (#9612)
2026-08-11 00:01:24 -07:00
austinpower1258 869ba5d7c7 Isolate Dock socket test helper to main actor 2026-08-10 23:47:49 -07:00
lawrencecchen d12a942ab7 test(tui): preserve explicit close tombstone across restart 2026-08-10 23:24:25 -07:00
austinpower1258 a0cccf6c49 Fix Dock browser callback test typing 2026-08-10 23:21:32 -07:00
austinpower1258 c80f74a6aa Use Swift Testing for Dock omnibar coverage 2026-08-10 23:03:32 -07:00
austinpower1258 3b27a684c8 Remove Dock socket test seam 2026-08-10 22:59:49 -07:00
austinpower1258 9477539657 Split browser parity value types 2026-08-10 22:53:01 -07:00
lawrencecchen 65dc8ab300 fix(tui): bump lifecycle readiness protocol to 12 2026-08-10 22:49:57 -07:00
austinpower1258 70e0740522 Fix merged test API drift 2026-08-10 22:44:56 -07:00
lawrencecchen badbd88db6 test(tui): cover lifecycle-ready protocol advertisement 2026-08-10 22:28:54 -07:00
lawrencecchen eac01358e9 test(tui): require lifecycle readiness protocol bump 2026-08-10 22:26:50 -07:00
austinpower1258 79fbedaa1b Fix Dock browser parity test compilation 2026-08-10 22:26:49 -07:00
austinpower1258 95d49aa687 Fix Dock menu routing compilation 2026-08-10 22:06:34 -07:00
lawrencecchen 236f57ff60 Merge origin/main into task-cli-lifecycle-grammar 2026-08-10 22:01:35 -07:00
lawrencecchen 4a3bf32e8d fix(tui): reconcile terminal close replay revisions 2026-08-10 22:01:26 -07:00
austinpower1258 36e4237f99 Merge remote-tracking branch 'origin/main' into issue-9869-dock-chromeless-browser 2026-08-10 21:55:06 -07:00
Austin Wang d64a84ac63 Keep bracketed paste framing atomic (#9875)
* Add failing atomic paste regression

* Keep bracketed paste framing atomic

* Pin GhosttyKit for atomic paste fix

* Pin combined GhosttyKit artifact

* Pin minimal compatible Ghostty merge

* Document current atomic paste integration

* Clarify active Ghostty integration pin
2026-08-10 21:53:40 -07:00
austinpower1258 27c3bd5713 Fix Dock browser parity compilation 2026-08-10 21:51:39 -07:00
austinpower1258 8a08866c7e Merge remote-tracking branch 'origin/main' into issue-9920-workitem-chain-producer 2026-08-10 21:50:15 -07:00
austinpower1258 6c9e014d2b Merge remote-tracking branch 'origin/main' into issue-9869-dock-chromeless-browser
# Conflicts:
#	Sources/DockSplitStore+Reset.swift
2026-08-10 21:32:11 -07:00
Austin Wang a489eec3ea Merge pull request #9924 from manaflow-ai/issue-9923-vault-resume-restore-verb
Route Vault resume through cmux restore
2026-08-10 21:13:47 -07:00
lawrencecchen 69995dd2fd test(tui): correct restart terminal snapshot expectation 2026-08-10 21:10:29 -07:00
austinpower1258 86f1cd7939 Restore current Ghostty pointer after main merge 2026-08-10 20:47:24 -07:00
austinpower1258 27adb030ef Merge remote-tracking branch 'origin/main' into issue-9869-dock-chromeless-browser
# Conflicts:
#	Sources/DockSplitStore+SessionRestore.swift
#	Sources/DockSplitStore+SessionSnapshot.swift
#	Sources/DockSplitStore+SurfaceTransfer.swift
#	Sources/DockSplitStore.swift
#	Sources/TerminalController.swift
#	cmuxTests/DockControlDefinitionDecodingTests.swift
2026-08-10 20:45:32 -07:00
austinpower1258 048dd603c4 fix: avoid cross-isolation browser detector capture 2026-08-10 20:45:11 -07:00
austinpower1258 6c194c4e17 fix: unify restored-agent cwd lifecycle 2026-08-10 20:42:49 -07:00
austinpower1258 85f460a7a1 test: cover Vault restore cwd lifecycle 2026-08-10 20:33:58 -07:00
austinpower1258 11b8615812 Give Dock browsers main-pane feature parity 2026-08-10 20:32:14 -07:00
austinpower1258 45287586b1 fix: close deferred handle audit gaps 2026-08-10 20:21:19 -07:00
lawrencecchen 0f77254aef style(tui): apply hosted lifecycle rustfmt 2026-08-10 20:17:30 -07:00
austinpower1258 78b2492d2e test: cover Vault restore fallback and placement 2026-08-10 20:13:43 -07:00
austinpower1258 59dab37dfa Merge remote-tracking branch 'origin/main' into issue-9923-vault-resume-restore-verb 2026-08-10 20:13:40 -07:00
austinpower1258 b9a2907a6a Merge remote-tracking branch 'origin/main' into issue-9920-workitem-chain-producer 2026-08-10 20:11:43 -07:00
lawrencecchen 9c018f9b17 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-10 20:10:20 -07:00
austinpower1258 7c866e496c refactor: quarantine Vault copy resume commands 2026-08-10 20:10:17 -07:00
austinpower1258 76bb995e57 test: cover deferred handle containers 2026-08-10 20:10:09 -07:00
austinpower1258 8935a7b69e fix: persist Vault restores in agent lifecycle 2026-08-10 20:10:05 -07:00
lawrencecchen 58c17ceb37 fix(tui): localize stopped-owner reload failures 2026-08-10 20:10:00 -07:00
lawrencecchen 48f6bac78d style(tui): format lifecycle regression coverage 2026-08-10 19:54:19 -07:00
lawrencecchen 901c56ce1e test(tui): expose stopped-owner reload localization 2026-08-10 19:50:19 -07:00
austinpower1258 28638efb92 test: cover Vault restore persistence lifecycle 2026-08-10 19:49:33 -07:00
lawrencecchen 5db9360501 Revert "fix(tui): close lifecycle review gaps"
This reverts commit f46ca1176f.
2026-08-10 19:49:00 -07:00
lawrencecchen f46ca1176f fix(tui): close lifecycle review gaps 2026-08-10 19:44:58 -07:00
austinpower1258 814ebd6e0c fix: close detached task installation race 2026-08-10 19:39:50 -07:00
Austin Wang 61ca64f534 Merge pull request #9776 from manaflow-ai/issue-9466-stage-manager-raise
Keep background attention from raising Stage Manager windows
2026-08-10 19:39:25 -07:00
lawrencecchen 7c14c57456 fix(tui): own pending host cleanup guard 2026-08-10 19:37:32 -07:00
austinpower1258 7e5cea062b test: strengthen Bonsplit teardown coverage 2026-08-10 19:32:07 -07:00
lawrencecchen c3c3d0cdcf style(tui): apply hosted rustfmt 2026-08-10 19:30:25 -07:00
austinpower1258 84ad00f525 fix: make task store teardown synchronous 2026-08-10 19:30:18 -07:00
lawrencecchen 4c441f6d91 fix(tui): cover running host publish window 2026-08-10 19:23:40 -07:00
austinpower1258 ab3a92cc3b fix: isolate macOS SwiftUI deferred state 2026-08-10 19:21:32 -07:00
lawrencecchen 6faa8b14a1 fix(tui): keep pending host storage portable 2026-08-10 19:19:24 -07:00
lawrencecchen fa363ef2a8 fix(tui): bind pending host callbacks exactly 2026-08-10 19:14:57 -07:00
austinpower1258 03f104f695 test: audit macOS SwiftUI deferred state 2026-08-10 19:10:00 -07:00
austinpower1258 b8889ba36a fix: isolate deferred work ownership 2026-08-10 19:05:29 -07:00
lawrencecchen a7252fdebf style(tui): apply hosted rustfmt 2026-08-10 18:50:50 -07:00
lawrencecchen db312c6297 fix(tui): accept reconnect before topology binding 2026-08-10 18:45:21 -07:00
lawrencecchen b093ca6475 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-10 18:17:32 -07:00
Abdulaziz Albahar 4c28ef112a Explain Auto-Connect once to migrated iOS users (#9891)
* test: cover Auto-Connect migration introduction

* feat(ios): explain Auto-Connect migration

* fix(ios): isolate Auto-Connect migration state

* fix(ios): unify root modal presentation

* fix(ios): harden migration notice presentation

* fix(ios): coordinate migration with shell modals

* fix(ios): preserve child presenter identity

* test(ios): cover Auto-Connect migration outcomes

* fix(ios): unify root Settings presentation

* fix(ios): root-host shell Settings

* fix(ios): advance queued migration in root sheet

* fix(ios): preserve modal action ownership

* test(ios): require transactional modal acquisition

* fix(ios): acquire modal before side effects

* test(ios): require fitted migration sheet

* fix(ios): fit migration sheet to content

* fix(ios): let migration content size sheet

* fix(ios): measure migration sheet detent

* fix(ios): isolate migration accessibility containers

* fix(ios): decouple migration accessibility from layout

* test(ios): add migration viewport probe

* test(ios): ignore viewport probe hittability

* fix(ios): bound migration scroll viewport

* test(ios): verify migration visibility by frame

* test(ios): require migration title visibility

* test(ios): cap migration probe to app viewport

* test(ios): cover Japanese migration landscape action

* fix(ios): use native migration sheet sizing
2026-08-10 18:09:24 -07:00
austinpower1258 5661b21342 test: audit deferred work ownership 2026-08-10 18:06:01 -07:00
lawrencecchen 643939b082 style(tui): apply hosted rustfmt 2026-08-10 18:05:45 -07:00
Lawrence Chen bb5623585a Finish CodeRouter organization UX and analytics operations (#9882)
* 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
2026-08-10 18:02:54 -07:00
lawrencecchen 151a48c688 test(tui): accept oversized-frame peer close 2026-08-10 17:54:05 -07:00
austinpower1258 e78fe95df4 Test workspace Dock browser socket routing 2026-08-10 17:42:23 -07:00
austinpower1258 63f31dcbae fix: route Vault resume through restore verb 2026-08-10 17:39:44 -07:00
lawrencecchen 5a636ace9e fix(tui): release unregistered Kitty quota 2026-08-10 17:30:00 -07:00
austinpower1258 af914dd3a8 test: start deferred Dock surface before input 2026-08-10 17:26:57 -07:00
lawrencecchen 5dab53fca2 fix(tui): retire unregistered Kitty surfaces 2026-08-10 17:26:17 -07:00
austinpower1258 48a6f727fd test: identify virtual mobile emission delays 2026-08-10 17:22:11 -07:00
austinpower1258 5be58f99c0 test: stabilize accepted Dock key input 2026-08-10 17:07:30 -07:00
austinpower1258 bfcfac8530 test: cover Vault restore-verb resume path 2026-08-10 17:02:27 -07:00
lawrencecchen 74ea49b4af test(tui): close Kitty runtimes explicitly 2026-08-10 16:43:15 -07:00
austinpower1258 5b714d679b fix: preserve mobile emission leading edge 2026-08-10 16:31:12 -07:00
austinpower1258 8c3c047dee test: require immediate mobile burst leading edge 2026-08-10 16:28:00 -07:00
austinpower1258 372ec7385c fix: coalesce mobile workspace emissions 2026-08-10 16:14:56 -07:00
austinpower1258 32822bf5dd Test Dock browser tab context routing 2026-08-10 16:10:32 -07:00
austinpower1258 2ccdd98d71 fix: preserve optional mobile unread injection 2026-08-10 15:59:35 -07:00
lawrencecchen 81aca8f6a3 test(tui): remove shutdown scheduler race 2026-08-10 15:55:02 -07:00
austinpower1258 26a34856e2 test: cap mobile summary burst emissions 2026-08-10 15:50:59 -07:00
austinpower1258 d11b5ad95c refactor: inject mobile workspace emission dependencies 2026-08-10 15:50:05 -07:00
lawrencecchen 87ab1555fb Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-10 15:36:19 -07:00
lawrencecchen ddfc6f799a style(tui): format stop completion coverage 2026-08-10 15:36:02 -07:00
lawrencecchen 88c49d1f48 fix(tui): preserve authoritative stop completion 2026-08-10 15:32:28 -07:00
lawrencecchen 5a4cfaf376 test(tui): preserve authoritative stop completion 2026-08-10 15:31:42 -07:00
austinpower1258 79dfe0f593 Merge remote-tracking branch 'origin/main' into issue-9466-stage-manager-raise 2026-08-10 15:28:37 -07:00
Lawrence Chen 621164d1f3 Make Windows state sync portable and terminal close atomic (#9915)
* 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
2026-08-10 15:24:54 -07:00
austinpower1258 784841e58d Test Dock browser routing after focus moves 2026-08-10 15:15:15 -07:00
austinpower1258 6af4abd181 fix: distinguish Dock reveal failures 2026-08-10 15:15:08 -07:00
austinpower1258 69c1b1d6f9 test: distinguish Dock reveal failures 2026-08-10 15:13:38 -07:00
austinpower1258 c1c03675a3 fix: preserve accepted input semantics 2026-08-10 15:13:13 -07:00
lawrencecchen 400fbaecf4 test(tui): use the lifecycle mux fixture directly 2026-08-10 15:10:22 -07:00
lawrencecchen ceabe59232 fix(tui): close final lifecycle review gaps 2026-08-10 14:58:54 -07:00
lawrencecchen a4370ff107 test(tui): cover final lifecycle review gaps 2026-08-10 14:43:03 -07:00
lawrencecchen 8570beb24b test(tui): accept indeterminate key delivery 2026-08-10 14:34:50 -07:00
lawrencecchen 4d0b246a6f fix(tui): preserve canonical CLI usage errors 2026-08-10 14:33:18 -07:00
lawrencecchen 54b2e0ef43 test(tui): wait for owner lifecycle readiness 2026-08-10 14:09:57 -07:00
lawrencecchen db9adeff4a fix(tui): satisfy lifecycle clippy gate 2026-08-10 13:50:56 -07:00
austinpower1258 1cf62495dd test: cover rejected explicit input attention paths 2026-08-10 13:49:07 -07:00
lawrencecchen f489d75107 fix(tui): preserve lifecycle JSON error messages 2026-08-10 13:42:01 -07:00
austinpower1258 ae654d497f refactor: centralize restored agent attention cleanup 2026-08-10 13:40:56 -07:00
austinpower1258 95269fc0a2 refactor: derive mobile previews from unread snapshots 2026-08-10 13:40:14 -07:00
austinpower1258 a9b7f8aa03 Test Dock browser focus precedence 2026-08-10 13:35:48 -07:00
austinpower1258 b6bd69f149 Merge remote-tracking branch 'origin/main' into issue-9466-stage-manager-raise
# Conflicts:
#	Sources/DockSplitStore+RestoredAgentLifecycle.swift
#	Sources/DockSplitStore+SessionRestore.swift
#	Sources/DockSplitStore+SessionSnapshot.swift
#	Sources/DockSplitStore+SurfaceTransfer.swift
#	Sources/DockSplitStore.swift
2026-08-10 13:31:42 -07:00
austinpower1258 eccd9a4155 fix: preserve explicit input attention ownership 2026-08-10 13:23:53 -07:00
lawrencecchen e06e93d7e9 test(tui): use current lifecycle resource protocol 2026-08-10 13:21:52 -07:00
lawrencecchen a4db2d196c test(tui): preserve lifecycle JSON errors 2026-08-10 12:59:29 -07:00
austinpower1258 17c4938616 ci: retain app-hosted Swift test counts 2026-08-10 12:59:26 -07:00
lawrencecchen ff5ae4206d style(tui): format lifecycle response handling 2026-08-10 12:52:05 -07:00
lawrencecchen 8aa038b557 fix(tui): validate lifecycle response contracts 2026-08-10 12:47:16 -07:00
lawrencecchen 127d6732d4 test(tui): cover lifecycle response review gaps 2026-08-10 12:26:11 -07:00
lawrencecchen 2d7c6aff44 fix(tui): preserve relay helper argument literals 2026-08-10 12:13:29 -07:00
lawrencecchen 35c79f35ae style(tui): format relay helper regression 2026-08-10 11:53:29 -07:00
lawrencecchen 9f6e143215 test(tui): preserve relay helper argument literals 2026-08-10 11:49:36 -07:00
lawrencecchen 67becf8434 fix(tui): close lifecycle review gaps 2026-08-10 11:42:37 -07:00
lawrencecchen 8362055f97 test(tui): cover remaining lifecycle review gaps 2026-08-10 11:20:25 -07:00
lawrencecchen e5c3b310fa fix(tui): keep daemon handoff ack-ordered 2026-08-10 11:08:51 -07:00
lawrencecchen c792e7e948 fix(tui): preserve shared mux shutdown access 2026-08-10 10:52:19 -07:00
lawrencecchen 328af43cfd Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar
# Conflicts:
#	cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
#	cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
#	cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/go/raw/generated_commands.go
#	cmux-tui/bindings/go/raw/generated_events.go
#	cmux-tui/bindings/go/raw/generated_metadata.go
#	cmux-tui/bindings/go/raw/generated_presence_test.go
#	cmux-tui/bindings/go/raw/generated_types.go
#	cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java
#	cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/python/cmux/raw/_generated/_schema.py
#	cmux-tui/bindings/python/cmux/raw/_generated/metadata.py
#	cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/rust/src/generated/commands.rs
#	cmux-tui/bindings/rust/src/generated/events.rs
#	cmux-tui/bindings/rust/src/generated/metadata.rs
#	cmux-tui/bindings/rust/src/generated/mod.rs
#	cmux-tui/bindings/rust/src/generated/types.rs
#	cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/typescript/src/raw/generated/commands.ts
#	cmux-tui/bindings/typescript/src/raw/generated/events.ts
#	cmux-tui/bindings/typescript/src/raw/generated/index.ts
#	cmux-tui/bindings/typescript/src/raw/generated/metadata.ts
#	cmux-tui/bindings/typescript/src/raw/generated/types.ts
#	cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/zig/src/raw/generated/protocol.zig
#	cmux-tui/crates/cmux-tui-core/src/mux.rs
#	cmux-tui/crates/cmux-tui-core/src/server.rs
#	cmux-tui/crates/cmux-tui/src/app.rs
#	cmux-tui/crates/cmux-tui/src/cli.rs
#	cmux-tui/crates/cmux-tui/src/cli/command.rs
#	cmux-tui/crates/cmux-tui/src/main.rs
#	cmux-tui/spec/cli.md
2026-08-10 10:43:58 -07:00
lawrencecchen f182f3a016 fix(tui): preserve resource option values 2026-08-10 10:30:56 -07:00
lawrencecchen 9d87c3b5a5 style(tui): format resource value regression 2026-08-10 10:13:19 -07:00
lawrencecchen f0c2f7aeb9 test(tui): preserve relay literals in resource values 2026-08-10 10:07:17 -07:00
Lawrence Chen ada6abce88 Merge pull request #9726 from manaflow-ai/codex/cmux-browser-provider
cmux-tui: integrate journaling with the native browser provider
2026-08-10 10:04:18 -07:00
lawrencecchen 9f818adaf5 fix(tui): close lifecycle routing gaps 2026-08-10 09:40:54 -07:00
lawrencecchen abc85ab064 test(tui): cover paused resource lifecycle routes 2026-08-10 09:21:29 -07:00
lawrencecchen 80c70ba26e test(tui): await exact descendant readiness 2026-08-10 08:38:31 -07:00
lawrencecchen 83246496ce fix(tui): keep relay validation outside resource inventory 2026-08-10 08:37:03 -07:00
lawrencecchen 8c27a7e78c style(tui): use imported io error 2026-08-10 07:53:47 -07:00
lawrencecchen e939423128 test(tui): accept completed descendant cleanup 2026-08-10 07:42:58 -07:00
lawrencecchen 34ea6cc32e fix(tui): reject relay tickets before help 2026-08-10 07:41:45 -07:00
lawrencecchen 497dd98555 test(tui): match macOS hook fork denial 2026-08-10 07:14:09 -07:00
lawrencecchen a197119f6a style(tui): format reload completion errors 2026-08-10 06:48:20 -07:00
lawrencecchen d9f93ce0f9 fix(tui): make owner reload completion authoritative 2026-08-10 06:35:59 -07:00
lawrencecchen 7408018f9e style(tui): apply hosted producer rustfmt 2026-08-10 06:33:35 -07:00
lawrencecchen ca6971c881 test(tui): prove producer deadline rollback 2026-08-10 06:20:35 -07:00
lawrencecchen f221b49e25 test(tui): assert admitted journal identity 2026-08-10 06:18:23 -07:00
lawrencecchen 3ff17062c4 style(tui): format reload ownership test 2026-08-10 06:16:50 -07:00
lawrencecchen 99db4f0481 test(tui): expose reload ownership races 2026-08-10 06:10:35 -07:00
lawrencecchen 23bd7e5c92 fix(tui): publish lifecycle readiness in SDK schema 2026-08-10 05:58:37 -07:00
lawrencecchen 252eccb7da test(tui): activate checkpoint ingress fence 2026-08-10 05:58:18 -07:00
lawrencecchen db46de7776 style(tui): format lifecycle identify tests 2026-08-10 05:41:10 -07:00
lawrencecchen 508bffabcd fix(tui): publish lifecycle owner readiness 2026-08-10 05:37:10 -07:00
lawrencecchen e2cea2560a style(tui): apply hosted process fence rustfmt 2026-08-10 05:30:38 -07:00
lawrencecchen ae67462a7c fix(tui): close hosted process fence races 2026-08-10 05:22:58 -07:00
lawrencecchen 5f80c59eb9 style(tui): format lifecycle regression tests 2026-08-10 05:02:24 -07:00
lawrencecchen e09baa7ccd test(tui): cover lifecycle readiness receipts 2026-08-10 04:53:05 -07:00
lawrencecchen a9e6efc583 test(tui): use one-shot mac fork probe 2026-08-10 04:43:58 -07:00
lawrencecchen e63cd0b740 fix(tui): close exact-head lifecycle findings 2026-08-10 04:41:35 -07:00
lawrencecchen 9a99daa72a test(tui): repair lifecycle readiness harness 2026-08-10 04:29:14 -07:00
lawrencecchen 30ebe1c089 test(tui): bound mac process fence proof 2026-08-10 04:28:53 -07:00
lawrencecchen d2384c596c fix(tui): expose atomic handoff commit internally 2026-08-10 04:17:29 -07:00
lawrencecchen 655ca1401b style(tui): apply hosted lifecycle rustfmt 2026-08-10 04:09:09 -07:00
lawrencecchen dbaaee1817 test(tui): cover final lifecycle review gaps 2026-08-10 04:01:51 -07:00
lawrencecchen 78681b5e36 fix(tui): synchronize server lifecycle readiness 2026-08-10 03:54:40 -07:00
lawrencecchen 8a1ca9ae32 test(tui): cover lifecycle completion races 2026-08-10 03:49:10 -07:00
lawrencecchen 3d43760a69 fix(tui): prioritize inline relay secret rejection 2026-08-10 03:37:53 -07:00
lawrencecchen 849f9d86a6 Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider 2026-08-10 03:33:48 -07:00
lawrencecchen 4f63aa20e2 fix(tui): close hosted root verification gaps 2026-08-10 03:33:31 -07:00
lawrencecchen 23db759f8e Merge current main into task-cli-lifecycle-grammar 2026-08-10 02:38:10 -07:00
lawrencecchen 0968d0bdfe test(tui): harden lifecycle failure harness 2026-08-10 02:36:48 -07:00
lawrencecchen 95873a15d7 test(tui): qualify owner reload worker 2026-08-10 02:27:21 -07:00
Lawrence Chen cd15399d6c Merge pull request #9908 from manaflow-ai/fix-tui-windows-rustfmt
Apply Rust formatting to Windows cmux-tui fixes
2026-08-10 02:25:52 -07:00
lawrencecchen 911f16df14 style(tui): apply hosted rustfmt 2026-08-10 01:41:53 -07:00
lawrencecchen fbb2a1c405 style(tui): apply Rust formatting to Windows fixes 2026-08-10 01:28:55 -07:00
lawrencecchen 09974bab6a Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider 2026-08-10 01:15:53 -07:00
lawrencecchen 685d6538b1 Merge current main into task-cli-lifecycle-grammar 2026-08-10 01:09:57 -07:00
lawrencecchen e79a34bb5e fix(tui): close lifecycle startup races 2026-08-10 01:06:55 -07:00
lawrencecchen 7d6ba0868c style(tui): apply hosted long-path rustfmt 2026-08-10 01:01:20 -07:00
Lawrence Chen e49e7cdf30 Merge pull request #9904 from manaflow-ai/test-tui-windows-conpty-resize
Add valid Windows ConPTY resize coverage
2026-08-10 00:50:36 -07:00
lawrencecchen 9d79d919c8 test(tui): cover lifecycle startup races 2026-08-10 00:32:14 -07:00
lawrencecchen a4d3865ee6 ci(tui): use available hosted Windows Python 2026-08-10 00:06:46 -07:00
lawrencecchen 458160affa Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider 2026-08-10 00:01:54 -07:00
lawrencecchen 3c4d22c8b8 style(tui): apply hosted rustfmt 2026-08-09 23:48:15 -07:00
lawrencecchen 4ca8ca40b5 Merge remote-tracking branch 'origin/main' into test-tui-windows-conpty-resize
# Conflicts:
#	.github/workflows/cmux-tui-build-package.yml
2026-08-09 23:47:29 -07:00
Lawrence Chen 3e63708c1e Merge pull request #9906 from manaflow-ai/fix-tui-windows-long-state-path-v2
Support long Windows cmux-tui state paths
2026-08-09 23:46:45 -07:00
lawrencecchen 5cadd65aed style(tui): apply hosted Windows exit rustfmt 2026-08-09 23:42:52 -07:00
lawrencecchen df15557435 fix(tui): sanitize workspace state path errors 2026-08-09 23:33:42 -07:00
lawrencecchen 10d75472a8 docs(tui): correct raw protocol inventory 2026-08-09 23:27:40 -07:00
lawrencecchen dd75b8f61b fix(tui): close lifecycle review gaps 2026-08-09 23:26:54 -07:00
lawrencecchen 62068dd8a8 test(tui): validate Windows workspace list output 2026-08-09 23:25:23 -07:00
lawrencecchen 8a5abc14ae test(tui): cover lifecycle closeout review paths 2026-08-09 23:24:20 -07:00
lawrencecchen 70b8d86390 test(tui): harden Windows long-path smoke 2026-08-09 23:22:30 -07:00
lawrencecchen 7cc93320ff Merge origin/main into codex/cmux-browser-provider 2026-08-09 23:16:26 -07:00
lawrencecchen 7c43264f53 Merge current main into task-cli-lifecycle-grammar 2026-08-09 23:13:49 -07:00
lawrencecchen aee20dcdce fix(tui): use SQLite long-path VFS on Windows 2026-08-09 23:13:43 -07:00
lawrencecchen 6e872e9072 fix(tui): harden lifecycle shutdown output 2026-08-09 23:12:13 -07:00
lawrencecchen 8b3ab77560 test(tui): cover SQLite long VFS byte boundary 2026-08-09 23:11:56 -07:00
lawrencecchen 1d34506b10 test(tui): cover lifecycle closeout edge cases 2026-08-09 23:09:59 -07:00
lawrencecchen 239552386c Merge remote-tracking branch 'origin/main' into fix-tui-windows-long-state-path-v2
# Conflicts:
#	.github/workflows/cmux-tui-build-package.yml
2026-08-09 23:05:46 -07:00
Lawrence Chen a35880b103 Merge pull request #9905 from manaflow-ai/fix-tui-windows-process-wait-v2
Fix Windows terminal exit publication
2026-08-09 23:05:17 -07:00
lawrencecchen 1e6179718c test(tui): make ConPTY smoke failures reliable 2026-08-09 23:02:32 -07:00
lawrencecchen 59ea6383c8 ci(tui): pin Windows ConPTY smoke dependencies 2026-08-09 23:00:15 -07:00
lawrencecchen 4f5e5e1f12 fix(tui): satisfy hosted owner lint 2026-08-09 23:00:02 -07:00
lawrencecchen 9a480effec fix(tui): keep owner shutdown bound across machines 2026-08-09 22:58:55 -07:00
lawrencecchen fdd00b22aa Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider 2026-08-09 22:54:48 -07:00
lawrencecchen 955909df09 style(tui): apply hosted browser rustfmt 2026-08-09 22:52:42 -07:00
lawrencecchen e498eff0d1 fix(tui): use extended Windows SQLite paths 2026-08-09 22:50:20 -07:00
lawrencecchen 7fdde87b32 test(tui): cover long Windows state paths 2026-08-09 22:50:20 -07:00
lawrencecchen b339743674 fix(tui): publish Windows terminal exits 2026-08-09 22:50:18 -07:00
lawrencecchen 047066d3f6 test(tui): cover Windows terminal process exit 2026-08-09 22:50:18 -07:00
lawrencecchen 9352051a73 test(tui): cover Windows ConPTY resize 2026-08-09 22:50:15 -07:00
lawrencecchen 3fcc3ef369 fix(tui): satisfy hosted shutdown checks 2026-08-09 22:49:02 -07:00
Lawrence Chen be29dfa7c5 Merge pull request #9901 from manaflow-ai/fix-tui-windows-gnu-link
Fix Windows GNU cmux-tui linking with LLD
2026-08-09 22:48:24 -07:00
lawrencecchen b81620ae71 fix(tui): close journal and provider ownership 2026-08-09 22:42:52 -07:00
lawrencecchen da3cee0f11 style(tui): apply hosted rustfmt 2026-08-09 22:41:32 -07:00
lawrencecchen 9fe3649238 fix(cli): route start options by grammar 2026-08-09 22:36:38 -07:00
lawrencecchen b72d652a7c fix(cli): keep accepted shutdown fenced 2026-08-09 22:30:15 -07:00
lawrencecchen 1c8cb0a238 fix(cli): preserve start help during validation 2026-08-09 22:19:59 -07:00
lawrencecchen 121602ef74 fix(tui): borrow process scope program 2026-08-09 22:16:18 -07:00
lawrencecchen 5e2d5d2e01 fix(cli): close lifecycle review races 2026-08-09 22:15:31 -07:00
lawrencecchen c6b9d23bb9 test(cli): cover review lifecycle races 2026-08-09 22:12:07 -07:00
lawrencecchen 169a0c2f94 test(tui): move final journal test values 2026-08-09 22:04:39 -07:00
lawrencecchen 081ad824ac test(cli): import owner-loop deadline type 2026-08-09 22:04:00 -07:00
lawrencecchen e3df6e1aa0 refactor(cli): remove stale session binding 2026-08-09 22:02:03 -07:00
lawrencecchen 9cc12dc6d2 fix(cli): redact cross-session lifecycle errors 2026-08-09 21:57:36 -07:00
lawrencecchen 2aaa893f6f test(tui): correct shutdown completion channel 2026-08-09 21:57:17 -07:00
lawrencecchen 5d7248ed8e test(cli): keep cross-session errors private 2026-08-09 21:56:42 -07:00
lawrencecchen 9fda05344b Merge remote-tracking branch 'origin/main' into fix-tui-windows-gnu-link
# Conflicts:
#	.github/workflows/cmux-tui.yml
2026-08-09 21:54:22 -07:00
lawrencecchen 2e6f08f248 fix(cli): keep rejected relay tickets private 2026-08-09 21:52:15 -07:00
lawrencecchen 74705bedc7 test(cli): cover server start secret rejection 2026-08-09 21:51:08 -07:00
lawrencecchen 14c9184217 style(tui): apply hosted journal rustfmt 2026-08-09 21:49:13 -07:00
lawrencecchen c178ff48ef fix(cli): repair hosted lifecycle gates 2026-08-09 21:48:49 -07:00
lawrencecchen dfd8947e89 Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider
# Conflicts:
#	.github/workflows/cmux-tui.yml
2026-08-09 21:43:40 -07:00
lawrencecchen e8579cf232 fix(tui): close final journal ownership races 2026-08-09 21:40:58 -07:00
lawrencecchen 9ba9f6bbac test(tui): expose final journal ownership races 2026-08-09 21:39:26 -07:00
lawrencecchen 1754fff692 style(cli): apply hosted rustfmt output 2026-08-09 21:37:56 -07:00
lawrencecchen a20f6f677a test(cli): replace lifecycle readiness sleeps 2026-08-09 21:30:57 -07:00
lawrencecchen 1a4c8f09e0 fix(cli): repair lifecycle conformance build 2026-08-09 21:30:46 -07:00
lawrencecchen b28463485e test(tui): run GNU app smoke after link 2026-08-09 21:30:33 -07:00
lawrencecchen 1b0400aee6 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/event_bus.rs
#	cmux-tui/crates/cmux-tui/tests/cli.rs
2026-08-09 21:28:04 -07:00
Lawrence Chen 1097eab636 Merge pull request #9837 from manaflow-ai/task-hosted-cmux-tui-verification
Add hosted cmux-tui verification
2026-08-09 21:26:58 -07:00
lawrencecchen a3cfc7fc0a Preserve terminals when closing views 2026-08-09 20:53:08 -07:00
lawrencecchen 259ef2e995 Merge remote-tracking branch 'origin/main' into fix-tui-windows-gnu-link 2026-08-09 20:51:47 -07:00
lawrencecchen 4008f042d6 Isolate cmux-tui core tests in hosted CI 2026-08-09 20:50:04 -07:00
lawrencecchen e8ce3f4c48 Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider 2026-08-09 20:46:26 -07:00
lawrencecchen 1be761a0eb fix(tui): fence journal shutdown ownership 2026-08-09 20:45:48 -07:00
lawrencecchen 766c8a11dc Fix terminal tombstone projections 2026-08-09 20:45:13 -07:00
lawrencecchen 29df8c1ae9 Merge remote-tracking branch 'origin/main' into task-hosted-cmux-tui-verification 2026-08-09 20:44:19 -07:00
lawrencecchen 454c0c8282 test(tui): run focused Windows GNU link probe 2026-08-09 20:42:43 -07:00
Lawrence Chen a5752bbed3 Brand account UI as cmux (#9833)
* Brand account UI as cmux

* Remove vendor term from account search metadata

* Revert "Remove vendor term from account search metadata"

This reverts commit 8d008f2514.
2026-08-09 20:42:05 -07:00
lawrencecchen ece3c26335 fix(tui): link Windows GNU gates with LLD 2026-08-09 20:40:23 -07:00
lawrencecchen 5de6ab3253 Merge remote-tracking branch 'origin/main' into task-hosted-cmux-tui-verification 2026-08-09 20:38:19 -07:00
lawrencecchen 35d4a9c6ec Complete hosted merge gate coverage 2026-08-09 20:36:42 -07:00
lawrencecchen b5649a0544 fix(tui): rescan GNU runtime after stack probe 2026-08-09 20:30:27 -07:00
lawrencecchen 8f61b66ccf test(tui): verify mac process launcher fence 2026-08-09 20:30:08 -07:00
Lawrence Chen dfe324d4cc Merge pull request #8991 from manaflow-ai/feat-tui-sdk-stack
Generate and verify seven cmux-tui SDKs
2026-08-09 20:26:44 -07:00
lawrencecchen a4b3be44cc Harden hosted verification checks 2026-08-09 20:21:04 -07:00
lawrencecchen d832d2597e ci(tui): prove process fences before shutdown 2026-08-09 20:20:23 -07:00
lawrencecchen 5c469e03d6 ci(tui): scope Windows GNU test linking 2026-08-09 20:19:30 -07:00
lawrencecchen 3a91bda158 ci(tui): run focused journal ownership tests 2026-08-09 20:10:52 -07:00
lawrencecchen c2cb2528d6 test(tui): cover mac process-scope fence 2026-08-09 20:08:18 -07:00
lawrencecchen f5cb93cdae Fix exact hosted verification gate 2026-08-09 20:07:44 -07:00
lawrencecchen 4a678e23e3 fix(tui): require GNU stack-probe runtime 2026-08-09 20:01:11 -07:00
lawrencecchen 1a0aeacbb6 Preserve stream read deadline across messages 2026-08-09 19:56:22 -07:00
lawrencecchen fafde2b494 test(tui): pass hook signal as owned descriptor 2026-08-09 19:54:33 -07:00
lawrencecchen 81e5677906 Merge remote-tracking branch 'origin/main' into closeout-pr9837-20260809
# Conflicts:
#	cmux-tui/crates/cmux-terminal-client/src/lib.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs
#	cmux-tui/crates/cmux-tui/src/machine_agent/runtime.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
#	cmux-tui/crates/cmux-tui/tests/cli.rs
2026-08-09 19:49:59 -07:00
lawrencecchen 6e74c1cccd style(tui): apply hosted import format 2026-08-09 19:44:53 -07:00
lawrencecchen cef5714ae1 Avoid try-with-resources close warning 2026-08-09 19:44:09 -07:00
lawrencecchen 9a7d2eaa84 test(tui): require Windows GNU test linking 2026-08-09 19:42:23 -07:00
lawrencecchen 0f10feea9f test(tui): make journal event assertion borrow explicit 2026-08-09 19:40:49 -07:00
lawrencecchen b6dae65692 Clean up reader threads before readiness assertions 2026-08-09 19:40:42 -07:00
lawrencecchen f481c42d9a fix(tui): reconcile hosted journal test prerequisites 2026-08-09 19:39:23 -07:00
lawrencecchen 8841dfca00 fix(tui): remove stale hook writer import 2026-08-09 19:31:10 -07:00
lawrencecchen 32bb6d1c0e test(tui): satisfy hosted Rust loop lint 2026-08-09 19:24:29 -07:00
lawrencecchen 1b9a1ab9e9 Merge remote-tracking branch 'origin/main' into feat-tui-sdk-stack 2026-08-09 19:23:29 -07:00
lawrencecchen d44ed5228c fix(tui): keep hosted process tests warning-free 2026-08-09 19:16:42 -07:00
Lawrence Chen 8d9aaef703 Update Ghostty for correct VT cursor replay (#9877)
* Update Ghostty replay fix pin

* Pin GhosttyKit replay archive checksum
2026-08-09 19:14:55 -07:00
lawrencecchen c2e75449cd Merge remote-tracking branch 'origin/main' into feat-tui-sdk-stack 2026-08-09 19:08:06 -07:00
lawrencecchen 8f87e780d9 style(tui): apply hosted process-scope format 2026-08-09 19:06:09 -07:00
lawrencecchen 4699129769 fix(tui): reconcile hosted root prerequisites 2026-08-09 19:02:13 -07:00
Austin Wang 1d6910d87d Merge pull request #9754 from manaflow-ai/issue-9504-dragsplit-focused-cursor
Fix drag-to-split cursor focus reconciliation
2026-08-09 18:59:02 -07:00
lawrencecchen 7a3d00646d test(tui): cover process and writer shutdown escapes 2026-08-09 18:58:54 -07:00
Austin Wang bf903a0b28 Merge pull request #9358 from manaflow-ai/issue-9220-sidebar-click-hang
Fix sidebar-triggered main-actor terminal teardown hang
2026-08-09 18:58:32 -07:00
austinpower1258 8fec712fe5 test: isolate Dock pointer focus suite 2026-08-09 18:56:58 -07:00
Austin Wang a23d39612d Merge pull request #9764 from manaflow-ai/issue-9588-lightmode-text-contrast
Fix surface-scoped terminal theme reload ordering
2026-08-09 18:55:53 -07:00
Austin Wang 209a761923 Merge pull request #9340 from manaflow-ai/issue-9337-dock-terminal-live-title
Fix live titles for Dock terminals
2026-08-09 18:55:29 -07:00
lawrencecchen 8220a78eb6 Merge remote-tracking branch 'origin/main' into feat-tui-sdk-stack 2026-08-09 18:40:26 -07:00
austinpower1258 4a126b196b test: isolate captured right-click regression 2026-08-09 18:37:21 -07:00
Austin Wang 957481cf73 Merge pull request #9540 from manaflow-ai/issue-9520-hermes-first-class
Add first-class Hermes restore and lifecycle support
2026-08-09 18:36:34 -07:00
lawrencecchen a95a2612f8 Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider
# Conflicts:
#	cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
#	cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
#	cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/go/raw/generated_commands.go
#	cmux-tui/bindings/go/raw/generated_events.go
#	cmux-tui/bindings/go/raw/generated_metadata.go
#	cmux-tui/bindings/go/raw/generated_presence_test.go
#	cmux-tui/bindings/go/raw/generated_types.go
#	cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java
#	cmux-tui/bindings/java/tests/com/cmux/raw/GeneratedCoverageTest.java
#	cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/python/cmux/raw/_generated/_schema.py
#	cmux-tui/bindings/python/cmux/raw/_generated/metadata.py
#	cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/rust/src/generated/commands.rs
#	cmux-tui/bindings/rust/src/generated/events.rs
#	cmux-tui/bindings/rust/src/generated/metadata.rs
#	cmux-tui/bindings/rust/src/generated/mod.rs
#	cmux-tui/bindings/rust/src/generated/types.rs
#	cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/typescript/src/raw/generated/commands.ts
#	cmux-tui/bindings/typescript/src/raw/generated/events.ts
#	cmux-tui/bindings/typescript/src/raw/generated/index.ts
#	cmux-tui/bindings/typescript/src/raw/generated/metadata.ts
#	cmux-tui/bindings/typescript/src/raw/generated/types.ts
#	cmux-tui/bindings/typescript/test/generated.test.ts
#	cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/zig/src/raw/generated/protocol.zig
#	cmux-tui/crates/cmux-tui-core/src/mux.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs
#	cmux-tui/crates/cmux-tui/src/app.rs
2026-08-09 18:34:36 -07:00
lawrencecchen 9e7d2ab2b7 fix(tui): fence and drain journal process scopes 2026-08-09 18:29:59 -07:00
Austin Wang d13b4090dd Merge pull request #9759 from manaflow-ai/issue-9648-diffviewer-deadlock
Fix diff viewer scheme-handler deadlock
2026-08-09 18:27:51 -07:00
lawrencecchen 81d9090bd1 Make C++ receive signal idempotent 2026-08-09 18:27:26 -07:00
lawrencecchen b5d3a6ef23 Merge remote-tracking branch 'origin/main' into feat-tui-sdk-stack 2026-08-09 18:17:26 -07:00
lawrencecchen e9c67185be Close Java connection after write failure 2026-08-09 18:16:57 -07:00
lawrencecchen 99c7fc2aab fix(tui): scope macOS process metadata import 2026-08-09 18:14:31 -07:00
Lawrence Chen 33b588e993 Merge pull request #9447 from manaflow-ai/feat-tui-resource-columns
Add configurable native TUI resource views
2026-08-09 18:14:05 -07:00
lawrencecchen dda8c8f2a5 fix(tui): preserve process identity through cleanup 2026-08-09 18:12:21 -07:00
lawrencecchen 1d24221e91 Bound Java commands by one deadline 2026-08-09 18:07:49 -07:00
lawrencecchen baefeaaee8 Address deterministic wait review findings 2026-08-09 17:56:36 -07:00
lawrencecchen 13718cfe84 style(tui): apply hosted journal rustfmt 2026-08-09 17:56:10 -07:00
lawrencecchen 0247bf8734 test(tui): signal journal cursor validation 2026-08-09 17:54:36 -07:00
lawrencecchen de09a97732 fix(tui): drain journal shutdown and cleanup scans 2026-08-09 17:50:40 -07:00
austinpower1258 de986cedb0 test: await right-click runtime readiness 2026-08-09 17:48:38 -07:00
austinpower1258 6c29d0f5ff test: keep CLI socket fixtures off shared queues 2026-08-09 17:47:34 -07:00
lawrencecchen ef81dd215f Merge remote-tracking branch 'origin/main' into feat-tui-sdk-stack 2026-08-09 17:46:02 -07:00
Lawrence Chen 9d9ff9756d Merge pull request #9878 from manaflow-ai/feat-ghostty-pointer-compat
Restore compatible Ghostty pointer
2026-08-09 17:45:27 -07:00
austinpower1258 7fd62a813f test: keep font shortcut fixture package-safe 2026-08-09 17:31:49 -07:00
lawrencecchen 8207293067 style(tui): apply hosted journal rustfmt 2026-08-09 17:31:46 -07:00
lawrencecchen a8e3263013 fix(tui): fence journal shutdown ownership 2026-08-09 17:26:07 -07:00
lawrencecchen d73de828b3 Restore compatible Ghostty pointer 2026-08-09 17:25:44 -07:00
austinpower1258 f081144aef test: avoid starving CLI exit waits 2026-08-09 17:12:42 -07:00
lawrencecchen 2abcd55356 fix(tui): clear hosted journal diagnostics 2026-08-09 17:09:23 -07:00
lawrencecchen 06267609e3 Merge remote-tracking branch 'origin/main' into feat-tui-sdk-stack
# Conflicts:
#	.github/workflows/cmux-tui-sdks.yml
#	.github/workflows/cmux-tui-spec.yml
#	.github/workflows/sdk-publish-crates.yml
#	.github/workflows/sdk-publish-go.yml
#	.github/workflows/sdk-publish-java.yml
#	.github/workflows/sdk-publish-npm.yml
#	.github/workflows/sdk-publish-python.yml
#	cmux-tui/Cargo.lock
#	cmux-tui/bindings/ERGONOMICS.md
#	cmux-tui/bindings/RELEASING.md
#	cmux-tui/bindings/check-versions.py
#	cmux-tui/bindings/codegen/emit_cpp.py
#	cmux-tui/bindings/codegen/emit_go.py
#	cmux-tui/bindings/codegen/emit_java.py
#	cmux-tui/bindings/codegen/emit_python.py
#	cmux-tui/bindings/codegen/emit_typescript.py
#	cmux-tui/bindings/codegen/emit_zig.py
#	cmux-tui/bindings/codegen/tests/test_emit_cpp.py
#	cmux-tui/bindings/codegen/tests/test_emit_go.py
#	cmux-tui/bindings/codegen/tests/test_generate.py
#	cmux-tui/bindings/codegen/tests/test_ir.py
#	cmux-tui/bindings/conformance/README.md
#	cmux-tui/bindings/conformance/adapter-protocol.md
#	cmux-tui/bindings/conformance/adapters/cpp/CMakeLists.txt
#	cmux-tui/bindings/conformance/adapters/cpp/main.cpp
#	cmux-tui/bindings/conformance/adapters/go/main.go
#	cmux-tui/bindings/conformance/adapters/java/build.sh
#	cmux-tui/bindings/conformance/adapters/python/adapter.py
#	cmux-tui/bindings/conformance/adapters/rust/Cargo.lock
#	cmux-tui/bindings/conformance/adapters/rust/Cargo.toml
#	cmux-tui/bindings/conformance/adapters/rust/src/main.rs
#	cmux-tui/bindings/conformance/adapters/typescript/adapter.mjs
#	cmux-tui/bindings/conformance/adapters/zig/build.zig
#	cmux-tui/bindings/conformance/adapters/zig/main.zig
#	cmux-tui/bindings/conformance/fixtures.json
#	cmux-tui/bindings/conformance/runner.py
#	cmux-tui/bindings/conformance/test_runner.py
#	cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
#	cmux-tui/bindings/cpp/CMakeLists.txt
#	cmux-tui/bindings/cpp/README.md
#	cmux-tui/bindings/cpp/include/cmux/client.hpp
#	cmux-tui/bindings/cpp/include/cmux/result.hpp
#	cmux-tui/bindings/cpp/include/cmux/transport.hpp
#	cmux-tui/bindings/cpp/include/cmux/version.hpp
#	cmux-tui/bindings/cpp/src/json.cpp
#	cmux-tui/bindings/cpp/src/unix_transport.cpp
#	cmux-tui/bindings/cpp/tests/consumer/CMakeLists.txt
#	cmux-tui/bindings/cpp/tests/consumer/main.cpp
#	cmux-tui/bindings/cpp/tests/test_attachment.cpp
#	cmux-tui/bindings/cpp/tests/test_client.cpp
#	cmux-tui/bindings/cpp/tests/test_generated.cpp
#	cmux-tui/bindings/cpp/tests/test_macro_collision.cpp
#	cmux-tui/bindings/cpp/tests/test_unix_transport.cpp
#	cmux-tui/bindings/examples/cpp-terminal-frontend/CMakeLists.txt
#	cmux-tui/bindings/examples/cpp-terminal-frontend/FRICTION.md
#	cmux-tui/bindings/examples/cpp-terminal-frontend/README.md
#	cmux-tui/bindings/examples/cpp-terminal-frontend/include/cmux_example/frontend.hpp
#	cmux-tui/bindings/examples/cpp-terminal-frontend/src/frontend.cpp
#	cmux-tui/bindings/examples/cpp-terminal-frontend/src/main.cpp
#	cmux-tui/bindings/examples/cpp-terminal-frontend/tests/frontend_test.cpp
#	cmux-tui/bindings/examples/go-terminal-bot/FRICTION.md
#	cmux-tui/bindings/examples/go-terminal-bot/README.md
#	cmux-tui/bindings/examples/go-terminal-bot/bot.go
#	cmux-tui/bindings/examples/go-terminal-bot/bot_integration_test.go
#	cmux-tui/bindings/examples/go-terminal-bot/cmd/go-terminal-bot/main.go
#	cmux-tui/bindings/examples/go-terminal-bot/config.go
#	cmux-tui/bindings/examples/go-terminal-bot/fake_server_test.go
#	cmux-tui/bindings/examples/go-terminal-bot/result.go
#	cmux-tui/bindings/examples/java-ci-orchestrator/FRICTION.md
#	cmux-tui/bindings/examples/java-ci-orchestrator/README.md
#	cmux-tui/bindings/examples/java-ci-orchestrator/src/com/cmux/examples/ci/CiOrchestrator.java
#	cmux-tui/bindings/examples/java-ci-orchestrator/tests/com/cmux/examples/ci/CiOrchestratorIntegrationTest.java
#	cmux-tui/bindings/examples/java-ci-orchestrator/tests/com/cmux/examples/ci/FakeCmuxServer.java
#	cmux-tui/bindings/examples/python-agent-watchdog/FRICTION.md
#	cmux-tui/bindings/examples/python-agent-watchdog/README.md
#	cmux-tui/bindings/examples/python-agent-watchdog/tests/test_watchdog.py
#	cmux-tui/bindings/examples/python-agent-watchdog/watchdog.py
#	cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.lock
#	cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.toml
#	cmux-tui/bindings/examples/rust-agent-dashboard/FRICTION.md
#	cmux-tui/bindings/examples/rust-agent-dashboard/README.md
#	cmux-tui/bindings/examples/rust-agent-dashboard/src/lib.rs
#	cmux-tui/bindings/examples/rust-agent-dashboard/src/main.rs
#	cmux-tui/bindings/examples/rust-agent-dashboard/src/model.rs
#	cmux-tui/bindings/examples/rust-agent-dashboard/tests/fake_server.rs
#	cmux-tui/bindings/examples/typescript-browser-controller/FRICTION.md
#	cmux-tui/bindings/examples/typescript-browser-controller/README.md
#	cmux-tui/bindings/examples/typescript-browser-controller/package-lock.json
#	cmux-tui/bindings/examples/typescript-browser-controller/package.json
#	cmux-tui/bindings/examples/typescript-browser-controller/scripts/verify-clean-linked-sdk.mjs
#	cmux-tui/bindings/examples/typescript-browser-controller/scripts/verify-packaged-consumer.mjs
#	cmux-tui/bindings/examples/typescript-browser-controller/src/controller.ts
#	cmux-tui/bindings/examples/typescript-browser-controller/src/node-demo.ts
#	cmux-tui/bindings/examples/typescript-browser-controller/src/websocket.ts
#	cmux-tui/bindings/examples/typescript-browser-controller/test/controller.test.ts
#	cmux-tui/bindings/examples/typescript-browser-controller/test/websocket.test.ts
#	cmux-tui/bindings/go/README.md
#	cmux-tui/bindings/go/client.go
#	cmux-tui/bindings/go/client_test.go
#	cmux-tui/bindings/java/README.md
#	cmux-tui/bindings/java/consumer-tests/com/cmux/consumer/ExternalJarConsumerTest.java
#	cmux-tui/bindings/java/pom.xml
#	cmux-tui/bindings/java/scripts/test.sh
#	cmux-tui/bindings/java/src/com/cmux/CmuxClient.java
#	cmux-tui/bindings/java/src/com/cmux/CmuxCommandException.java
#	cmux-tui/bindings/java/src/com/cmux/CmuxDecodeException.java
#	cmux-tui/bindings/java/src/com/cmux/JsonException.java
#	cmux-tui/bindings/java/src/com/cmux/Pane.java
#	cmux-tui/bindings/java/src/com/cmux/Screen.java
#	cmux-tui/bindings/java/src/com/cmux/Tab.java
#	cmux-tui/bindings/java/src/com/cmux/Workspace.java
#	cmux-tui/bindings/python/README.md
#	cmux-tui/bindings/python/cmux/__init__.py
#	cmux-tui/bindings/python/cmux/client.py
#	cmux-tui/bindings/python/cmux/errors.py
#	cmux-tui/bindings/python/cmux/transport.py
#	cmux-tui/bindings/python/pyproject.toml
#	cmux-tui/bindings/python/tests/support.py
#	cmux-tui/bindings/python/tests/test_consumer.py
#	cmux-tui/bindings/python/tests/test_convenience.py
#	cmux-tui/bindings/python/tests/test_events.py
#	cmux-tui/bindings/python/tests/test_lifecycle.py
#	cmux-tui/bindings/python/tests/test_protocol.py
#	cmux-tui/bindings/python/tests/test_schema_roundtrip.py
#	cmux-tui/bindings/rust/Cargo.toml
#	cmux-tui/bindings/rust/README.md
#	cmux-tui/bindings/rust/examples/e2e.rs
#	cmux-tui/bindings/rust/src/client.rs
#	cmux-tui/bindings/rust/src/codec.rs
#	cmux-tui/bindings/rust/src/convenience.rs
#	cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/rust/src/generated/commands.rs
#	cmux-tui/bindings/rust/src/generated/events.rs
#	cmux-tui/bindings/rust/src/generated/metadata.rs
#	cmux-tui/bindings/rust/src/generated/mod.rs
#	cmux-tui/bindings/rust/src/generated/types.rs
#	cmux-tui/bindings/rust/src/lib.rs
#	cmux-tui/bindings/rust/src/presence.rs
#	cmux-tui/bindings/rust/src/topology.rs
#	cmux-tui/bindings/rust/tests/mock_server.rs
#	cmux-tui/bindings/rust/tests/public_api.rs
#	cmux-tui/bindings/typescript/README.md
#	cmux-tui/bindings/typescript/e2e/e2e.ts
#	cmux-tui/bindings/typescript/package-lock.json
#	cmux-tui/bindings/typescript/package.json
#	cmux-tui/bindings/typescript/scripts/verify-packaged-consumer.mjs
#	cmux-tui/bindings/typescript/src/browser.ts
#	cmux-tui/bindings/typescript/src/client.ts
#	cmux-tui/bindings/typescript/src/errors.ts
#	cmux-tui/bindings/typescript/src/index.ts
#	cmux-tui/bindings/typescript/src/node-client.ts
#	cmux-tui/bindings/typescript/src/node-transport.ts
#	cmux-tui/bindings/typescript/src/protocol/index.ts
#	cmux-tui/bindings/typescript/src/transport-limits.ts
#	cmux-tui/bindings/typescript/src/websocket-transport.ts
#	cmux-tui/bindings/typescript/test/authority.test.ts
#	cmux-tui/bindings/typescript/test/client.test.ts
#	cmux-tui/bindings/typescript/test/field-compatibility.test.ts
#	cmux-tui/bindings/typescript/test/generated.test.ts
#	cmux-tui/bindings/typescript/test/package-consumer.test.ts
#	cmux-tui/bindings/typescript/test/presence.test.ts
#	cmux-tui/bindings/typescript/test/protocol-types.ts
#	cmux-tui/bindings/typescript/test/websocket-transport.test.ts
#	cmux-tui/bindings/zig/README.md
#	cmux-tui/bindings/zig/build.zig
#	cmux-tui/bindings/zig/build.zig.zon
#	cmux-tui/bindings/zig/examples/watch.zig
#	cmux-tui/bindings/zig/src/cmux.zig
#	cmux-tui/scripts/check-sdk-schema.py
#	cmux-tui/scripts/test_check_sdk_schema.py
#	cmux-tui/spec/README.md
#	cmux-tui/spec/bindings.md
#	cmux-tui/spec/sdk-schema.json
2026-08-09 17:08:02 -07:00
lawrencecchen 3e5f5f5485 fix(tui): close journal process scope review gaps 2026-08-09 17:06:30 -07:00
austinpower1258 9f663a85ba test: keep right-click regression deterministic 2026-08-09 17:05:53 -07:00
austinpower1258 88144ebdf1 test: cover accepted attention transitions 2026-08-09 17:01:41 -07:00
austinpower1258 a72ec33b16 refactor: isolate attention ownership types 2026-08-09 16:54:10 -07:00
Lawrence Chen 2e8eedf991 Merge pull request #8856 from manaflow-ai/task-cmux-tui-spec-completeness
Make cmux-tui programmability inventory enforceable
2026-08-09 16:54:07 -07:00
lawrencecchen 1a60931f55 style(tui): apply hosted journal rustfmt 2026-08-09 16:53:39 -07:00
lawrencecchen 092a5f44e9 Merge remote-tracking branch 'origin/main' into feat-pr-8856-closeout
# Conflicts:
#	.github/workflows/cmux-tui-spec.yml
#	cmux-tui/bindings/generate.sh
#	cmux-tui/crates/cmux-tui-core/src/server.rs
#	cmux-tui/crates/cmux-tui/src/app.rs
#	cmux-tui/crates/cmux-tui/src/config.rs
#	cmux-tui/scripts/check-spec-inventory.py
#	cmux-tui/scripts/test_check_spec_inventory.py
#	cmux-tui/spec/README.md
#	cmux-tui/spec/bindings.md
#	cmux-tui/spec/cli.md
#	cmux-tui/spec/commands.md
#	cmux-tui/spec/events.md
#	cmux-tui/spec/frontends.md
#	cmux-tui/spec/inventory.json
#	cmux-tui/spec/inventory.schema.json
#	cmux-tui/spec/machine-agent.md
#	cmux-tui/spec/native-frontend.md
#	cmux-tui/spec/programmability.md
#	cmux-tui/spec/terminal-host.md
2026-08-09 16:52:30 -07:00
lawrencecchen 467c8ccd67 Merge remote sleep audit updates
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/unix_process_scope.rs
2026-08-09 16:47:33 -07:00
lawrencecchen dcc1e243f5 fix(tui): close journal shutdown lifecycle gaps 2026-08-09 16:47:10 -07:00
lawrencecchen 726ff99682 Remove SDK sleep-based synchronization 2026-08-09 16:43:16 -07:00
lawrencecchen 54f3fae4cf style(tui): apply hosted Rust format 2026-08-09 16:41:39 -07:00
austinpower1258 e004773638 fix: refresh and harden diff viewer trust 2026-08-09 16:41:35 -07:00
lawrencecchen 293daadd1d test(tui): replace machine creation sleep 2026-08-09 16:35:36 -07:00
lawrencecchen 561f5abb23 Document bounded process cleanup probe 2026-08-09 16:26:54 -07:00
austinpower1258 d549189e00 test: cover diff viewer trust hardening 2026-08-09 16:23:36 -07:00
lawrencecchen 62a9d5a8e2 test(tui): isolate terminal close CLI contract 2026-08-09 16:21:58 -07:00
lawrencecchen 6117fe34c4 Test detached hook environment replacement 2026-08-09 16:21:53 -07:00
austinpower1258 b264787d04 test: cover deferred diff viewer allowlist growth 2026-08-09 16:19:49 -07:00
lawrencecchen d67ca74f05 Fence journal shutdown and reconnect recovery 2026-08-09 16:14:18 -07:00
austinpower1258 a07a4fe92d test: exercise routed sidebar terminal shortcut 2026-08-09 16:13:38 -07:00
lawrencecchen a9e662b8f3 Test journal detach and detached hook containment 2026-08-09 16:07:14 -07:00
austinpower1258 97f2007217 fix: address diff viewer review feedback 2026-08-09 15:55:53 -07:00
lawrencecchen d47f000747 style(tui): apply hosted Rust format 2026-08-09 15:54:10 -07:00
austinpower1258 cc5afd057e fix: preserve compatible observation teardown 2026-08-09 15:49:44 -07:00
lawrencecchen d5b26aec5a Fence terminal reads and contain detached hooks 2026-08-09 15:47:53 -07:00
austinpower1258 e93082b68c fix: bind deferred telemetry to runtime lifecycle 2026-08-09 15:33:55 -07:00
austinpower1258 e3c8a35475 fix: isolate observation teardown 2026-08-09 15:33:27 -07:00
austinpower1258 c31c07200a test: require synchronous observation teardown 2026-08-09 15:31:57 -07:00
lawrencecchen 3791300984 test(tui): retry inspected terminal close 2026-08-09 15:23:41 -07:00
lawrencecchen ca0b32c603 Pin merged Ghostty dependency mainline 2026-08-09 15:13:01 -07:00
lawrencecchen fa4c2da7a6 Apply hosted Rust formatting 2026-08-09 15:12:53 -07:00
lawrencecchen f18ace71aa Pin merged Ghostty dependency for hosted verification 2026-08-09 15:09:01 -07:00
lawrencecchen 28d2043ee3 Close journal shutdown and hook lifecycle gaps 2026-08-09 15:08:50 -07:00
austinpower1258 fe135b7344 fix: preserve passive attention boundaries 2026-08-09 15:07:00 -07:00
austinpower1258 e881d2f598 test: cover remaining attention ownership gaps 2026-08-09 15:05:20 -07:00
lawrencecchen 8469a8e117 Fix hosted cmux-tui verification review findings 2026-08-09 15:02:09 -07:00
lawrencecchen 99e90fa8db Merge remote-tracking branch 'origin/main' into closeout-pr9837-20260809
# Conflicts:
#	ghostty
2026-08-09 14:59:29 -07:00
austinpower1258 11071d6ad1 test: reject deferred retired-runtime telemetry 2026-08-09 14:53:02 -07:00
lawrencecchen bfa3c46d06 Wake blocked journal writers on failure 2026-08-09 14:52:32 -07:00
austinpower1258 7be9b49c68 Merge remote-tracking branch 'origin/main' into issue-9588-lightmode-text-contrast 2026-08-09 14:52:16 -07:00
lawrencecchen 7dddd819a4 fix(sdk): include agent event in subscribe inventory 2026-08-09 14:52:08 -07:00
lawrencecchen 15dcfccd32 Merge remote-tracking branch 'origin/codex/cmux-browser-provider' into codex/cmux-browser-provider 2026-08-09 14:51:24 -07:00
lawrencecchen e8e018c015 Fix hosted cross-platform test compilation 2026-08-09 14:51:20 -07:00
lawrencecchen b1141bb172 fix(tui): replace journal polling with signals 2026-08-09 14:49:19 -07:00
lawrencecchen 8b80d0d52c Apply hosted Windows hook formatting 2026-08-09 14:39:51 -07:00
lawrencecchen 996d12c8eb test(tui): reconcile uncertain close outcome 2026-08-09 14:38:36 -07:00
lawrencecchen 0cc8d57ff1 Close Windows hook spawn race 2026-08-09 14:33:42 -07:00
austinpower1258 45757ce499 fix: enforce attention observation ownership 2026-08-09 14:32:36 -07:00
austinpower1258 213840cee3 test: expect workspace Dock attention ownership 2026-08-09 14:28:14 -07:00
lawrencecchen 04ce6435fa Close journal deadline and process-tree gaps 2026-08-09 14:26:46 -07:00
lawrencecchen 49a6bf8af5 Bound journal receipts and provider resolution 2026-08-09 14:15:23 -07:00
austinpower1258 c57a99c5e6 fix: preserve exact attention ownership 2026-08-09 14:13:44 -07:00
austinpower1258 d0b4e74e59 test: cover workspace Dock Feed attention cleanup 2026-08-09 13:59:08 -07:00
austinpower1258 05a7b68476 fix: harden Dock unread ownership 2026-08-09 13:33:54 -07:00
austinpower1258 3890650b0d Require restored diff viewer URL in test 2026-08-09 13:31:20 -07:00
austinpower1258 5793a774e1 fix: render Dock agent decision attention 2026-08-09 13:26:39 -07:00
austinpower1258 f84d774b28 Keep localized Dock metadata within SEO bounds 2026-08-09 13:26:00 -07:00
austinpower1258 f6a08aa9b3 Fix Dock locale schema placement 2026-08-09 13:18:15 -07:00
austinpower1258 909878e7b1 test: require rendered Dock decision attention 2026-08-09 12:53:40 -07:00
austinpower1258 ad31e3f75b fix: accept explicit input without empty scans 2026-08-09 12:35:06 -07:00
austinpower1258 b2d2c827d5 Address chromeless browser review feedback 2026-08-09 12:27:42 -07:00
austinpower1258 5cf7c3920c test: require accepted input fast paths 2026-08-09 12:14:47 -07:00
austinpower1258 e5cdb714a0 fix: decode persisted Hermes registration 2026-08-09 12:11:18 -07:00
austinpower1258 a978650bb0 Fix browser chrome toggle return 2026-08-09 12:06:51 -07:00
austinpower1258 dbddb211ba test: cover Hermes app snapshot decoding 2026-08-09 12:02:49 -07:00
austinpower1258 bf743ad8b0 Preserve chromeless browser policy 2026-08-09 11:53:52 -07:00
austinpower1258 07a4bef146 Add chromeless Dock browser controls 2026-08-09 11:45:50 -07:00
austinpower1258 b13f5bf0b0 fix: dismiss unread for copy-mode input 2026-08-09 11:31:32 -07:00
austinpower1258 8c5942c496 test: clear unread for accepted copy-mode input 2026-08-09 11:30:54 -07:00
austinpower1258 71d854e04e fix: dismiss unread only after accepted input 2026-08-09 11:23:41 -07:00
austinpower1258 0787062387 test: require accepted input before unread dismissal 2026-08-09 11:20:19 -07:00
austinpower1258 7fda3a8739 fix: route Hermes Vault resume through managed wrapper 2026-08-09 11:15:39 -07:00
austinpower1258 c429a0a5c7 test: cover Hermes Vault managed wrapper routing 2026-08-09 11:13:08 -07:00
austinpower1258 2765657827 fix: isolate Feed attention from agent lifecycle 2026-08-09 11:02:59 -07:00
austinpower1258 fb717d63c3 test: isolate Feed attention lifecycle ownership 2026-08-09 11:01:09 -07:00
austinpower1258 404eed2115 fix: restore lifecycle after Feed attention 2026-08-09 10:50:25 -07:00
austinpower1258 31d45e4096 test: reject stale Feed lifecycle ownership 2026-08-09 10:48:57 -07:00
austinpower1258 a8892038f0 fix: preserve exact attention state across owner transfer 2026-08-09 10:41:58 -07:00
austinpower1258 5da6f8a90c test: cover stale owners and atomic unread transfer 2026-08-09 10:33:46 -07:00
austinpower1258 12ea44f482 fix: parse attached Hermes short resume 2026-08-09 10:04:21 -07:00
austinpower1258 9071bbcd68 test: cover attached Hermes short resume 2026-08-09 10:03:59 -07:00
austinpower1258 633bb492f8 fix: stabilize attention ownership and unread replacement 2026-08-09 09:53:13 -07:00
austinpower1258 905072c8fc test: cover overlapping attention across owner transfer 2026-08-09 09:48:43 -07:00
austinpower1258 31a8f9d5a2 test: cover attention transfer and atomic unread replacement 2026-08-09 09:46:49 -07:00
austinpower1258 021d8714dc fix: rearm Hermes after hook record reuse 2026-08-09 09:31:06 -07:00
austinpower1258 969d72f708 test: cover reused Hermes resume record 2026-08-09 09:30:01 -07:00
austinpower1258 78bd52122e fix: route Dock attention through exact panel owners 2026-08-09 09:23:04 -07:00
austinpower1258 d237b3ef93 test: cover surface-less Dock attention and input dismissal 2026-08-09 09:19:10 -07:00
austinpower1258 6ddb161d7b fix: rearm Hermes restore after completed turn 2026-08-09 08:58:38 -07:00
austinpower1258 3eab38be4b fix: return panel owner lifecycle explicitly 2026-08-09 08:55:13 -07:00
austinpower1258 6ff6098401 fix: close remaining Dock attention ownership gaps 2026-08-09 08:48:21 -07:00
austinpower1258 08c9d7a81d test: cover remaining Dock attention ownership gaps 2026-08-09 08:42:32 -07:00
austinpower1258 2170d3607e fix: fail closed across remaining attention routes 2026-08-09 08:14:53 -07:00
austinpower1258 d9a2994887 test: cover remaining fail-closed attention routes 2026-08-09 08:06:31 -07:00
austinpower1258 8ed29f23ae test: cover idle Hermes second-launch restore 2026-08-09 08:02:42 -07:00
austinpower1258 bf560e85fa fix: keep Dock reveal bound to its owner window 2026-08-09 07:42:48 -07:00
austinpower1258 8b8b561f34 test: fail closed when Dock owner window is unavailable 2026-08-09 07:28:12 -07:00
austinpower1258 0ff8b44b22 Merge remote-tracking branch 'origin/main' into issue-9520-hermes-first-class 2026-08-09 07:26:32 -07:00
austinpower1258 53539c021e fix: constrain Python Hermes entrypoint detection 2026-08-09 07:26:24 -07:00
austinpower1258 f5c036fd81 refactor: unify unread observation ownership 2026-08-09 07:05:55 -07:00
austinpower1258 e9d3e79698 fix: separate live and rendered surface ownership 2026-08-09 06:53:17 -07:00
austinpower1258 0cef8a8849 test: reject unrelated Python Hermes arguments 2026-08-09 06:43:03 -07:00
austinpower1258 ff429a05bd fix: close scoped unread attention gaps 2026-08-09 06:19:13 -07:00
austinpower1258 a7ab936b53 fix: detect venv Hermes resume sessions 2026-08-09 06:02:01 -07:00
austinpower1258 49e9c8ccc2 test: cover window Dock notification open parity 2026-08-09 05:53:27 -07:00
austinpower1258 fc065ac05c test: cover idle Python Hermes autoresume 2026-08-09 05:41:53 -07:00
austinpower1258 45765c8ff1 test: provide Dock focus fixture state 2026-08-09 05:33:22 -07:00
austinpower1258 c7ed0ee1e2 fix: scope background unread publication 2026-08-09 05:15:17 -07:00
austinpower1258 18acebc387 test: cover owner-scoped unread publication 2026-08-09 04:52:13 -07:00
austinpower1258 4d743baf9d fix: keep Hermes launch shims native 2026-08-09 04:47:17 -07:00
austinpower1258 c17c9f98f2 test: isolate Dock focus fixtures 2026-08-09 04:46:39 -07:00
austinpower1258 c3ec08c7c8 test: cover PATH-shadowed Hermes bash launchers 2026-08-09 04:46:05 -07:00
austinpower1258 ba01c8fc36 fix: fail open stale Hermes Python bridge 2026-08-09 04:39:38 -07:00
austinpower1258 b6e7b86881 test: cover Hermes bridge fallback without watcher 2026-08-09 04:36:53 -07:00
austinpower1258 bce2108d85 fix: reject stale Hermes Python wrappers 2026-08-09 04:33:14 -07:00
austinpower1258 6822bba9dc test: cover stale Hermes Python wrapper restore 2026-08-09 04:32:32 -07:00
Lawrence Chen 111ceca888 Add private team-scoped CodeRouter usage metrics (#9864)
* 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
2026-08-09 04:02:55 -07:00
austinpower1258 11a1ddf66a fix: preflight workspace Dock focus 2026-08-09 03:56:30 -07:00
austinpower1258 eb5db49558 test: cover unavailable workspace Dock focus 2026-08-09 03:55:32 -07:00
Lawrence Chen e3c09b9d4c Upgrade web to Next.js 16.3 instant navigation (#9841)
* 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
2026-08-09 03:49:19 -07:00
austinpower1258 11538d4bb7 fix: make Dock attention updates incremental 2026-08-09 03:43:55 -07:00
austinpower1258 8234e17774 test: cover incremental Dock unread and focus failures 2026-08-09 03:39:37 -07:00
austinpower1258 8c580ff090 fix: rearm retired Hermes restore sessions 2026-08-09 03:36:18 -07:00
austinpower1258 f04e62cce3 test: cover retired Hermes restore checkpoint 2026-08-09 03:28:18 -07:00
austinpower1258 f6000cbac6 fix: ignore hidden workspace Dock notifications 2026-08-09 03:06:10 -07:00
austinpower1258 73d554aec2 test: reject hidden workspace Dock notifications 2026-08-09 03:04:04 -07:00
austinpower1258 316b7ae8ee fix: preserve Dock scope during notification opens 2026-08-09 02:49:46 -07:00
austinpower1258 047da6840e test: cover workspace Dock notification opens 2026-08-09 02:48:32 -07:00
austinpower1258 6859f8073a test: avoid process-wide AppKit interception 2026-08-09 02:39:57 -07:00
austinpower1258 1467f10911 fix: route attention through live focus owners 2026-08-09 02:37:21 -07:00
austinpower1258 9090fde894 fix: close attention review gaps 2026-08-09 02:23:43 -07:00
austinpower1258 aa0c789aed test: cover sidebar-focused terminal bell 2026-08-09 02:20:58 -07:00
austinpower1258 e4fac0ed84 Merge origin/main into issue-9466-stage-manager-raise 2026-08-09 02:18:54 -07:00
austinpower1258 433865de68 fix: keep quit snapshot loading off main actor 2026-08-09 02:18:49 -07:00
austinpower1258 8d661ac8b1 fix: recover durable Hermes resume sessions 2026-08-09 02:18:43 -07:00
austinpower1258 5d543e7675 fix: preserve exact Dock mark and autosave state 2026-08-09 01:57:02 -07:00
austinpower1258 7a94f55639 test: cover exact Dock mark and autosave state 2026-08-09 01:44:00 -07:00
austinpower1258 c086ce435d test: cover Hermes gateway bootstrap routing 2026-08-09 01:37:41 -07:00
austinpower1258 8d9033aad9 fix: keep background attention owner-scoped 2026-08-09 01:08:18 -07:00
austinpower1258 12ca7a13fe test: cover missing Hermes resume checkpoints 2026-08-09 00:51:56 -07:00
austinpower1258 a66fab4b26 test: correct Dock attention fixture coverage 2026-08-09 00:50:00 -07:00
austinpower1258 e0cf8fc4a0 test: cover focused Dock attention commands 2026-08-09 00:10:06 -07:00
austinpower1258 5bf855d1f1 fix: pin Hermes autoresume to session profile 2026-08-08 23:50:26 -07:00
austinpower1258 ef83242195 test: cover default Hermes autoresume profile drift 2026-08-08 23:47:05 -07:00
austinpower1258 84d9e9f299 fix: route projected terminal attention 2026-08-08 23:38:05 -07:00
austinpower1258 93b7e0f733 test: isolate Dock availability fixtures 2026-08-08 23:31:49 -07:00
austinpower1258 6c298ae559 fix: pin Hermes Vault resumes to indexed profile 2026-08-08 23:24:40 -07:00
austinpower1258 cfe925984b test: cover Hermes Vault profile mismatch 2026-08-08 23:18:38 -07:00
austinpower1258 7a3b405d5b test: cover mirror-owned terminal attention 2026-08-08 23:18:29 -07:00
austinpower1258 81213b24ac fix: complete background attention routing 2026-08-08 22:55:20 -07:00
austinpower1258 86c6bf05a3 test: cover remaining attention routing gaps 2026-08-08 22:33:13 -07:00
austinpower1258 21b232d6d1 fix: close attention owner lifecycle gaps 2026-08-08 22:14:57 -07:00
austinpower1258 d4e6f5ff22 fix: deduplicate Hermes completion replays 2026-08-08 22:02:17 -07:00
austinpower1258 62f8762ed5 Merge remote-tracking branch 'origin/main' into issue-9520-hermes-first-class 2026-08-08 21:47:54 -07:00
austinpower1258 2c6df311a2 fix: preserve durable Hermes resume recovery 2026-08-08 21:47:31 -07:00
austinpower1258 68d8b5caa6 test: cover repeated Hermes recovery failures 2026-08-08 21:47:26 -07:00
austinpower1258 4e0112adfb test: make non-key window fixtures deterministic 2026-08-08 21:44:25 -07:00
austinpower1258 b1592dc39f test: stabilize attention owner lifecycle coverage 2026-08-08 21:39:23 -07:00
austinpower1258 e9000ddb8a fix: repair Hermes restore identity and Vault indexing 2026-08-08 21:18:45 -07:00
austinpower1258 9578b85056 test: cover legacy Hermes restore and Vault indexing 2026-08-08 21:18:45 -07:00
austinpower1258 faf35dc651 test: cover attention owner lifecycle gaps 2026-08-08 20:58:43 -07:00
austinpower1258 08b8ab8490 test: close dock unread review gaps 2026-08-08 20:34:29 -07:00
austinpower1258 17e00f22b7 fix: preserve repeated terminal bell flashes 2026-08-08 20:32:56 -07:00
Abdulaziz AlbaharandClaude Fable 5 6d37f62a47 Add iPhone control for Simulator panes (#9401)
* 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]>
2026-08-08 20:27:34 -07:00
austinpower1258 d5362ece33 fix: route terminal bells through exact owners 2026-08-08 20:25:58 -07:00
Abdulaziz AlbaharandClaude Fable 5 866d15982c iOS: open workspace search selections inside the search tab (#9820)
* 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]>
2026-08-08 20:15:12 -07:00
austinpower1258 b73d704eba test: cover exact window dock bell navigation 2026-08-08 20:05:56 -07:00
Abdulaziz AlbaharandClaude Fable 5 0883e93109 iOS: custom sort for the All Computers workspace list (#9828)
* 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]>
2026-08-08 20:01:11 -07:00
austinpower1258 a8132adcdd fix: bound restored diff viewer requests 2026-08-08 19:48:40 -07:00
Abdulaziz AlbaharandClaude Fable 5 ba47b1dc0d Keep the iOS terminal dock pinned during keyboard reversals (#9836)
* 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]>
2026-08-08 19:47:24 -07:00
24cb551c9f Inline notification replies (macOS + iOS) with schema-driven reply shapes and a notification debug mode (#8670)
* 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]>
2026-08-08 19:12:38 -07:00
austinpower1258 4e2603af45 fix: cancel diff viewer picker commands 2026-08-08 19:07:55 -07:00
austinpower1258 5f19f1682f fix: reconcile focus after drag-to-split 2026-08-08 18:36:01 -07:00
austinpower1258 a315547cba fix: keep dock unread lookup constant-time 2026-08-08 18:32:36 -07:00
austinpower1258 5cf87c893b fix: preserve dock terminal bell unread state 2026-08-08 18:19:14 -07:00
austinpower1258 c478fc8fd6 Merge remote-tracking branch 'origin/main' into issue-9648-diffviewer-deadlock 2026-08-08 18:10:33 -07:00
austinpower1258 ff63cb3a2d fix: bound diff viewer asset decoding 2026-08-08 18:10:08 -07:00
austinpower1258 6c42f973fa test: cover dock terminal bell unread state 2026-08-08 18:06:07 -07:00
Abdulaziz AlbaharandClaude Fable 5 cea3a768dd Stop redundant Iroh registration publication (#9350)
* 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]>
2026-08-08 18:01:47 -07:00
austinpower1258 572f229d98 fix: bind coalesced titles to runtime lifecycle 2026-08-08 17:49:13 -07:00
austinpower1258 f6bd09fd40 test: prove stale teardown fallback lane 2026-08-08 17:49:05 -07:00
austinpower1258 0d5e6e6151 Merge remote-tracking branch 'origin/main' into issue-9520-hermes-first-class
# Conflicts:
#	Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+ClaudeCommandShimLifecycle.swift
#	Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
2026-08-08 17:45:02 -07:00
austinpower1258 38207a4909 test: reject titles from hibernated runtimes 2026-08-08 17:38:47 -07:00
austinpower1258 f5b268c8fd fix: bound terminal bell flash work 2026-08-08 17:38:44 -07:00
austinpower1258 47b968be9f Merge remote-tracking branch 'origin/main' into issue-9220-sidebar-click-hang
# Conflicts:
#	Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h
2026-08-08 17:38:10 -07:00
austinpower1258 40b27969e4 fix: preserve Hermes TUI resume identity 2026-08-08 17:19:10 -07:00
austinpower1258 f3dd028b05 test: cover Hermes TUI approval resume identity 2026-08-08 16:49:27 -07:00
austinpower1258 8c42a40ffa Merge remote-tracking branch 'origin/main' into issue-9648-diffviewer-deadlock 2026-08-08 16:45:02 -07:00
austinpower1258 e0bdf48c5a test: keep terminal bell fixture local 2026-08-08 16:43:17 -07:00
austinpower1258 a03080d64a fix: prevent diff viewer scheme deadlock 2026-08-08 16:38:00 -07:00
austinpower1258 769a002c07 test: make cached Iroh renewal deterministic 2026-08-08 16:37:26 -07:00
austinpower1258 2962fa83ec fix: require terminal bell ownership 2026-08-08 16:35:28 -07:00
Austin Wang f9a29b4806 Merge pull request #9798 from manaflow-ai/issue-9495-dblclick-rename-commit
Fix AppKit sidebar double-click inline rename committing instantly (#9495)
2026-08-08 16:31:55 -07:00
austinpower1258 a7c7a0b186 fix: preserve in-app terminal bell attention 2026-08-08 16:28:43 -07:00
austinpower1258 708d7b8146 Merge branch 'main' into issue-9588-lightmode-text-contrast 2026-08-08 16:27:23 -07:00
austinpower1258 d1821d920c Merge remote-tracking branch 'origin/main' into issue-9337-dock-terminal-live-title
# Conflicts:
#	Sources/Panels/FilePreviewPDFSharingPresenter.swift
#	scripts/ci/run-app-host-xcodebuild.sh
#	tests/test_ci_app_host_xcodebuild_retry.sh
2026-08-08 16:24:00 -07:00
Austin Wang 560d347b58 Merge pull request #9808 from manaflow-ai/issue-9583-korean-nfc-nfd-font
Resolve NFD Hangul clusters via canonical composition for font selection
2026-08-08 16:10:52 -07:00
Austin Wang f9cb0df9a8 Merge pull request #9800 from manaflow-ai/issue-9591-hibernation-omp-sighup
Scope OMP lifecycle hooks to the pane-owning top-level session
2026-08-08 16:08:35 -07:00
Austin Wang bd19583aad Merge pull request #9796 from manaflow-ai/issue-9769-cli-burst-pty-wedge
Make cold PTY spawn unstarvable: reclaim bootstrap-window custody, bound the shim gate, unpin prime slots, surface queued sends (#9769)
2026-08-08 15:59:34 -07:00
Austin Wang a9b3bb2e3a Merge pull request #9777 from manaflow-ai/issue-9550-invisible-ax-windows
Stop AX snapshots from retaining transient windows
2026-08-08 15:56:46 -07:00
Austin Wang 3148f79410 Merge pull request #9794 from manaflow-ai/issue-9677-browser-cmdz
Fix browser pane Cmd+Z/Cmd+Shift+Z: perform undo/redo on a per-web-view undo manager
2026-08-08 15:31:52 -07:00
lawrencecchen feb726043b Pin safe Ghostty cursor replay 2026-08-08 00:26:52 -07:00
lawrencecchen 5fc83a619f Pin emitted-state cursor replay fix 2026-08-08 00:20:46 -07:00
lawrencecchen c7d60a7ac7 style(tui): apply hosted CLI format 2026-08-08 00:18:46 -07:00
lawrencecchen fe152ab641 Fence journal commits and provider snapshots 2026-08-08 00:18:10 -07:00
lawrencecchen aa4f83ec6b Verify Ghostty cursor replay on hosted runners 2026-08-08 00:16:48 -07:00
lawrencecchen a2d0c77650 Pin reviewed Ghostty tabstop replay coverage 2026-08-08 00:15:07 -07:00
lawrencecchen b79608cb6d test(tui): update hosted CLI contracts 2026-08-08 00:14:33 -07:00
lawrencecchen 484e10874c Own forced cleanup in CLI test fixtures 2026-08-08 00:10:35 -07:00
lawrencecchen 1288cb62a9 Close final journal deadline review findings 2026-08-08 00:05:54 -07:00
lawrencecchen bf2f5d4e2e Apply hosted Rust formatting 2026-08-08 00:04:42 -07:00
lawrencecchen 666fd27fa2 Test TUI CLI cleanup failures precisely 2026-08-07 23:58:49 -07:00
lawrencecchen 79a1253b8d Make oversized frame test platform neutral 2026-08-07 23:54:52 -07:00
lawrencecchen 28277c23be Apply hosted Rust formatting 2026-08-07 23:54:18 -07:00
lawrencecchen f11f490ce3 Include registry admission in journal deadlines 2026-08-07 23:50:11 -07:00
lawrencecchen 2d628601ae test(tui): accept reset after oversized frame 2026-08-07 23:46:23 -07:00
lawrencecchen 20edfd5ea2 Keep SQLite waits inside journal deadlines 2026-08-07 23:42:17 -07:00
lawrencecchen d5feb39b8b test(tui): accept oversized-frame disconnect race 2026-08-07 23:41:27 -07:00
lawrencecchen a482343a7d Fix CLI terminal host fixture after merge 2026-08-07 23:40:07 -07:00
lawrencecchen 265ffadabd Bound journal retries and unblock frontend events 2026-08-07 23:37:07 -07:00
lawrencecchen db20c03d33 Fix terminal host reset fixtures after merge 2026-08-07 23:32:49 -07:00
lawrencecchen 0694e9d5a5 test(tui): update merged host record fixtures 2026-08-07 23:28:09 -07:00
lawrencecchen 00f0b7b30a Bound journal shutdown and close hosted test gaps 2026-08-07 23:25:39 -07:00
lawrencecchen 0e4d869fb1 fix(cli): close owner reload receiver on exit 2026-08-07 23:25:02 -07:00
lawrencecchen fd615d5e4d Merge remote-tracking branch 'origin/main' into task-hosted-cmux-tui-verification 2026-08-07 23:24:44 -07:00
lawrencecchen 5e5220616c test(cli): cover owner event loop wake 2026-08-07 23:23:49 -07:00
lawrencecchen ab05234d6f Serialize hosted TUI integration tests 2026-08-07 23:22:40 -07:00
lawrencecchen 9af268b301 style(tui): apply hosted rustfmt 2026-08-07 23:21:11 -07:00
lawrencecchen 3eca049dca fix(cli): preserve lifecycle shutdown fences 2026-08-07 23:18:06 -07:00
lawrencecchen 5639228d24 test(cli): cover lifecycle shutdown fences 2026-08-07 23:16:17 -07:00
lawrencecchen a752a1f82d Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-07 23:15:47 -07:00
lawrencecchen 158f4c6eda fix(cli): flush shutdown reply before owner exit 2026-08-07 23:13:36 -07:00
lawrencecchen a388069d8f Scope hosted memory checks to startup ownership 2026-08-07 23:09:44 -07:00
lawrencecchen dcfbf2ca7e test(cli): cover shutdown ack flush ordering 2026-08-07 23:09:14 -07:00
lawrencecchen 0535e85726 Close exact-head shutdown and provider review findings 2026-08-07 23:08:56 -07:00
lawrencecchen 5bf6936165 test(tui): pipe helper output in drain coverage 2026-08-07 22:39:11 -07:00
lawrencecchen f951e96b82 Make hosted TUI cleanup checks deterministic 2026-08-07 22:32:58 -07:00
lawrencecchen fd5d79a300 fix(cli): isolate owner reload delivery 2026-08-07 22:32:52 -07:00
lawrencecchen d1021fbbc3 test(cli): cover reload mailbox overflow 2026-08-07 22:32:10 -07:00
lawrencecchen e7328c5d78 Apply hosted Rust formatting and repair C++ parser 2026-08-07 22:30:45 -07:00
lawrencecchen b6e4b9fb09 fix(cli): unify local shutdown completion 2026-08-07 22:28:46 -07:00
lawrencecchen 674bf97eef test(cli): cover interactive session shutdown 2026-08-07 22:27:40 -07:00
lawrencecchen 21dcb7a7a2 Close final journal review findings 2026-08-07 22:27:34 -07:00
lawrencecchen 0b5429a759 fix(tui): clear final hosted test gates 2026-08-07 22:25:25 -07:00
lawrencecchen af26d64e79 Pin corrected Ghostty cursor replay 2026-08-07 22:19:03 -07:00
lawrencecchen 58d8650a4f test(cli): assert lifecycle timeout bounds 2026-08-07 22:18:53 -07:00
lawrencecchen e2c85b15c8 fix(cli): reject machine lifecycle targeting 2026-08-07 22:17:07 -07:00
lawrencecchen f924eb6468 test(cli): reject machine lifecycle targeting 2026-08-07 22:16:32 -07:00
lawrencecchen f9e0467ed2 fix(cli): preserve remote help routing 2026-08-07 22:13:51 -07:00
lawrencecchen 36676067d2 test(cli): cover remote typo help routing 2026-08-07 22:13:14 -07:00
lawrencecchen 8f78b38a9c Close exact-head journal review findings 2026-08-07 22:08:49 -07:00
lawrencecchen 3f2fec28ab fix(cli): close final lifecycle review gaps 2026-08-07 22:08:33 -07:00
lawrencecchen 459345c375 test(cli): cover final lifecycle review gaps 2026-08-07 22:05:59 -07:00
lawrencecchen 4b029b7796 test(tui): update SDK event inventory counts 2026-08-07 22:04:51 -07:00
lawrencecchen 84f7450c32 fix(cli): signal interactive shutdown by disconnect 2026-08-07 22:03:16 -07:00
lawrencecchen fa2ded8847 Fix baseline terminal replay verification 2026-08-07 21:59:46 -07:00
lawrencecchen 07617e6b1c Validate watcher settings before dispatch 2026-08-07 21:59:13 -07:00
lawrencecchen 8eb60437ca Poll hosted verification at a safe rate 2026-08-07 21:58:33 -07:00
lawrencecchen 006210dd4b fix(cli): apply reloads in the local owner 2026-08-07 21:58:18 -07:00
lawrencecchen 2f18651bf9 Repair hosted root test build 2026-08-07 21:57:22 -07:00
lawrencecchen f0e37f88c5 test(cli): cover owner config reload dispatch 2026-08-07 21:57:21 -07:00
lawrencecchen a8624dfd86 fix(cli): stop interactive local owners 2026-08-07 21:55:52 -07:00
lawrencecchen 92e0a8b84d Verify hosted outcomes without timing assumptions 2026-08-07 21:55:35 -07:00
lawrencecchen 7a0f325fac test(cli): cover interactive owner stop 2026-08-07 21:54:57 -07:00
lawrencecchen e946cedeed fix(tui): type agent event state fields 2026-08-07 21:54:35 -07:00
lawrencecchen 7d4ca1d55e fix(cli): document embedded remote ownership 2026-08-07 21:52:24 -07:00
lawrencecchen 49c446ae42 test(cli): cover embedded remote stop ownership 2026-08-07 21:50:46 -07:00
lawrencecchen b70f33f67f Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/lib.rs
#	cmux-tui/spec/cli.md
2026-08-07 21:49:23 -07:00
lawrencecchen 6867191b33 fix(tui): keep refreshed agents on live surfaces 2026-08-07 21:48:42 -07:00
lawrencecchen 4074ff2bc0 fix(cli): close lifecycle review findings 2026-08-07 21:47:09 -07:00
lawrencecchen ce9b7ade4b Keep secret filter test compile-only safe 2026-08-07 21:46:15 -07:00
lawrencecchen 4c38eb094f test(cli): cover lifecycle review findings 2026-08-07 21:45:48 -07:00
lawrencecchen b473634704 Close journal review findings 2026-08-07 21:45:12 -07:00
lawrencecchen 050264f057 Separate replay behavior from Valgrind 2026-08-07 21:42:33 -07:00
lawrencecchen bd4167ea77 fix(tui): expose layout overrides to tests 2026-08-07 21:35:21 -07:00
lawrencecchen acd04ea18c Sync root workspace lockfile from hosted metadata 2026-08-07 21:34:15 -07:00
lawrencecchen 6e93bd36d8 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-07 21:33:32 -07:00
Lawrence Chen 219f2b971e Merge pull request #9733 from manaflow-ai/task-cmux-tui-saved-state-recovery
Fix cmux-tui incompatible session recovery
2026-08-07 21:33:21 -07:00
lawrencecchen c8bced6ccd Keep full replay coverage under Valgrind 2026-08-07 21:33:05 -07:00
lawrencecchen 246d0622ac Stabilize full hosted TUI suites 2026-08-07 21:32:34 -07:00
lawrencecchen 22215b71e3 fix(cli): isolate process routing from resource grammar 2026-08-07 21:32:17 -07:00
lawrencecchen 74e3e854fc Fix remaining root merge source defects 2026-08-07 21:30:56 -07:00
lawrencecchen 68693031fe fix(cli): bound lifecycle protocol responses 2026-08-07 21:28:12 -07:00
lawrencecchen 4f56d2f0de fix(tui): publish agent event SDK contract 2026-08-07 21:28:03 -07:00
lawrencecchen 060fcc1e2e Serialize Ghostty replay tests under Valgrind 2026-08-07 21:25:48 -07:00
lawrencecchen 5d8a34ced2 fix(cli): harden local server lifecycle grammar 2026-08-07 21:25:07 -07:00
lawrencecchen 4150f8ec06 test(cli): cover lifecycle recovery gaps 2026-08-07 21:24:53 -07:00
lawrencecchen f8b8452b60 fix(tui): clear hosted lint and inventory gates 2026-08-07 21:22:49 -07:00
lawrencecchen 40212bef4d fix(tui): satisfy hosted Linux Clippy 2026-08-07 21:22:44 -07:00
lawrencecchen 30a2e8a82c Tolerate hosted verification API errors 2026-08-07 21:19:01 -07:00
lawrencecchen ae12710402 Remove constant-only protocol test 2026-08-07 21:18:15 -07:00
lawrencecchen f6b9542ea1 Fix duplicated root merge test blocks 2026-08-07 21:17:34 -07:00
lawrencecchen 4d6f27337a Merge remote-tracking branch 'origin/task-cmux-tui-saved-state-recovery' into task-cli-lifecycle-grammar
# Conflicts:
#	cmux-tui/crates/cmux-tui/src/cli.rs
2026-08-07 21:16:04 -07:00
lawrencecchen 9ff4d5288e style(tui): apply hosted review format 2026-08-07 21:12:12 -07:00
lawrencecchen 3978f9a958 Match pinned cmux-tui rustfmt 2026-08-07 21:11:38 -07:00
lawrencecchen d6c8c930c9 Merge remote-tracking branch 'origin/main' into task-cli-lifecycle-grammar 2026-08-07 21:10:02 -07:00
lawrencecchen 44d66969fa perf(tui): keep reset host scan linear 2026-08-07 21:08:15 -07:00
lawrencecchen ee79f2c681 Address hosted cross-platform Clippy findings 2026-08-07 21:08:04 -07:00
lawrencecchen 8c3ad3c18c fix(tui): close reset safety review 2026-08-07 21:05:45 -07:00
lawrencecchen a4a685ab9f fix(tui): satisfy hosted clippy gates 2026-08-07 21:04:50 -07:00
lawrencecchen eb7f9744b2 Merge latest main into session journal root 2026-08-07 21:04:49 -07:00
lawrencecchen 38e85ade72 Fix root stack source contract regressions 2026-08-07 21:04:22 -07:00
lawrencecchen 4e796f5a01 Keep release verification on pinned Rust 2026-08-07 21:03:36 -07:00
lawrencecchen f5c75d74e4 Fix remaining hosted TUI Clippy findings 2026-08-07 21:02:52 -07:00
lawrencecchen fc4cab47c9 style(tui): apply hosted Rust format 2026-08-07 21:00:17 -07:00
lawrencecchen f7f1903d06 Pin hosted cmux-tui Rust toolchain 2026-08-07 20:59:50 -07:00
Lawrence Chen d95fbb38c3 Clarify temporary coderouter account status (#9838)
Show an accurate temporary-unavailability state without exposing internal migration details or incorrectly reporting a service connection failure.
2026-08-07 20:58:25 -07:00
lawrencecchen e461b1eeac test(tui): cover removed agent refresh 2026-08-07 20:57:30 -07:00
lawrencecchen 57cf263064 Merge latest main into session journal root 2026-08-07 20:54:52 -07:00
lawrencecchen b966954e71 Merge source reconciliation for session journal stack
# Conflicts:
#	cmux-tui/bindings/ERGONOMICS.md
#	cmux-tui/bindings/cpp/.cmux-resource-api.json
#	cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
#	cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
#	cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp
#	cmux-tui/bindings/go/.cmux-resource-api.json
#	cmux-tui/bindings/go/README.md
#	cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/go/raw/README.md
#	cmux-tui/bindings/go/raw/client_test.go
#	cmux-tui/bindings/go/raw/generated_commands.go
#	cmux-tui/bindings/go/raw/generated_events.go
#	cmux-tui/bindings/go/raw/generated_metadata.go
#	cmux-tui/bindings/go/raw/generated_presence_test.go
#	cmux-tui/bindings/go/raw/generated_types.go
#	cmux-tui/bindings/java/.cmux-resource-api.json
#	cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java
#	cmux-tui/bindings/python/.cmux-resource-api.json
#	cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/python/cmux/raw/_generated/_schema.py
#	cmux-tui/bindings/python/cmux/raw/_generated/metadata.py
#	cmux-tui/bindings/python/tests/test_protocol.py
#	cmux-tui/bindings/rust/.cmux-resource-api.json
#	cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/rust/src/generated/commands.rs
#	cmux-tui/bindings/rust/src/generated/events.rs
#	cmux-tui/bindings/rust/src/generated/metadata.rs
#	cmux-tui/bindings/rust/src/generated/mod.rs
#	cmux-tui/bindings/rust/src/generated/types.rs
#	cmux-tui/bindings/typescript/.cmux-resource-api.json
#	cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/typescript/src/raw/generated/commands.ts
#	cmux-tui/bindings/typescript/src/raw/generated/events.ts
#	cmux-tui/bindings/typescript/src/raw/generated/index.ts
#	cmux-tui/bindings/typescript/src/raw/generated/metadata.ts
#	cmux-tui/bindings/typescript/src/raw/generated/types.ts
#	cmux-tui/bindings/zig/.cmux-resource-api.json
#	cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/zig/src/raw/generated/protocol.zig
#	cmux-tui/crates/cmux-tui-core/src/resource_router.rs
#	cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs
#	cmux-tui/docs/README.md
#	cmux-tui/docs/protocol.md
#	cmux-tui/spec/README.md
#	cmux-tui/spec/cli.md
#	cmux-tui/spec/commands.md
#	cmux-tui/spec/frontends.md
#	cmux-tui/spec/render.md
2026-08-07 20:54:49 -07:00
lawrencecchen 2632b0b2f5 fix(tui): align reset platform support 2026-08-07 20:54:44 -07:00
lawrencecchen 8ab5dafa50 Merge remote-tracking branch 'origin/main' into feat-cmux-tui-session-journal
# Conflicts:
#	.github/workflows/cmux-tui.yml
#	cmux-tui/Cargo.lock
#	cmux-tui/bindings/ERGONOMICS.md
#	cmux-tui/bindings/conformance/runner.py
#	cmux-tui/bindings/cpp/.cmux-resource-api.json
#	cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
#	cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
#	cmux-tui/bindings/cpp/include/cmux/resource.hpp
#	cmux-tui/bindings/cpp/src/resource_models.cpp
#	cmux-tui/bindings/cpp/tests/test_resource.cpp
#	cmux-tui/bindings/examples/cpp-terminal-frontend/tests/frontend_test.cpp
#	cmux-tui/bindings/examples/java-ci-orchestrator/tests/com/cmux/examples/ci/FakeCmuxServer.java
#	cmux-tui/bindings/examples/python-agent-watchdog/tests/test_watchdog.py
#	cmux-tui/bindings/examples/python-dev-orchestrator/fake_cmux_server.py
#	cmux-tui/bindings/examples/rust-agent-dashboard/tests/fake_server.rs
#	cmux-tui/bindings/go/.cmux-resource-api.json
#	cmux-tui/bindings/go/operations.go
#	cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/go/raw/README.md
#	cmux-tui/bindings/go/raw/client_test.go
#	cmux-tui/bindings/go/raw/generated_commands.go
#	cmux-tui/bindings/go/raw/generated_events.go
#	cmux-tui/bindings/go/raw/generated_metadata.go
#	cmux-tui/bindings/go/raw/generated_presence_test.go
#	cmux-tui/bindings/go/raw/generated_types.go
#	cmux-tui/bindings/go/resource_api_test.go
#	cmux-tui/bindings/go/resources.go
#	cmux-tui/bindings/java/.cmux-resource-api.json
#	cmux-tui/bindings/java/src/com/cmux/Client.java
#	cmux-tui/bindings/java/src/com/cmux/Snapshots.java
#	cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java
#	cmux-tui/bindings/java/tests/com/cmux/ResourceApiTest.java
#	cmux-tui/bindings/python/.cmux-resource-api.json
#	cmux-tui/bindings/python/cmux/models.py
#	cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/python/cmux/raw/_generated/_schema.py
#	cmux-tui/bindings/python/cmux/raw/_generated/metadata.py
#	cmux-tui/bindings/python/cmux/resources.py
#	cmux-tui/bindings/python/tests/test_protocol.py
#	cmux-tui/bindings/python/tests/test_resource_api.py
#	cmux-tui/bindings/rust-sidebar/tests/runtime.rs
#	cmux-tui/bindings/rust/.cmux-resource-api.json
#	cmux-tui/bindings/rust/src/codec.rs
#	cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/rust/src/generated/commands.rs
#	cmux-tui/bindings/rust/src/generated/events.rs
#	cmux-tui/bindings/rust/src/generated/metadata.rs
#	cmux-tui/bindings/rust/src/generated/mod.rs
#	cmux-tui/bindings/rust/src/generated/types.rs
#	cmux-tui/bindings/rust/src/resource/mod.rs
#	cmux-tui/bindings/rust/src/resource/model.rs
#	cmux-tui/bindings/rust/tests/mock_server.rs
#	cmux-tui/bindings/rust/tests/operation_reachability.rs
#	cmux-tui/bindings/typescript/.cmux-resource-api.json
#	cmux-tui/bindings/typescript/src/models.ts
#	cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/typescript/src/raw/generated/commands.ts
#	cmux-tui/bindings/typescript/src/raw/generated/events.ts
#	cmux-tui/bindings/typescript/src/raw/generated/index.ts
#	cmux-tui/bindings/typescript/src/raw/generated/metadata.ts
#	cmux-tui/bindings/typescript/src/raw/generated/types.ts
#	cmux-tui/bindings/typescript/src/resources.ts
#	cmux-tui/bindings/typescript/test/resource-api.test.ts
#	cmux-tui/bindings/zig/.cmux-resource-api.json
#	cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/zig/src/raw/generated/protocol.zig
#	cmux-tui/bindings/zig/src/resource.zig
#	cmux-tui/crates/cmux-tui-core/src/mux.rs
#	cmux-tui/crates/cmux-tui-core/src/mux/resource_topology.rs
#	cmux-tui/crates/cmux-tui-core/src/resource.rs
#	cmux-tui/crates/cmux-tui-core/src/resource_api.rs
#	cmux-tui/crates/cmux-tui-core/src/resource_router.rs
#	cmux-tui/crates/cmux-tui-core/src/resource_router/auxiliary.rs
#	cmux-tui/crates/cmux-tui-core/src/resource_router/content.rs
#	cmux-tui/crates/cmux-tui-core/src/server.rs
#	cmux-tui/crates/cmux-tui-core/src/surface.rs
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/resource_store.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/terminal_exit_store.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs
#	cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs
#	cmux-tui/crates/cmux-tui/Cargo.toml
#	cmux-tui/crates/cmux-tui/src/app.rs
#	cmux-tui/crates/cmux-tui/src/cli/command.rs
#	cmux-tui/crates/cmux-tui/src/localization.rs
#	cmux-tui/crates/cmux-tui/src/main.rs
#	cmux-tui/crates/cmux-tui/src/pty_input.rs
#	cmux-tui/crates/cmux-tui/src/remote_runtime.rs
#	cmux-tui/crates/cmux-tui/src/session/mod.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
#	cmux-tui/crates/cmux-tui/src/session/tree.rs
#	cmux-tui/crates/cmux-tui/tests/cli.rs
#	cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs
#	cmux-tui/docs/protocol.md
#	cmux-tui/scripts/test_check_resource_api_boundary.py
#	cmux-tui/spec/README.md
#	cmux-tui/spec/bindings.md
#	cmux-tui/spec/commands.md
#	cmux-tui/spec/frontends.md
#	cmux-tui/spec/inventory.json
#	cmux-tui/spec/native-frontend.md
#	cmux-tui/spec/resource-api-v2.md
#	cmux-tui/spec/resource-operations-v2.json
#	cmux-tui/spec/resource-operations-v2.md
#	cmux-tui/spec/terminal-host.md
2026-08-07 20:51:37 -07:00
Lawrence Chen 36c549ac3c Add coderouter team settings dashboard (#9835)
Rename the user-facing Subrouter dashboard to coderouter, add a chatmux-style account menu, and expose full Stack team settings under Billing.
2026-08-07 20:50:49 -07:00
lawrencecchen e101b726a8 Fix hosted cmux-tui Clippy failures 2026-08-07 20:50:44 -07:00
lawrencecchen 8e13261a3e test(tui): match quoted main reset command 2026-08-07 20:45:56 -07:00
lawrencecchen cf18a59d32 fix(tui): cancel connects and scope agent updates 2026-08-07 20:45:54 -07:00
lawrencecchen 01e274493d Use valid dry-run package versions 2026-08-07 20:43:53 -07:00
lawrencecchen 6f0da8e979 Isolate hosted verification runs 2026-08-07 20:43:43 -07:00
lawrencecchen aa9d5a9aa8 test(tui): cover main reset guidance 2026-08-07 20:42:29 -07:00
lawrencecchen 0a23fed15a Complete hosted TUI release verification 2026-08-07 20:41:45 -07:00
Abdulaziz AlbaharandClaude Fable 5 4dc1004766 Fix CLI compile break from classify() tuple access (#9834)
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]>
2026-08-07 20:40:50 -07:00
lawrencecchen dbff90070b fix(tui): satisfy hosted Clippy gates 2026-08-07 20:40:33 -07:00
Abdulaziz AlbaharandClaude Fable 5 8c766f7bfc Add iOS Move to Group context-menu picker for workspaces (#9779)
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]>
2026-08-07 20:38:14 -07:00
lawrencecchen 307c33e27d Add hosted cmux-tui verification 2026-08-07 20:37:43 -07:00
lawrencecchen 2307470307 fix(tui): close reset recovery review findings 2026-08-07 20:33:02 -07:00
lawrencecchen dcf67be067 Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-07 20:22:18 -07:00
lawrencecchen 8154170461 fix(tui): close review feedback gaps 2026-08-07 20:22:09 -07:00
austinpower1258 f568ecb99b fix: bridge Hermes hooks into TUI gateway 2026-08-07 19:22:35 -07:00
austinpower1258 656823b0bd test: cover repeated Hermes TUI completions 2026-08-07 19:05:22 -07:00
Austin Wang 7cc1346904 Merge pull request #9760 from manaflow-ai/issue-9706-lease-gone-live-pty
Preserve live remote PTYs when relay lease disappears
2026-08-07 18:51:45 -07:00
austinpower1258 3e263ff6da fix: keep Hermes TUI watcher idle 2026-08-07 18:43:16 -07:00
austinpower1258 bbbc36769c test: bound Hermes TUI watcher CPU 2026-08-07 18:42:56 -07:00
austinpower1258 7d6ba1fc58 fix: bridge Hermes TUI lifecycle into cmux 2026-08-07 18:24:53 -07:00
Austin Wang 59c45b35e2 Merge pull request #9804 from manaflow-ai/issue-9592-codex-permission-notify
Notify on codex PermissionRequest: new nativeApprovalPrompt feed semantic raises the agentPermissionPrompt alert
2026-08-07 18:15:11 -07:00
Lawrence Chen 408ae46e35 Stop creating dev-artifact issues for E2E runs (#9832)
* Stop creating issues for E2E runs

* Handle unavailable E2E recordings
2026-08-07 18:01:34 -07:00
lawrencecchen 9328fbe51d feat(cli): add local server lifecycle grammar 2026-08-07 17:59:29 -07:00
lawrencecchen 7e5d89e943 test(cli): cover local server lifecycle grammar 2026-08-07 17:59:04 -07:00
lawrencecchen 7dfb4c1c1e fix(tui): avoid eager machine connections 2026-08-07 17:52:30 -07:00
lawrencecchen caba0cca6a fix(tui): address resource rail review feedback 2026-08-07 17:30:33 -07:00
austinpower1258 3d3a4e16ac test: cover Hermes TUI lifecycle bridge 2026-08-07 17:25:59 -07:00
lawrencecchen 672c2a1e84 fix(tui): keep smoke protocol in sync 2026-08-07 17:21:48 -07:00
lawrencecchen 4c372e152d fix(tui): stabilize resource rail focus 2026-08-07 17:20:21 -07:00
lawrencecchen 5e33738147 fix(tui): return from active resource rails 2026-08-07 17:04:19 -07:00
lawrencecchen eaa60e497d Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns
# Conflicts:
#	cmux-tui/Cargo.lock
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs
#	cmux-tui/crates/cmux-tui/src/app.rs
#	cmux-tui/crates/cmux-tui/src/config.rs
#	cmux-tui/docs/machines.md
#	cmux-tui/spec/inventory.json
#	cmux-tui/spec/terminal-host.md
2026-08-07 17:03:40 -07:00
austinpower1258 0ab2fe6ec7 Merge remote-tracking branch 'origin/main' into issue-9520-hermes-first-class 2026-08-07 16:47:03 -07:00
austinpower1258 83e69db83b fix: close Hermes live alias and quit watchdog regressions 2026-08-07 16:46:46 -07:00
lawrencecchen d6dee4aed2 fix: harden reset recovery edge cases 2026-08-07 04:37:38 -07:00
austinpower1258andClaude Fable 5 f232177ace Give the CLI queued-send test CI-load-tolerant timeouts
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]>
2026-08-07 04:16:19 -07:00
austinpower1258 82df26a12d test(ci): cover published app-host temp aliases 2026-08-07 04:10:05 -07:00
lawrencecchen b128fd2946 fix: document apple reset stat fields 2026-08-07 04:04:47 -07:00
lawrencecchen cd2e3ca5ab fix: detect reset path recreation 2026-08-07 03:57:52 -07:00
austinpower1258 c558c52ec3 Merge remote-tracking branch 'origin/main' into issue-9550-invisible-ax-windows 2026-08-07 03:57:21 -07:00
austinpower1258andClaude Fable 5 fdc8a22c2f Reserve deadline budget for the essential attention send
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]>
2026-08-07 03:47:57 -07:00
austinpower1258 d43a6260b6 fix: inherit simulator modules from test host 2026-08-07 03:47:36 -07:00
lawrencecchen 35df85e278 fix: harden reset ownership checks 2026-08-07 03:46:23 -07:00
austinpower1258 143902c821 Canonicalize isolated app-host config paths
Treat macOS /tmp and /private/tmp spellings as the same validated app-host home.
2026-08-07 03:37:11 -07:00
austinpower1258 3e185e1dfa fix: let app host own static simulator linkage 2026-08-07 03:36:49 -07:00
austinpower1258andClaude Fable 5 e5b68203e3 Resolve the live pane before addressing the attention command
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]>
2026-08-07 03:35:47 -07:00
lawrencecchen ed857c34ee fix: fail closed for unsupported reset deletion 2026-08-07 03:31:29 -07:00
austinpower1258 6442db3d3d fix: keep simulator products statically linked 2026-08-07 03:26:35 -07:00
austinpower1258andClaude Fable 5 e98c53f8b5 Give the attention transport a real deadline across connect/auth/send
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]>
2026-08-07 03:24:30 -07:00
lawrencecchen 9c9602eab4 fix: rewind reset directory scans 2026-08-07 03:21:01 -07:00
austinpower1258 872e31fd48 test: cover Hermes live alias and quit watchdog regressions 2026-08-07 03:18:24 -07:00
austinpower1258andClaude Fable 5 5fcda4e59a Add CLI-level regression tests for the permission-prompt delivery path
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]>
2026-08-07 03:15:56 -07:00
austinpower1258andClaude Fable 5 869da76ed3 Address review: reopen shim gate after lifecycle cancel, harden CLI test, reword queued marker
- 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]>
2026-08-07 03:10:21 -07:00
austinpower1258 4864e19d64 Fix inherited Xcode 26.3 warnings
Clear two warnings introduced on main without changing the warning budget.
2026-08-07 03:09:58 -07:00
lawrencecchen 6fecd3a5fd fix: bound reset manifests and legacy markers 2026-08-07 03:09:01 -07:00
austinpower1258andClaude Fable 5 80c815480c Cover missing surface ID and newline sanitization in attention tests
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]>
2026-08-07 03:05:02 -07:00
austinpower1258andClaude Fable 5 6540195741 Document the accepted prompt-submit clear race
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]>
2026-08-07 03:01:31 -07:00
lawrencecchen ebb49af198 fix: stage reset child deletes 2026-08-07 02:57:02 -07:00
lawrencecchen 17e6db8f9c test(tui): update C++ raw command count 2026-08-07 02:56:50 -07:00
austinpower1258 7602181e2a fix: address dock title review findings 2026-08-07 02:53:26 -07:00
austinpower1258 6f627a74fe fix: close remaining Hermes review gaps 2026-08-07 02:52:25 -07:00
austinpower1258andClaude Fable 5 a19d51714c Bound relay telemetry, share and unit-test the attention command builder
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]>
2026-08-07 02:49:05 -07:00
lawrencecchen c9262db0bd fix: fail closed on reset root races 2026-08-07 02:45:07 -07:00
austinpower1258andClaude Fable 5 2957e161ad Await the app's acknowledgement of the approval notify/clear
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]>
2026-08-07 02:36:06 -07:00
Austin Wang 1058cd2120 Merge branch 'main' into issue-9588-lightmode-text-contrast 2026-08-07 02:34:16 -07:00
austinpower1258 a9d8d5006a test: cover remaining Hermes review regressions 2026-08-07 02:31:06 -07:00
austinpower1258 067c185208 fix: statically link simulator support into bundled CLI 2026-08-07 02:30:25 -07:00
lawrencecchen addf008d37 fix: delete only confirmed reset entries 2026-08-07 02:27:18 -07:00
austinpower1258andClaude Fable 5 c8b9c91be2 Keep the approval clear off the unordered wrapper telemetry lane
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]>
2026-08-07 02:26:43 -07:00
austinpower1258 af49efbd34 Merge remote-tracking branch 'origin/main' into issue-9520-hermes-first-class 2026-08-07 02:13:48 -07:00
austinpower1258andClaude Fable 5 e6a000c0c8 Send approval signal before telemetry; clear on the wrapper hook path too
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]>
2026-08-07 02:13:43 -07:00
austinpower1258 37677f6ce2 fix: address Hermes restore review findings 2026-08-07 02:12:24 -07:00
lawrencecchen 1dd80f8c7a fix: delete reset state by directory handle 2026-08-07 02:07:28 -07:00
austinpower1258andClaude Fable 5 6cd92a0735 Clear codex approval prompts only on tool completion; batch one-way sends
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]>
2026-08-07 01:58:51 -07:00
lawrencecchen 5108fe5d0a fix: harden reset staged deletion 2026-08-07 01:57:22 -07:00
austinpower1258 63c6832221 test: compare isolated app-host paths canonically 2026-08-07 01:55:16 -07:00
austinpower1258andClaude Fable 5 4df3d416bf chore: pin GhosttyKit artifact and record Hangul fork change
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]>
2026-08-07 01:54:42 -07:00
austinpower1258 4a64c5caeb test: cover Hermes review regressions 2026-08-07 01:50:28 -07:00
austinpower1258andClaude Fable 5 5e2ee3af4d Document the deliberate pane-wide notification-clear contract
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]>
2026-08-07 01:45:26 -07:00
lawrencecchen 48e2c96400 fix: avoid reset preflight mutations 2026-08-07 01:44:19 -07:00
austinpower1258andClaude Fable 5 1066f9b914 fix: resolve NFD Hangul clusters via canonical composition in ghostty
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]>
2026-08-07 01:43:08 -07:00
austinpower1258andClaude Fable 5 9977c7ecb5 test: ghostty regression test for NFC/NFD Hangul font divergence
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]>
2026-08-07 01:43:07 -07:00
austinpower1258andClaude Fable 5 1b3aaa83ac Redact approval-prompt notification body and self-heal stale codex alerts
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]>
2026-08-07 01:38:35 -07:00
austinpower1258 46b95df9f6 fix: complete Hermes profile hook routing 2026-08-07 01:35:56 -07:00
lawrencecchen 35469090d6 fix: fail closed on busy live markers 2026-08-07 01:33:56 -07:00
austinpower1258andClaude Fable 5 1ae6c7334c Notify on codex PermissionRequest via new nativeApprovalPrompt feed semantic
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]>
2026-08-07 01:25:59 -07:00
austinpower1258 adf86b1395 fix: clear merged Swift warning regressions 2026-08-07 01:24:59 -07:00
austinpower1258 6dac4418be fix: canonicalize app-host config log paths 2026-08-07 01:24:58 -07:00
austinpower1258 9a9ecf2764 test: cover app-host config path aliases 2026-08-07 01:24:57 -07:00
lawrencecchen 34e6441515 fix: guard reset deletes with confirmed fingerprints 2026-08-07 01:24:22 -07:00
austinpower1258 985bd0ed49 Accept validated macOS temp aliases in app-host CI 2026-08-07 01:21:45 -07:00
lawrencecchen 1ac69cd3cd fix: allow symlinked reset state roots 2026-08-07 01:13:43 -07:00
austinpower1258andClaude Fable 5 63f0ea429e fix: ignore same-session session_switch re-emissions
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]>
2026-08-07 01:11:13 -07:00
austinpower1258andClaude Fable 5 d8ee541ee6 test: same-session session_switch must not rebind the pane
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]>
2026-08-07 01:11:12 -07:00
austinpower1258andClaude Fable 5 c723de4faf Make cold PTY spawn unstarvable and surface queued send delivery
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]>
2026-08-07 01:02:39 -07:00
lawrencecchen b41b3df542 fix: report retried terminal-host reset 2026-08-07 01:01:26 -07:00
austinpower1258andClaude Fable 5 ae4c079650 fix: keep OMP pane ownership on globalThis across module instances
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]>
2026-08-07 00:56:57 -07:00
austinpower1258andClaude Fable 5 6b09adc58e test: model OMP's per-session extension module loading
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]>
2026-08-07 00:56:55 -07:00
austinpower1258 c39c8bc9be Merge remote-tracking branch 'origin/main' into issue-9550-invisible-ax-windows
# Conflicts:
#	web/tests/install-analytics.test.tsx
2026-08-07 00:54:28 -07:00
austinpower1258 e9a988f6cf Gate isolated sharing delegate conformance 2026-08-07 00:53:33 -07:00
austinpower1258 ff60b0b27e Merge remote-tracking branch 'origin/main' into issue-9337-dock-terminal-live-title 2026-08-07 00:53:25 -07:00
lawrencecchen 9b0a698a1c fix: render safe reset selectors 2026-08-07 00:51:41 -07:00
austinpower1258 be48d2568d test: cover Hermes profile launch review gaps 2026-08-07 00:51:39 -07:00
austinpower1258andClaude Fable 5 c3472d67a4 Pre-size the rename field before its focus grab and clear the editor box
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]>
2026-08-07 00:49:57 -07:00
austinpower1258andClaude Fable 5 98ba895364 fix: scope OMP lifecycle hooks to the pane-owning top-level session
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]>
2026-08-07 00:44:08 -07:00
austinpower1258 ec8b69920c Merge remote-tracking branch 'origin/main' into issue-9550-invisible-ax-windows 2026-08-07 00:41:07 -07:00
austinpower1258 930ba2ecac Fix current-main CI guard regressions 2026-08-07 00:40:36 -07:00
austinpower1258andClaude Fable 5 ffd5b853c9 Share the sidebar inline-rename engine with the AppKit list (#9495)
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]>
2026-08-07 00:39:54 -07:00
lawrencecchen 72a666344a fix: harden reset confirmation ownership 2026-08-07 00:39:37 -07:00
austinpower1258 c4d418bba2 Unify surface configuration reload ordering 2026-08-07 00:35:41 -07:00
austinpower1258 d0422cfcc2 Test surface theme reload ordering 2026-08-07 00:35:22 -07:00
austinpower1258 42683b9424 fix: hide internal shell-state fallback 2026-08-07 00:27:16 -07:00
austinpower1258 af2bc23a64 fix: close Hermes approval and quit-time review gaps 2026-08-07 00:25:50 -07:00
lawrencecchen 063ac855fd fix: harden reset deletion boundary 2026-08-07 00:20:35 -07:00
austinpower1258andClaude Fable 5 523089bf46 fix: run web-content undo/redo when focus mode sees a declined chord
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]>
2026-08-07 00:10:49 -07:00
austinpower1258andClaude Fable 5 48ed024dc2 test: focus-mode Cmd+Z must perform web-content undo when the page declines
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]>
2026-08-07 00:10:23 -07:00
lawrencecchen c74405e1fc fix: block terminal-host publication during reset 2026-08-07 00:07:32 -07:00
austinpower1258 08333bd35c test: assert synchronous dock config seed directly 2026-08-06 23:59:13 -07:00
austinpower1258 623a829478 fix: authenticate workspace shell relay source 2026-08-06 23:59:06 -07:00
austinpower1258andClaude Fable 5 538fc6967c fix: perform browser undo/redo on a per-web-view undo manager
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]>
2026-08-06 23:57:48 -07:00
austinpower1258 dedd6a1c2b Merge remote-tracking branch 'origin/main' into issue-9520-hermes-first-class
# Conflicts:
#	CLI/cmux.swift
2026-08-06 23:57:12 -07:00
lawrencecchen 44077efe7e fix: classify reset fingerprint races 2026-08-06 23:53:28 -07:00
austinpower1258 2331088534 fix: address Hermes deterministic restore review findings 2026-08-06 23:50:23 -07:00
austinpower1258andClaude Fable 5 d475067122 Add failing regression test: codex PermissionRequest must raise a permission-prompt notification
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]>
2026-08-06 23:50:22 -07:00
lawrencecchen 0591142d3e fix: fingerprint reset file contents 2026-08-06 23:42:08 -07:00
austinpower1258andClaude Fable 5 51c37efee5 test: OMP lifecycle hooks must be owned by the top-level session
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]>
2026-08-06 23:36:52 -07:00
lawrencecchen 36ed4ae8f0 fix: hold terminal-host reset leases 2026-08-06 23:32:57 -07:00
austinpower1258andClaude Fable 5 3956c75e4a test: browser Cmd+Z/Cmd+Shift+Z must perform web-content undo/redo
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]>
2026-08-06 23:21:28 -07:00
austinpower1258 fe7f7236d2 test: cover Hermes deterministic restore review findings 2026-08-06 23:12:34 -07:00
austinpower1258 3038288f81 fix: import dock shell activity state 2026-08-06 23:07:24 -07:00
austinpower1258andClaude Fable 5 6d4a2933a5 Add failing regression tests for burst-wedged PTY spawn and silent queued sends
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]>
2026-08-06 23:05:15 -07:00
austinpower1258andClaude Fable 5 fc1c48db2f Add failing regression tests for AppKit sidebar inline rename (#9495)
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]>
2026-08-06 23:00:25 -07:00
lawrencecchen cd07d75e77 fix: harden saved-state reset confirmation 2026-08-06 22:59:47 -07:00
austinpower1258 7a78c50351 fix: import terminal surface for shell activity routing 2026-08-06 22:52:24 -07:00
austinpower1258 a38c0a0cc8 Stop AX snapshots retaining transient windows 2026-08-06 22:51:56 -07:00
austinpower1258 eae5bb5985 Add #9550 AX window retention regression test 2026-08-06 22:51:56 -07:00
lawrencecchen 021a839dbe fix: own saved-state reset API 2026-08-06 22:48:03 -07:00
austinpower1258 728dfec369 test: cover implicit remote shell lifecycle routing 2026-08-06 22:33:00 -07:00
lawrencecchen e326110ba6 fix: rename reset state before deletion 2026-08-06 22:30:31 -07:00
austinpower1258 b36be1d285 fix: detect installed Hermes runtime and restore its official icon 2026-08-06 22:24:14 -07:00
austinpower1258 e539849606 Merge remote-tracking branch 'origin/main' into issue-9706-lease-gone-live-pty 2026-08-06 22:23:46 -07:00
lawrencecchen 3a2f4b79d3 fix: cover session sidecars in reset token 2026-08-06 22:20:54 -07:00
lawrencecchen 42d96b53cb fix: harden session guard coordinator 2026-08-06 22:12:23 -07:00
austinpower1258 e76a4b0bdd test: cover drag-to-split cursor focus 2026-08-06 22:11:38 -07:00
austinpower1258 1dcf57676d test: cover real Hermes restore and Vault presentation 2026-08-06 22:10:02 -07:00
austinpower1258 05f3e1a0a9 fix: wait for PTY teardown before daemon exit 2026-08-06 22:03:51 -07:00
lawrencecchen 70ac77691d fix: clean exact reset guards safely 2026-08-06 22:03:41 -07:00
austinpower1258 1d7626f5a0 fix: keep background attention inside cmux 2026-08-06 22:01:34 -07:00
austinpower1258 598dbd2da3 fix: address dock title lifecycle review findings 2026-08-06 22:00:10 -07:00
lawrencecchen 187dc8d16b fix: bound reset guard storage 2026-08-06 21:57:44 -07:00
austinpower1258 f6d690d0d1 test: keep daemon alive during PTY teardown 2026-08-06 21:54:07 -07:00
lawrencecchen 42e1fd1d52 fix: order reset confirmation lifecycle 2026-08-06 21:49:16 -07:00
austinpower1258 c2e9a50145 test: prevent background attention from reaching AppKit 2026-08-06 21:45:18 -07:00
lawrencecchen cafa67af0e fix: avoid unsupported windows directory flush 2026-08-06 21:41:50 -07:00
austinpower1258 068e855f77 fix: refine persistent daemon exit checks 2026-08-06 21:38:13 -07:00
austinpower1258 3d3b2642f4 fix: import workspace shell activity state 2026-08-06 21:37:39 -07:00
lawrencecchen 7469555336 fix: bind reset confirmation to state identity 2026-08-06 21:34:26 -07:00
austinpower1258 a503a36e87 Merge remote-tracking branch 'origin/main' into issue-9706-lease-gone-live-pty 2026-08-06 21:29:50 -07:00
austinpower1258 4fedac14cb test: reproduce diff viewer scheme deadlock 2026-08-06 21:28:36 -07:00
austinpower1258 06e27e445b fix: preserve live PTYs after relay lease loss 2026-08-06 21:26:02 -07:00
lawrencecchen 0403ad22bb fix: harden guarded state reset 2026-08-06 21:24:41 -07:00
austinpower1258 b74ce85df9 fix: rotate hibernated terminal process generations 2026-08-06 21:17:28 -07:00
lawrencecchen bc83387350 fix: require reset preview confirmation 2026-08-06 21:15:56 -07:00
austinpower1258 e018d5f071 test: bound shell dedupe across process generations 2026-08-06 21:08:57 -07:00
austinpower1258 2dd592e8ee test: preserve live PTY after relay lease loss 2026-08-06 21:08:29 -07:00
austinpower1258 f0db8f9315 test: cover hibernated terminal process generations 2026-08-06 21:08:17 -07:00
lawrencecchen 53c3a67ac3 fix: avoid mutating missing reset targets 2026-08-06 21:05:59 -07:00
lawrencecchen 5319386733 fix: require durable reset sync 2026-08-06 21:01:05 -07:00
lawrencecchen c68b5ac8bf fix: reuse terminal host reset snapshot 2026-08-06 20:56:08 -07:00
austinpower1258 9b06ef32fb fix: guard restored Dock terminal titles 2026-08-06 20:48:50 -07:00
lawrencecchen 4aa79e32b2 test: gate terminal host reset coverage to unix 2026-08-06 20:48:03 -07:00
lawrencecchen 30055fa7e8 fix: report reset safety failures safely 2026-08-06 20:44:21 -07:00
lawrencecchen e38a863376 fix: make session reset preview first 2026-08-06 20:39:20 -07:00
austinpower1258 65e56c4f54 fix: address remaining Hermes review findings 2026-08-06 20:38:20 -07:00
lawrencecchen 3aaa605bdb fix: harden session state reset gate 2026-08-06 20:31:57 -07:00
austinpower1258 20fe7baa8a test: cover remaining Hermes review regressions 2026-08-06 20:05:56 -07:00
austinpower1258 42c3cfedbd fix: centralize Dock terminal title ownership 2026-08-06 17:43:57 -07:00
lawrencecchen 8227438ae8 fix: offer scoped session state reset recovery 2026-08-06 17:31:46 -07:00
lawrencecchen b0d71f0a3b test: cover incompatible session state reset recovery 2026-08-06 17:29:50 -07:00
austinpower1258 33424f95bc Merge remote-tracking branch 'origin/main' into issue-9337-dock-terminal-live-title 2026-08-06 17:23:36 -07:00
austinpower1258 a5b8826678 fix: propagate live titles to Dock terminals 2026-08-06 17:19:55 -07:00
cmux-lawrence 98abef6232 Merge remote-tracking branch 'origin/main' into codex/cmux-browser-provider 2026-08-06 08:11:29 -07:00
cmux-lawrence eb036e978c Merge remote-tracking branch 'origin/feat-terminal-multiview' into codex/cmux-browser-provider
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/server.rs
2026-08-06 08:11:25 -07:00
cmux-lawrence a97628581a Merge terminal multiview into browser provider 2026-08-06 07:38:12 -07:00
lawrencecchen 02ad3eb862 test(tui): accept completed browser resize 2026-08-06 07:00:18 -07:00
lawrencecchen 056127d9b7 test(tui): make queued attach deadlines deterministic 2026-08-06 06:20:17 -07:00
lawrencecchen 0060dc3189 test(tui): isolate render scan instrumentation 2026-08-06 05:42:21 -07:00
lawrencecchen 0d55ba54ad test(tui): await committed terminal before teardown 2026-08-06 05:21:06 -07:00
lawrencecchen a5c24d79bf fix(tui): fail closed on browser view indexes 2026-08-06 05:07:21 -07:00
lawrencecchen 6c9519e681 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-06 04:53:50 -07:00
lawrencecchen 6fdd0dbd40 fix(tui): restore resource protocol v2 2026-08-06 04:47:52 -07:00
cmux-lawrence 03fe214ac5 Merge latest terminal multiview changes 2026-08-06 04:45:54 -07:00
cmux-lawrence 0a2f53f593 feat(tui): revision-fence viewport bootstrap creation 2026-08-06 04:45:26 -07:00
lawrencecchen f0bb535e31 test(tui): reject legacy zero-view snapshots 2026-08-06 04:42:56 -07:00
lawrencecchen 2bb6acf2a1 test(tui): harden browser index normalization 2026-08-06 04:08:37 -07:00
lawrencecchen 48a5c97652 test(tui): isolate shutdown cancellation timing 2026-08-06 04:01:32 -07:00
cmux-lawrence b885c71dec feat(tui): separate creation receipts from retry attempts 2026-08-06 03:46:06 -07:00
lawrencecchen 006fb168d0 ci(tui): keep normal test deadlines strict 2026-08-06 03:40:27 -07:00
lawrencecchen c43ee337ab fix(tui): validate multiview browser index shape 2026-08-06 03:38:51 -07:00
lawrencecchen de66eda89c test(tui): reconcile journal multiview regressions 2026-08-06 03:38:45 -07:00
cmux-lawrence dcee6081b1 Merge remote-tracking branch 'origin/feat-terminal-multiview' into codex/cmux-browser-provider
# Conflicts:
#	cmux-tui/bindings/go/operations.go
#	cmux-tui/bindings/go/resource_api_test.go
2026-08-06 03:31:32 -07:00
lawrencecchen cc92564ea8 docs(go): clarify terminal projection validation 2026-08-06 03:31:05 -07:00
cmux-lawrence 66b359e4f5 Merge remote-tracking branch 'origin/feat-cmux-tui-session-journal' into codex/cmux-browser-provider 2026-08-06 03:30:02 -07:00
lawrencecchen b1117d8edd refactor(go): localize terminal alias presence check 2026-08-06 03:23:59 -07:00
lawrencecchen 8bb3cc0394 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-06 03:18:50 -07:00
lawrencecchen 45f3856624 test(go): cover omitted terminal tab identities 2026-08-06 03:18:43 -07:00
lawrencecchen dac52bde2a Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal 2026-08-06 03:14:22 -07:00
lawrencecchen a783c2a7e3 test(tui): scale remote fixtures under instrumentation 2026-08-06 03:08:01 -07:00
cmux-lawrence af118fcd14 Merge remote-tracking branch 'origin/feat-terminal-multiview' into codex/cmux-browser-provider 2026-08-06 02:55:01 -07:00
cmux-lawrence 5e3dd51b85 Merge remote-tracking branch 'origin/feat-cmux-tui-session-journal' into codex/cmux-browser-provider 2026-08-06 02:55:00 -07:00
cmux-lawrence bbb720aaf6 test(tui): reconcile journal and multiview fixtures 2026-08-06 02:46:07 -07:00
cmux-lawrence de83baf7f0 test(tui): cover workspace-scoped browser receipts 2026-08-06 02:42:29 -07:00
cmux-lawrence ef13792ab1 fix(tui): repair journal test compilation 2026-08-06 02:42:29 -07:00
lawrencecchen 7870f50df0 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-06 02:42:07 -07:00
lawrencecchen c95ed1e1e8 test(tui): compare legacy terminal placement without import 2026-08-06 02:38:17 -07:00
lawrencecchen 4e5ad659c1 docs(tui): define all-view exit detachment 2026-08-06 02:35:09 -07:00
lawrencecchen 4bb7093091 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-06 02:32:01 -07:00
lawrencecchen fb7dfa3bb0 fix(tui): preserve protocol-one snapshot compatibility 2026-08-06 02:31:14 -07:00
lawrencecchen eb43345d8d test(zig): assert the remaining stream deadline 2026-08-06 02:31:11 -07:00
lawrencecchen b1506d2a2a test(web): cover hosted coderouter production gate 2026-08-06 02:25:48 -07:00
cmux-lawrence bcfb024ab5 Merge remote-tracking branch 'origin/feat-terminal-multiview' into codex/cmux-browser-provider 2026-08-06 02:24:00 -07:00
cmux-lawrence b15ec4e79c Merge remote-tracking branch 'origin/feat-cmux-tui-session-journal' into codex/cmux-browser-provider 2026-08-06 02:23:58 -07:00
lawrencecchen c5c6dc3f1d fix(tui): harden journal replay and SDK streams 2026-08-06 02:19:55 -07:00
lawrencecchen 6631c6451a Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-06 02:19:52 -07:00
lawrencecchen 3a76870899 ci(tui): isolate application valgrind tests 2026-08-06 02:19:24 -07:00
lawrencecchen de03a228e3 test(tui): make scheduler fixtures cooperative 2026-08-06 02:19:23 -07:00
lawrencecchen 7443d3f24b chore: keep terminal multiview change scoped 2026-08-06 01:52:15 -07:00
lawrencecchen f1225c629f ci(tui): shard valgrind leak checks 2026-08-06 01:40:38 -07:00
lawrencecchen 2ea2c5f898 test(tui): expose journal merge blockers 2026-08-06 01:28:56 -07:00
cmux-lawrence ec12d89252 Merge latest terminal multiview 2026-08-06 00:51:30 -07:00
cmux-lawrence 71998628d0 Merge latest TUI session journal 2026-08-06 00:51:30 -07:00
lawrencecchen c8c50a3d1d docs(tui): describe unbounded host wakeup accurately 2026-08-06 00:35:02 -07:00
lawrencecchen 64e70cdf92 Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs
2026-08-06 00:34:34 -07:00
lawrencecchen 379ea2132a test(tui): stop leaking SDK stream metadata 2026-08-06 00:32:02 -07:00
lawrencecchen 89e72c2001 fix(tui): make host backpressure byte-authoritative 2026-08-06 00:25:45 -07:00
lawrencecchen 97ec95b386 perf(tui): compact installed hook commands 2026-08-06 00:23:55 -07:00
lawrencecchen f00be78538 test(tui): bound installed hook commands 2026-08-06 00:22:35 -07:00
lawrencecchen 6aaaffc673 test(tui): expose hidden host frame queue limit 2026-08-06 00:21:00 -07:00
lawrencecchen f6a3900550 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-06 00:20:09 -07:00
lawrencecchen 8f08195915 fix(sdk): drain final stream frames on macOS 2026-08-05 23:59:04 -07:00
lawrencecchen ba10e4a156 test(sdk): expose sidebar terminal failures 2026-08-05 23:58:07 -07:00
lawrencecchen cc4e8e61fd style(tui): satisfy stream acknowledgement lint 2026-08-05 23:54:47 -07:00
lawrencecchen 5bc1857565 fix(tui): reconcile canonical consumer fixtures 2026-08-05 23:48:45 -07:00
lawrencecchen 62f5244f97 test(tui): reject legacy terminal aliases in consumers 2026-08-05 23:43:24 -07:00
cmux-lawrence 16cb714407 Merge latest TUI session journal
# Conflicts:
#	cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
#	cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
#	cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/go/raw/generated_commands.go
#	cmux-tui/bindings/go/raw/generated_events.go
#	cmux-tui/bindings/go/raw/generated_metadata.go
#	cmux-tui/bindings/go/raw/generated_presence_test.go
#	cmux-tui/bindings/go/raw/generated_types.go
#	cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java
#	cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/python/cmux/raw/_generated/_schema.py
#	cmux-tui/bindings/python/cmux/raw/_generated/metadata.py
#	cmux-tui/bindings/rust-sidebar/tests/runtime.rs
#	cmux-tui/bindings/rust/src/codec.rs
#	cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/rust/src/generated/commands.rs
#	cmux-tui/bindings/rust/src/generated/events.rs
#	cmux-tui/bindings/rust/src/generated/metadata.rs
#	cmux-tui/bindings/rust/src/generated/mod.rs
#	cmux-tui/bindings/rust/src/generated/types.rs
#	cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/typescript/src/raw/generated/commands.ts
#	cmux-tui/bindings/typescript/src/raw/generated/events.ts
#	cmux-tui/bindings/typescript/src/raw/generated/index.ts
#	cmux-tui/bindings/typescript/src/raw/generated/metadata.ts
#	cmux-tui/bindings/typescript/src/raw/generated/types.ts
#	cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/zig/src/raw/generated/protocol.zig
#	cmux-tui/crates/cmux-tui/src/main.rs
2026-08-05 23:27:51 -07:00
lawrencecchen 5562335384 ci(tui): isolate valgrind runtime instrumentation 2026-08-05 23:27:35 -07:00
cmux-lawrence 6bb27209da Merge latest terminal multiview 2026-08-05 23:12:11 -07:00
lawrencecchen 4db9904bee refactor(tui): name resource close plan inputs 2026-08-05 22:47:44 -07:00
lawrencecchen 771f43a6e2 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-05 22:42:50 -07:00
lawrencecchen db69ebb54e style(tui): format inactive hook regression 2026-08-05 22:41:17 -07:00
lawrencecchen da8cb163fd Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	.github/workflows/cmux-tui.yml
2026-08-05 22:23:13 -07:00
lawrencecchen d8be804af0 perf(tui): fail inactive agent hooks immediately 2026-08-05 22:22:07 -07:00
lawrencecchen 5e403546c2 test(tui): require stale hooks to fail fast 2026-08-05 22:10:03 -07:00
lawrencecchen d019394de3 style(tui): stabilize deadline job formatting 2026-08-05 22:04:56 -07:00
lawrencecchen 9e8ff119a0 fix(tui): scope agent trees by session identity 2026-08-05 22:04:33 -07:00
lawrencecchen 744fa50f9b test(tui): keep publication probe formatter-stable 2026-08-05 22:01:13 -07:00
lawrencecchen 594993f854 test(tui): keep progressive agent roots together 2026-08-05 21:56:21 -07:00
lawrencecchen ae7b38683f fix(tui): fence fanout deadline admission 2026-08-05 21:54:43 -07:00
lawrencecchen a4ca53d28c style(tui): format concurrency regression 2026-08-05 21:51:24 -07:00
lawrencecchen 11c0362fbf Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-05 21:50:14 -07:00
lawrencecchen a9f3672402 fix(tui): stop fanout admission at shared deadline 2026-08-05 21:50:06 -07:00
lawrencecchen 8f2c9a30c3 test(tui): reject expired fanout admission 2026-08-05 21:49:25 -07:00
lawrencecchen cedfd80ea6 fix(tui): publish workspace deltas in commit order 2026-08-05 21:41:35 -07:00
lawrencecchen 91f8e47ad5 perf(tui): delegate hook activation to helper 2026-08-05 21:34:48 -07:00
lawrencecchen ff257deec4 test(tui): require ordered workspace publication fence 2026-08-05 21:24:59 -07:00
cmux-lawrence a45c9b5e6f Merge latest cmux main 2026-08-05 21:22:12 -07:00
cmux-lawrence 0c1a436944 Merge latest terminal multiview fixes 2026-08-05 21:22:12 -07:00
lawrencecchen 9d840c07d6 test(tui): require minimal delegated hook commands 2026-08-05 21:05:41 -07:00
cmux-lawrence 390291c28b feat(tui): attach browser surfaces to native provider 2026-08-05 21:04:27 -07:00
lawrencecchen 2a42acbc8f Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	.github/workflows/cmux-tui.yml
#	cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
#	cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
#	cmux-tui/bindings/go/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/go/raw/generated_commands.go
#	cmux-tui/bindings/go/raw/generated_events.go
#	cmux-tui/bindings/go/raw/generated_metadata.go
#	cmux-tui/bindings/go/raw/generated_presence_test.go
#	cmux-tui/bindings/go/raw/generated_types.go
#	cmux-tui/bindings/java/src/com/cmux/raw/.cmux-sdk-manifest.json
#	cmux-tui/bindings/java/src/com/cmux/raw/Protocol.java
#	cmux-tui/bindings/python/cmux/raw/_generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/python/cmux/raw/_generated/_schema.py
#	cmux-tui/bindings/python/cmux/raw/_generated/metadata.py
#	cmux-tui/bindings/rust-sidebar/tests/runtime.rs
#	cmux-tui/bindings/rust/src/client.rs
#	cmux-tui/bindings/rust/src/codec.rs
#	cmux-tui/bindings/rust/src/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/rust/src/generated/commands.rs
#	cmux-tui/bindings/rust/src/generated/events.rs
#	cmux-tui/bindings/rust/src/generated/metadata.rs
#	cmux-tui/bindings/rust/src/generated/mod.rs
#	cmux-tui/bindings/rust/src/generated/types.rs
#	cmux-tui/bindings/rust/src/resource/client.rs
#	cmux-tui/bindings/rust/tests/mock_server.rs
#	cmux-tui/bindings/typescript/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/typescript/src/raw/generated/commands.ts
#	cmux-tui/bindings/typescript/src/raw/generated/events.ts
#	cmux-tui/bindings/typescript/src/raw/generated/index.ts
#	cmux-tui/bindings/typescript/src/raw/generated/metadata.ts
#	cmux-tui/bindings/typescript/src/raw/generated/types.ts
#	cmux-tui/bindings/zig/src/raw/generated/.cmux-sdk-manifest.json
#	cmux-tui/bindings/zig/src/raw/generated/protocol.zig
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs
#	cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs
#	cmux-tui/crates/cmux-tui/src/session/mod.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
2026-08-05 20:53:51 -07:00
lawrencecchen adf24ae22c ci(tui): scope TLS valgrind undefined checks 2026-08-05 20:37:24 -07:00
austinpower1258 edce9f5b68 Merge remote-tracking branch 'origin/main' into issue-9520-hermes-first-class 2026-08-05 20:28:57 -07:00
austinpower1258 5252b324b5 fix: address Hermes lifecycle review findings 2026-08-05 20:28:12 -07:00
lawrencecchen be17bc0c1e perf(tui): keep agent hooks off critical paths 2026-08-05 20:25:41 -07:00
austinpower1258 9f9872e564 test: cover Hermes review regressions 2026-08-05 20:24:15 -07:00
lawrencecchen c4a31d85f7 ci(tui): scope valgrind origin tracking 2026-08-05 20:20:17 -07:00
lawrencecchen 919a164250 ci(tui): isolate host backpressure ordering 2026-08-05 20:08:08 -07:00
lawrencecchen 633375d3ed test(tui): synchronize deferred host ack 2026-08-05 20:08:08 -07:00
lawrencecchen 3872207c66 test(tui): require nonblocking portable agent hooks 2026-08-05 20:03:36 -07:00
cmux-lawrence 029b8c0ec4 Merge remote-tracking branch 'origin/feat-terminal-multiview' into codex/cmux-browser-provider
# Conflicts:
#	.github/workflows/cmux-tui.yml
#	cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
2026-08-05 20:02:44 -07:00
lawrencecchen d73fe9a56a ci(remote): budget latency under instrumentation 2026-08-05 19:40:36 -07:00
lawrencecchen b54d74441a test(remote): require instrumented latency budget 2026-08-05 19:39:26 -07:00
lawrencecchen 4fbbcca4bc ci(tui): serialize instrumented runtime tests 2026-08-05 19:12:04 -07:00
lawrencecchen 15de9dd8b6 test(tui): scale loaded browser verification 2026-08-05 18:55:23 -07:00
lawrencecchen ffea43e97b Make animated selection fixture output-free 2026-08-05 18:46:50 -07:00
lawrencecchen ccf2165cd4 Make selection input fixture output-free 2026-08-05 18:46:50 -07:00
lawrencecchen 0a8d864e25 test(tui): bound browser event fixtures 2026-08-05 18:45:24 -07:00
lawrencecchen 545d767a28 test(tui): synchronize navigation supersession 2026-08-05 18:40:06 -07:00
lawrencecchen 80a34af8a4 Merge latest terminal multiview into journal integration base 2026-08-05 18:24:40 -07:00
lawrencecchen ec4fae94f5 test(tui): gate ordered write drain checks 2026-08-05 18:20:27 -07:00
lawrencecchen f30078837f test(tui): synchronize shutdown drain assertion 2026-08-05 18:10:42 -07:00
lawrencecchen 09701ba422 fix(tui): interrupt journal streams on signal 2026-08-05 17:54:33 -07:00
lawrencecchen e9f4829ccb Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-05 17:49:56 -07:00
lawrencecchen 05fda1290a test(tui): synchronize clear permit assertion 2026-08-05 17:49:12 -07:00
lawrencecchen 7f7bc01057 test(tui): cover prompt journal stream interrupt 2026-08-05 17:45:17 -07:00
lawrencecchen 4eeb357a94 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-05 17:27:10 -07:00
lawrencecchen 418e41b356 fix(tui): guard late cell pixel completion 2026-08-05 17:26:57 -07:00
lawrencecchen af8912444d Classify fanout completions by deadline 2026-08-05 17:25:38 -07:00
lawrencecchen 2f4986ace3 docs(tui): clarify agent hook activation 2026-08-05 17:16:07 -07:00
lawrencecchen 2c564d2577 test(sdk): synchronize stream close ownership 2026-08-05 17:11:09 -07:00
lawrencecchen cc11ec940c test(sidebar): synchronize idle stream snapshot 2026-08-05 16:45:59 -07:00
lawrencecchen 6caecb452c test(sdk): synchronize idle stream deadline 2026-08-05 16:32:07 -07:00
lawrencecchen 73c61239ba test(tui): make teardown checks deterministic 2026-08-05 16:13:48 -07:00
lawrencecchen f7c1cf7dc3 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-05 15:47:34 -07:00
lawrencecchen 0108db569f test(tui): make SDK cancellation check deterministic 2026-08-05 15:46:59 -07:00
lawrencecchen 940e8481af fix(tui): harden terminal multiview lifecycle 2026-08-05 15:25:12 -07:00
lawrencecchen 21cfbda64f test(tui): require close cleanup outside creation fence 2026-08-05 14:52:58 -07:00
lawrencecchen 327cbe6253 test(tui): cover terminal projection recovery gaps 2026-08-05 14:14:21 -07:00
lawrencecchen cd7ce7af84 Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/browser.rs
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/public_projection_store.rs
#	cmux-tui/crates/cmux-tui-core/tests/browser_runtime.rs
2026-08-05 09:38:58 -07:00
lawrencecchen 23bb4a9b5a perf(tui): trim journal queue allocations 2026-08-05 08:28:44 -07:00
lawrencecchen f00423e716 Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	.github/workflows/cmux-tui.yml
#	cmux-tui/bindings/rust-sidebar/tests/runtime.rs
#	cmux-tui/crates/cmux-tui/src/ui/graphics_writer.rs
2026-08-05 08:18:43 -07:00
lawrencecchen 7490693e51 fix(tui): preserve terminal exit stream ownership 2026-08-05 08:10:58 -07:00
lawrencecchen b9faca0d87 feat(tui): harden session journal hooks 2026-08-05 08:09:37 -07:00
lawrencecchen c94324fb86 fix(tui): make recovery validation deterministic 2026-08-05 06:33:08 -07:00
lawrencecchen c7a8940974 fix(tui): inventory sidebar menu actions 2026-08-05 06:21:40 -07:00
lawrencecchen a2d744109e fix(tui): isolate Zig SDK toolchain 2026-08-05 05:46:39 -07:00
lawrencecchen 88646922d4 feat(tui): add sidebar profiles and SSH connection flow 2026-08-05 05:44:01 -07:00
lawrencecchen fa06e524cc fix(tui): import projection id in app tests 2026-08-05 05:19:48 -07:00
lawrencecchen a2b4b6817b Merge remote-tracking branch 'origin/pr-9387' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/bindings/rust/src/codec.rs
#	cmux-tui/bindings/rust/src/resource/client.rs
2026-08-05 05:16:46 -07:00
lawrencecchen be78737a4a fix(web): inject hosted Subrouter client factory 2026-08-05 05:07:21 -07:00
lawrencecchen b3ea7e4415 test(web): isolate hosted Subrouter environment 2026-08-05 04:57:30 -07:00
lawrencecchen 78474245e3 fix(tui): restore cross-platform validation 2026-08-05 04:57:19 -07:00
lawrencecchen d087539bb9 test(tui): cover machine connection and sidebar profiles 2026-08-05 04:35:21 -07:00
lawrencecchen 885f440076 Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal 2026-08-05 04:28:08 -07:00
lawrencecchen 9d12698a4a fix(tui): make terminal placements canonical 2026-08-05 04:27:57 -07:00
lawrencecchen cc6a64ab58 test(tui): reject redundant terminal placement alias 2026-08-05 04:27:38 -07:00
lawrencecchen 7131fc976d Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-05 02:45:26 -07:00
lawrencecchen 0dc88abd92 fix(tui): satisfy projection lints 2026-08-05 02:44:22 -07:00
lawrencecchen f48773bea5 Merge remote-tracking branch 'origin/main' into feat-cmux-tui-session-journal 2026-08-05 02:39:56 -07:00
lawrencecchen 69e6423d99 Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/bindings/rust/tests/catalog_manifest.rs
#	cmux-tui/crates/cmux-tui-core/src/resource_api.rs
2026-08-05 02:39:49 -07:00
lawrencecchen 92094c2e24 perf(tui): batch journal topology enrichment 2026-08-05 02:39:14 -07:00
lawrencecchen 78fcd06dfd feat(tui): add bounded journal replay 2026-08-05 02:39:09 -07:00
lawrencecchen 1c5ff143d0 fix(tui): derive agent edges from session ancestry 2026-08-05 02:39:01 -07:00
lawrencecchen 62827450b4 fix(tui): make public projections self-consistent 2026-08-05 02:37:29 -07:00
lawrencecchen d8fa39962f test(tui): reproduce public projection review failures 2026-08-05 02:32:26 -07:00
lawrencecchen 03db16136a Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-05 02:27:59 -07:00
lawrencecchen cb31615a1b Close SSH carriers gracefully before reaping 2026-08-05 02:20:00 -07:00
lawrencecchen 073a25c591 test: require graceful SSH carrier close 2026-08-05 02:01:02 -07:00
lawrencecchen 708bae372d test(tui): require session-derived agent ancestry 2026-08-05 02:00:50 -07:00
lawrencecchen 0198a9942e fix(tui): preserve typed terminal launch failures 2026-08-05 01:59:53 -07:00
lawrencecchen bb7a9c20d4 fix(tui): remove unshipped snapshot compatibility 2026-08-05 01:59:46 -07:00
lawrencecchen 5d05b2acf9 test(sdk): lock terminal tab identity validation 2026-08-05 01:52:12 -07:00
lawrencecchen 6bfe30255a Reap pooled SSH runtimes on exit 2026-08-05 01:51:34 -07:00
lawrencecchen b11b008810 fix(sdk): derive Rust catalog hash in test 2026-08-05 01:49:05 -07:00
lawrencecchen 24eb1bb86f Merge remote-tracking branch 'origin/main' into feat-cmux-tui-session-journal 2026-08-05 01:40:39 -07:00
lawrencecchen 9e370b9368 Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/bindings/cpp/.cmux-resource-api.json
#	cmux-tui/bindings/go/.cmux-resource-api.json
#	cmux-tui/bindings/java/.cmux-resource-api.json
#	cmux-tui/bindings/python/.cmux-resource-api.json
#	cmux-tui/bindings/rust/.cmux-resource-api.json
#	cmux-tui/bindings/typescript/.cmux-resource-api.json
#	cmux-tui/bindings/zig/.cmux-resource-api.json
#	cmux-tui/crates/cmux-pty/src/macos.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/tests.rs
2026-08-05 01:40:29 -07:00
lawrencecchen c43d891622 fix(tui): migrate owned frontend projections 2026-08-05 01:35:39 -07:00
lawrencecchen fde3749a79 fix(sdk): decode legacy terminal snapshots 2026-08-05 01:31:20 -07:00
lawrencecchen 7a609cfe4f test(tui): require owned projection migration 2026-08-05 01:28:53 -07:00
lawrencecchen 4bb6fdcd0d Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-05 01:28:19 -07:00
lawrencecchen 9aabb2aaf1 Keep machine connections warm across switches 2026-08-05 01:26:55 -07:00
lawrencecchen a565a3042f feat(tui): scope frontend views with durable leases 2026-08-05 01:24:27 -07:00
lawrencecchen 29f1d3ee86 test(sdk): reproduce legacy terminal snapshot rejection 2026-08-05 01:16:27 -07:00
lawrencecchen 2871a0a50d fix(tui): keep recovery errors product-facing 2026-08-05 01:07:35 -07:00
lawrencecchen 258b208e82 test(tui): reject internal session recovery errors 2026-08-05 01:06:21 -07:00
lawrencecchen 806dc6e608 fix(tui): make host disconnect teardown race-free 2026-08-05 00:47:00 -07:00
lawrencecchen 1587afc334 test(tui): require window-scoped frontend ownership 2026-08-05 00:15:03 -07:00
lawrencecchen 83497be0f6 feat(tui): journal resource effect outcomes 2026-08-05 00:10:22 -07:00
lawrencecchen 568b0297bd test(tui): reproduce concurrent PTY descriptor leak 2026-08-05 00:08:16 -07:00
lawrencecchen 817da44a07 test(tui): require journaled resource effect outcomes 2026-08-05 00:03:40 -07:00
lawrencecchen f9d7c8af24 Merge branch 'feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/spec/terminal-host.md
2026-08-04 23:55:49 -07:00
lawrencecchen 501e287cb6 docs(tui): define persistent frontend ownership 2026-08-04 23:55:09 -07:00
lawrencecchen 097c586f2c fix(tui): keep one prelaunch resource protocol 2026-08-04 23:54:50 -07:00
lawrencecchen 310e99aa57 test: require immediate persistent machine selection 2026-08-04 23:49:49 -07:00
lawrencecchen 28da4e69e0 fix(tui): version the multiview resource contract 2026-08-04 23:09:40 -07:00
cmux reload-cloud 5d286eda8b feat: activate lifecycle-accurate Hermes hooks 2026-08-04 23:03:30 -07:00
cmux reload-cloud 25dbdc9a2b test: reject premature Hermes smart-approval notifications 2026-08-04 22:40:50 -07:00
lawrencecchen 80b57d3e17 test(tui): require protocol bump for multiview 2026-08-04 22:17:21 -07:00
lawrencecchen 64e3969f0e Merge remote-tracking branch 'origin/main' into feat-cmux-tui-session-journal 2026-08-04 22:16:55 -07:00
lawrencecchen 4106ee805f Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_protocol.rs
#	cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime.rs
2026-08-04 22:16:49 -07:00
lawrencecchen cf7474edc5 Document terminal host launch failures 2026-08-04 22:15:24 -07:00
lawrencecchen 5fae1279d2 Make animated selection fixture output-free 2026-08-04 22:04:30 -07:00
lawrencecchen 8698ef6fc0 Render status for empty machine sessions 2026-08-04 22:02:06 -07:00
lawrencecchen 7bd9a69b8b test: render status without a workspace 2026-08-04 21:59:07 -07:00
lawrencecchen bff27eacd3 Give serialized Valgrind coverage headroom 2026-08-04 21:50:59 -07:00
lawrencecchen f5f9bd0f93 Keep machine sidebar usable when terminal launch fails 2026-08-04 21:28:41 -07:00
lawrencecchen be464f3a67 test(tui): allow raced fanout completion 2026-08-04 21:28:27 -07:00
lawrencecchen dfafe87935 fix(tui): report terminal host launch failures 2026-08-04 21:26:11 -07:00
lawrencecchen 080ee02201 test: preserve terminal host launch errors 2026-08-04 21:22:43 -07:00
lawrencecchen 45ba66fdee test(tui): reproduce hidden terminal host launch failure 2026-08-04 21:13:28 -07:00
lawrencecchen d2c12ca2ce Serialize core tests under Valgrind 2026-08-04 21:08:36 -07:00
lawrencecchen 38741b4041 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview
# Conflicts:
#	cmux-tui/dist/scripts/test_linux_packages.py
2026-08-04 21:01:01 -07:00
lawrencecchen f27db14e3a Make selection input fixture output-free 2026-08-04 20:47:40 -07:00
lawrencecchen c1da806141 Make terminal detach the final stream marker 2026-08-04 20:41:46 -07:00
lawrencecchen 70ddd4e3ab Test self-detach terminal ordering 2026-08-04 20:38:06 -07:00
lawrencecchen ac8cca6a49 Stabilize deadline and drain verification 2026-08-04 20:20:27 -07:00
lawrencecchen d538be2cb1 Synchronize browser pointer test with frame authority 2026-08-04 20:06:44 -07:00
lawrencecchen 22203f0168 Derive browser retry test budget 2026-08-04 19:51:52 -07:00
lawrencecchen 958936f126 Classify fanout completions by deadline 2026-08-04 19:51:52 -07:00
lawrencecchen 6975c4f646 Stabilize Rust stream close test 2026-08-04 19:25:55 -07:00
lawrencecchen 1d407b5da9 Align C++ protocol coverage count 2026-08-04 19:10:21 -07:00
lawrencecchen 0a401b2b5b Align SDK coverage with protocol inventory 2026-08-04 19:05:18 -07:00
lawrencecchen 75115171f1 Record terminal host activation message 2026-08-04 19:01:30 -07:00
lawrencecchen a3a99c487d Stabilize hosted terminal verification 2026-08-04 18:57:21 -07:00
lawrencecchen fe6fedbe23 Satisfy strict journal router lint 2026-08-04 18:18:39 -07:00
lawrencecchen a2b3b37f16 Merge latest terminal multiview base 2026-08-04 18:13:26 -07:00
lawrencecchen 13fa80e756 Activate sidebar resources on mouse down 2026-08-04 17:56:52 -07:00
lawrencecchen 1bebaa05cc test: require sidebar activation on mouse down 2026-08-04 17:39:13 -07:00
lawrencecchen a23c328f58 Order initial terminal output after topology 2026-08-04 17:28:41 -07:00
lawrencecchen e0d5a42302 test(tui): keep attach smoke ephemeral 2026-08-04 16:59:21 -07:00
lawrencecchen db93a463e3 fix(tui): classify superseded surface attaches 2026-08-04 16:46:43 -07:00
lawrencecchen ae830c381b Test initial terminal output journaling 2026-08-04 16:44:34 -07:00
lawrencecchen ea1242e0c1 test(tui): reproduce mirror retirement attach race 2026-08-04 16:37:46 -07:00
lawrencecchen 9127dfe9e7 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-04 16:28:39 -07:00
lawrencecchen 06575bb203 fix(tui): retire stale surface attach races 2026-08-04 16:28:15 -07:00
lawrencecchen f58e55ecc0 test(tui): reproduce stale surface attach race 2026-08-04 16:13:24 -07:00
lawrencecchen 372d8afec0 Harden loaded TUI verification 2026-08-04 15:53:53 -07:00
lawrencecchen 28428710d4 Test fragmented terminal host delivery 2026-08-04 15:53:26 -07:00
lawrencecchen 7a79c03fcf Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-04 15:38:05 -07:00
lawrencecchen d02e6517a7 test(tui): make attach smoke lifecycle-aware 2026-08-04 15:30:44 -07:00
cmux reload-cloud ed841e0890 test: require automatic Hermes hook activation 2026-08-04 15:19:44 -07:00
lawrencecchen c49c06944e fix(tui): bind auxiliary PTYs to daemon lifetime 2026-08-04 14:03:06 -07:00
lawrencecchen c910b38fcc Stabilize journal CI under load 2026-08-04 13:03:02 -07:00
lawrencecchen a1dbabd3cb test(tui): reproduce sidebar host leak on shutdown 2026-08-04 12:05:25 -07:00
lawrencecchen 9378f548d5 test(tui): isolate CLI fixtures from user config 2026-08-04 11:56:11 -07:00
lawrencecchen cd1c02a0c2 Stabilize cross-platform TUI verification 2026-08-04 11:13:55 -07:00
lawrencecchen 0066bb5a58 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-04 10:16:50 -07:00
lawrencecchen 6e6a292ebd fix(tui): publish multiview control contracts 2026-08-04 08:40:08 -07:00
lawrencecchen 2842bc11bd test(tui): reproduce boxed request schema omission 2026-08-04 08:15:19 -07:00
lawrencecchen 0ccae477cf Optimize indexed journal subject catch-up 2026-08-04 07:53:18 -07:00
lawrencecchen 50b7dc7ee5 fix(tui): restore projection viewport state 2026-08-04 07:01:10 -07:00
lawrencecchen cd66b9211a fix(tui): release retired view attachments 2026-08-04 06:35:11 -07:00
lawrencecchen db67e7b5ca test(tui): reproduce retired attach lease leak 2026-08-04 05:46:09 -07:00
lawrencecchen 4f3be53952 fix(tui): make frontend journal ids cross-platform 2026-08-04 05:27:14 -07:00
lawrencecchen c2dc4ffb1f fix(tui): reconcile stacked multiview checks 2026-08-04 05:07:00 -07:00
lawrencecchen b1ed35008c Use managed SSH lifecycle for machine rail 2026-08-04 04:39:13 -07:00
lawrencecchen 64eb21554a fix(tui): specify multiview transport commands 2026-08-04 04:29:51 -07:00
lawrencecchen 83f024d3da fix(tui): isolate backend view projections 2026-08-04 04:27:54 -07:00
lawrencecchen 371151dfbc test(tui): reproduce multiview projection leaks 2026-08-04 04:22:00 -07:00
lawrencecchen 0c1d45be75 fix(tui): retain host input under mutation bursts 2026-08-04 04:11:14 -07:00
lawrencecchen 29151156a9 fix(tui): supersede stale attachment resizes 2026-08-04 04:10:16 -07:00
lawrencecchen 266cfe77e1 Merge remote-tracking branch 'origin/feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/server.rs
#	cmux-tui/crates/cmux-tui-core/tests/pty.rs
#	cmux-tui/crates/cmux-tui/src/app.rs
#	cmux-tui/crates/cmux-tui/src/session/mod.rs
#	cmux-tui/crates/cmux-tui/src/session/tree.rs
2026-08-04 04:03:04 -07:00
lawrencecchen 9f01450d83 test: require managed SSH machine lifecycle 2026-08-04 03:58:45 -07:00
lawrencecchen 915608e83c test(tui): reproduce stale attachment resize failure 2026-08-04 03:58:23 -07:00
lawrencecchen 711c9392a7 fix(tui): type openpty window size per platform 2026-08-04 03:46:05 -07:00
lawrencecchen 188064ff34 fix(tui): make view size release idempotent 2026-08-04 03:33:27 -07:00
lawrencecchen 8c641eb814 fix(tui): sync journal SDK compatibility gates 2026-08-04 03:33:25 -07:00
lawrencecchen 417c27fb24 Document status copy menu route 2026-08-04 03:23:31 -07:00
lawrencecchen 853707a627 test(tui): reproduce missing size lease release loop 2026-08-04 03:13:27 -07:00
lawrencecchen 5c20c8c5a7 Fix status copy menu activation 2026-08-04 02:43:14 -07:00
lawrencecchen c92ac4282d test: activate status copy menu 2026-08-04 02:37:00 -07:00
lawrencecchen db56ace820 Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-04 02:12:47 -07:00
lawrencecchen 75fde6abbd Fix startup config drain and selectable status errors 2026-08-04 02:08:54 -07:00
lawrencecchen 637e151e2a fix(tui): update resource operation count 2026-08-04 02:06:36 -07:00
lawrencecchen d354cba9e6 fix(tui): register frontend journal command 2026-08-04 01:57:34 -07:00
lawrencecchen 999ee84511 test(tui): reproduce burst input loss 2026-08-04 01:35:51 -07:00
austinpower1258 4ff40fe83e Harden Hermes hook routing and process matching 2026-08-04 01:04:32 -07:00
lawrencecchen 6610c7a415 fix(tui): keep smoke terminals process-owned 2026-08-04 00:53:30 -07:00
lawrencecchen 7cbc6301bd Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-04 00:50:23 -07:00
lawrencecchen ca5980883c fix(tui): establish the startup input route 2026-08-04 00:50:04 -07:00
lawrencecchen 64d8bd9049 test(tui): reproduce empty startup input route 2026-08-04 00:40:20 -07:00
austinpower1258 55b4dc3a37 test: harden Hermes integration fixtures 2026-08-04 00:38:28 -07:00
lawrencecchen d482b42907 test: cover startup pipe stall and status selection 2026-08-04 00:37:11 -07:00
lawrencecchen 8d7205bb3d test(tui): cover installed Claude hook alias 2026-08-04 00:35:44 -07:00
austinpower1258 efd73a3eac test: fix Hermes fixture type inference 2026-08-04 00:34:13 -07:00
austinpower1258 b32dfb7180 feat: make Hermes a durable first-class agent 2026-08-04 00:29:52 -07:00
austinpower1258 0792812120 test: cover Hermes hook approval migration 2026-08-04 00:23:43 -07:00
lawrencecchen 8b95103041 fix(tui): recognize Claude hook adapter alias 2026-08-04 00:21:54 -07:00
lawrencecchen 8faac39503 perf(tui): bound hook dispatcher writer holds 2026-08-04 00:10:18 -07:00
austinpower1258 5fca512ada test: cover Hermes durable restore and pinned hooks 2026-08-04 00:04:27 -07:00
lawrencecchen 57690dafed fix(tui): unify agent root aliases 2026-08-03 23:53:20 -07:00
lawrencecchen 2089a1e823 fix(tui): make hook helper help succeed 2026-08-03 23:42:40 -07:00
lawrencecchen fe86abc81a feat(tui): index nested agent topology 2026-08-03 23:38:10 -07:00
lawrencecchen a1157423ec style(tui): format degenerate split layout 2026-08-03 23:34:58 -07:00
lawrencecchen 11ab0fbc06 Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-03 23:31:43 -07:00
lawrencecchen 147b10c3ad fix(tui): make degenerate split layout total 2026-08-03 23:26:28 -07:00
lawrencecchen 749aacb8d3 test(tui): reproduce zero-size split crash 2026-08-03 23:15:45 -07:00
lawrencecchen 45800ecdf7 Add native menu scrolling and global sidebar access 2026-08-03 23:13:30 -07:00
lawrencecchen d132217415 fix(tui): preserve queued creation through terminal exit 2026-08-03 23:07:44 -07:00
lawrencecchen 96c8c04f8a fix(tui): omit absent journal ingress fields 2026-08-03 21:38:34 -07:00
lawrencecchen 9644a6cf89 test(tui): reject null agent journal optionals 2026-08-03 21:29:00 -07:00
lawrencecchen 46d09c700f Test native menu scrolling and global sidebar access 2026-08-03 21:25:32 -07:00
lawrencecchen 2e3bbb12b9 feat(tui): normalize nested agent hook envelopes 2026-08-03 21:17:21 -07:00
lawrencecchen 674a653392 Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-03 20:22:01 -07:00
lawrencecchen dc9fc8a9c0 feat(tui): ingest native agent hooks losslessly 2026-08-03 19:41:12 -07:00
lawrencecchen e98fc16b82 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-03 19:18:34 -07:00
lawrencecchen a1874d37a9 fix(tui): make terminal projections client-local and receipt ordered 2026-08-03 19:17:22 -07:00
lawrencecchen 4e891b94b3 perf(tui): make hook completion wakeups lossless 2026-08-03 18:27:38 -07:00
lawrencecchen c15888ffa8 test(tui): require durable hook worker wakeups 2026-08-03 18:25:40 -07:00
lawrencecchen b1dc7b81a5 perf(tui): bound and batch journal hook delivery 2026-08-03 17:41:30 -07:00
lawrencecchen 18dbd62c00 test(tui): reproduce semantic destination races 2026-08-03 17:14:09 -07:00
lawrencecchen 4c2deae7f1 test(tui): require bounded journal hook workers 2026-08-03 17:04:23 -07:00
lawrencecchen 16ee5ed7ee Align recoverable workspace test with explicit focus 2026-08-03 16:54:31 -07:00
lawrencecchen 83a893a6bb Fix sidebar regression test imports 2026-08-03 16:52:04 -07:00
lawrencecchen 35916bf668 Keep modified navigation directional in file view 2026-08-03 16:44:09 -07:00
lawrencecchen 2190da2f3b Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-03 16:38:29 -07:00
lawrencecchen c8b1485a5a Keep sidebar resource clicks terminal-focused 2026-08-03 16:38:22 -07:00
lawrencecchen 2f1cbb5e5f Test modifier navigation through sidebar trees 2026-08-03 16:34:16 -07:00
lawrencecchen afcb8821f5 Test explicit sidebar focus entrypoints 2026-08-03 16:32:33 -07:00
lawrencecchen a122f1f2d0 Compose native actions into sidebar views 2026-08-03 16:27:42 -07:00
lawrencecchen 7f9a8c62dc Test workspace tree creation action 2026-08-03 16:16:24 -07:00
lawrencecchen 4d62f5437b Optimize journal ingress merge representation 2026-08-03 10:14:47 -07:00
lawrencecchen a6e27363ef Sync SDK descriptors with journal catalog 2026-08-03 09:58:14 -07:00
lawrencecchen 0a92bc94c3 Make graphics invalidation race test deterministic 2026-08-03 09:38:44 -07:00
lawrencecchen 951150abd4 Reject terminal attaches after host exit 2026-08-03 09:29:46 -07:00
lawrencecchen 34dac05868 Test every journal CLI operation path 2026-08-03 09:29:46 -07:00
lawrencecchen 534282b87d Bound terminal journal chunk allocations 2026-08-03 09:06:17 -07:00
lawrencecchen ee03102caf Test bounded terminal journal chunk allocations 2026-08-03 09:04:37 -07:00
lawrencecchen c63683f6d6 Preserve terminal journal batches across writer faults 2026-08-03 09:02:58 -07:00
lawrencecchen 1ed726f3f3 Test terminal journal recovery without data loss 2026-08-03 09:00:55 -07:00
lawrencecchen 48f779bda7 Keep journal idempotency stable across reconnects 2026-08-03 08:54:13 -07:00
lawrencecchen 2cf5e616ac Test journal retries across CLI reconnects 2026-08-03 08:51:39 -07:00
lawrencecchen 4951cb8e3b Omit absent journal hook filters from results 2026-08-03 08:44:36 -07:00
lawrencecchen 80776b6d1f Test journal hook list filter contract 2026-08-03 08:43:05 -07:00
lawrencecchen 996476db96 Fix journal restore preview result schema 2026-08-03 08:37:12 -07:00
lawrencecchen f0d97ed45c Test journal restore preview result contract 2026-08-03 08:35:21 -07:00
lawrencecchen 4c6abe344d Implement fast session journal hooks and replay 2026-08-03 08:32:55 -07:00
lawrencecchen e4cc51324a Format agent state validation 2026-08-03 07:38:04 -07:00
lawrencecchen c8bf9ee9de Use canonical agent states in CLI 2026-08-03 07:28:15 -07:00
lawrencecchen 353894b5f8 Test canonical agent CLI states 2026-08-03 07:24:17 -07:00
lawrencecchen 765375e38c Refresh sidebars when agent state changes 2026-08-03 07:17:29 -07:00
lawrencecchen fd015152d9 Test live agent sidebar updates 2026-08-03 07:13:51 -07:00
lawrencecchen 31aabfacbd Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-03 06:57:42 -07:00
lawrencecchen 707ec830d0 Fix resize feedback and exact tab rename 2026-08-03 06:57:32 -07:00
lawrencecchen 704d411dee Test resize acknowledgements and exact tab rename 2026-08-03 06:57:11 -07:00
lawrencecchen 633549cf3e Add configurable native sidebar projections 2026-08-03 06:56:48 -07:00
lawrencecchen ab63d93d3c Test journal receipt isolation by origin 2026-08-03 05:27:36 -07:00
lawrencecchen ddb3aaac9e Test journal convergence with terminal multiview 2026-08-03 05:25:33 -07:00
lawrencecchen 826f11302b Merge branch 'feat-terminal-multiview' into feat-cmux-tui-session-journal
# Conflicts:
#	cmux-tui/bindings/cpp/.cmux-resource-api.json
#	cmux-tui/bindings/go/.cmux-resource-api.json
#	cmux-tui/bindings/java/.cmux-resource-api.json
#	cmux-tui/bindings/python/.cmux-resource-api.json
#	cmux-tui/bindings/rust/.cmux-resource-api.json
#	cmux-tui/bindings/rust/src/resource/mod.rs
#	cmux-tui/bindings/rust/tests/catalog_manifest.rs
#	cmux-tui/bindings/typescript/.cmux-resource-api.json
#	cmux-tui/bindings/zig/.cmux-resource-api.json
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs
#	cmux-tui/crates/cmux-tui-core/src/workspace_registry/terminal_exit_store.rs
#	cmux-tui/scripts/test_check_resource_api_boundary.py
#	cmux-tui/spec/commands.md
#	cmux-tui/spec/resource-api-v1.md
#	cmux-tui/spec/resource-operations-v1.md
2026-08-03 04:57:06 -07:00
lawrencecchen 6e797d0767 test(tui): reproduce input and resize lifecycle races 2026-08-03 04:51:57 -07:00
lawrencecchen 4db16e080b Add native SSH and rename menus to TUI columns 2026-08-03 04:30:55 -07:00
lawrencecchen 7cc56951fd Resolve checkpoint terminals by public identity 2026-08-03 04:13:28 -07:00
lawrencecchen fde0042687 Test checkpoint capture with public terminal IDs 2026-08-03 04:11:12 -07:00
lawrencecchen a2e698e366 Satisfy journal CLI warning gate 2026-08-03 04:06:23 -07:00
lawrencecchen bc9b31b49c fix(tui): atomically detach exited terminal views 2026-08-03 04:04:29 -07:00
lawrencecchen 97ee0fed25 Harden journal integrity and hook isolation 2026-08-03 04:04:08 -07:00
lawrencecchen 884e8a79fc Test journal integrity and hook isolation 2026-08-03 04:00:06 -07:00
lawrencecchen 1bfa8ab55f Add extensible journal hooks and restoration primitives 2026-08-03 03:57:27 -07:00
lawrencecchen e9e476bb16 test(tui): reproduce Ctrl-D exit topology leak 2026-08-03 03:36:05 -07:00
lawrencecchen 2a4d6b4923 Merge remote-tracking branch 'origin/main' into feat-tui-resource-columns 2026-08-03 03:35:45 -07:00
lawrencecchen 7ad218bfde Register machine source menu contract 2026-08-03 03:19:20 -07:00
lawrencecchen 749c5bf576 Prototype configurable TUI resource columns 2026-08-03 03:15:47 -07:00
lawrencecchen 6a0417d8fa Share decoded journal fanout across subscribers 2026-08-03 02:14:05 -07:00
lawrencecchen c567d31b52 Add compiled journal regex filters 2026-08-03 02:03:10 -07:00
lawrencecchen 96937e1934 Test compiled journal regex filters 2026-08-03 01:53:14 -07:00
lawrencecchen d6dd4aa32e Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-03 01:47:40 -07:00
lawrencecchen 785498a4cf Negotiate journal support with resident sessions 2026-08-03 01:46:22 -07:00
lawrencecchen d54ed30b9d Test stale journal subscription negotiation 2026-08-03 01:43:26 -07:00
lawrencecchen 1e96971e62 fix(tui): keep modifiers outside semantic input 2026-08-03 01:42:42 -07:00
lawrencecchen cd91e0a660 test(tui): reproduce modifier prefix cancellation 2026-08-03 01:41:13 -07:00
lawrencecchen b96c2b6156 Add durable journal subscriptions 2026-08-02 23:39:44 -07:00
lawrencecchen 610a7c9692 test(tui): move backpressure fixtures into workers 2026-08-02 23:28:46 -07:00
lawrencecchen 35a397355c Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-02 23:22:05 -07:00
lawrencecchen df719a1857 fix(tui): keep slow client projections current 2026-08-02 23:21:59 -07:00
lawrencecchen 3019323812 test(tui): reproduce slow-client output failures 2026-08-02 23:02:12 -07:00
lawrencecchen 4f738f2e80 Make journal migration repeat-safe 2026-08-02 21:32:28 -07:00
lawrencecchen a6ea6627a8 Add append-only cmux-tui session journal 2026-08-02 21:20:10 -07:00
lawrencecchen e88b100593 fix(tui): seed legacy compatibility workspace 2026-08-02 21:13:41 -07:00
lawrencecchen 9f433018c4 test(tui): cover legacy workspace selection recovery 2026-08-02 21:10:56 -07:00
lawrencecchen 9349df26ad Merge remote-tracking branch 'origin/main' into feat-terminal-multiview
# Conflicts:
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
2026-08-02 20:46:53 -07:00
lawrencecchen ea1e38db67 Implement client-local terminal projections 2026-08-01 18:27:41 -07:00
lawrencecchen 962bdfd035 Test enhanced prefix split routing regression 2026-08-01 16:10:49 -07:00
lawrencecchen 56fb9aca77 Align lifecycle docs with terminal projections 2026-08-01 08:58:15 -07:00
lawrencecchen 35974b46a9 Document terminal projection resource path 2026-08-01 08:49:35 -07:00
lawrencecchen 6f783b4ec9 Document terminal project in CLI help 2026-08-01 08:44:50 -07:00
lawrencecchen 71f2a5aaf6 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview
# Conflicts:
#	cmux-tui/bindings/ERGONOMICS.md
#	cmux-tui/crates/cmux-tui-core/src/mux.rs
#	cmux-tui/crates/cmux-tui-core/src/surface.rs
#	cmux-tui/crates/cmux-tui/src/session/remote.rs
#	cmux-tui/crates/cmux-tui/tests/terminal_host_recovery.rs
2026-08-01 08:38:43 -07:00
lawrencecchen 684ad0f7dc Fix projected terminal response and close 2026-08-01 07:52:54 -07:00
lawrencecchen c86144508d Complete terminal multiview lifecycle contracts 2026-08-01 07:28:42 -07:00
lawrencecchen d6cb088450 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview 2026-08-01 05:46:43 -07:00
lawrencecchen d7286996fa Harden terminal multiview lifecycle 2026-08-01 05:46:39 -07:00
lawrencecchen feccd2f698 Merge remote-tracking branch 'origin/main' into feat-terminal-multiview
# Conflicts:
#	cmux-tui/crates/cmux-tui-core/src/surface.rs
2026-08-01 04:55:07 -07:00
lawrencecchen e66472146e Implement client-local terminal multiview architecture 2026-08-01 04:53:42 -07:00
austinpower1258 cb37e0dfa3 test: bound stuck close regression failure 2026-07-31 23:44:03 -07:00
austinpower1258 e443d3aa7b test: assert terminal free gate state directly 2026-07-31 23:42:37 -07:00
austinpower1258 6e66669792 test: isolate terminal free gate state 2026-07-31 23:28:55 -07:00
austinpower1258 d5c5c3e8ad fix: isolate stuck terminal close teardowns 2026-07-31 23:27:02 -07:00
austinpower1258 40578022e7 test: require surface-scoped native free gate 2026-07-31 23:23:24 -07:00
austinpower1258 7fcc434fe6 test: cover stuck close teardown isolation 2026-07-31 23:16:41 -07:00
austinpower1258 96c6c1f86a test: bound terminal teardown watchdog waits 2026-07-31 22:41:06 -07:00
austinpower1258 4ba55e78b7 Merge remote-tracking branch 'origin/main' into issue-9220-sidebar-click-hang 2026-07-31 22:30:21 -07:00
austinpower1258 77ab34b3a4 fix: move explicit terminal teardown off main actor 2026-07-31 22:25:38 -07:00
austinpower1258 0a42b05f6b test: reproduce main-actor terminal teardown hang 2026-07-31 21:56:12 -07:00
austinpower1258 31bc556044 test: cover live titles for Dock terminals 2026-07-31 20:59:46 -07:00
lawrencecchen 6c7bdeab2e fix(tui-sdk): make clean CI consumers portable 2026-07-27 21:16:05 -07:00
lawrencecchen 2afaca5883 test(tui-sdk): cover clean SDK consumers 2026-07-27 20:31:41 -07:00
lawrencecchen 745b82a353 fix(tui-sdk): preserve JSON field presence 2026-07-26 22:23:33 -07:00
lawrencecchen 3a3e7a936d feat(tui): generate and verify seven language SDKs 2026-07-26 21:36:54 -07:00
lawrencecchen 92264853ca Merge main into TUI programmability inventory 2026-07-26 20:02:47 -07:00
lawrencecchen cc4ead3acb fix(tui): harden inventory authority contracts 2026-07-25 20:13:23 -07:00
lawrencecchen ac8dcf9f1f test(tui): cover external review hardening gaps 2026-07-25 20:07:27 -07:00
lawrencecchen 237f2a85f7 fix(tui): make action and event contracts truthful 2026-07-25 19:46:25 -07:00
lawrencecchen dd2a0d0261 test(tui): cover review contract gaps 2026-07-25 19:45:09 -07:00
lawrencecchen e8bb839066 fix(tui): derive menu inventory from runtime metadata 2026-07-25 19:25:45 -07:00
lawrencecchen 4d4df2e9d9 test(tui): require runtime-owned menu metadata 2026-07-25 19:22:28 -07:00
lawrencecchen 0632b61a32 fix(tui): align runtime and protocol contracts 2026-07-25 19:06:57 -07:00
lawrencecchen eb69ebd76e test(tui): cover autoreview contract regressions 2026-07-25 19:05:18 -07:00
lawrencecchen b02a3c6f4a fix(tui): derive inventory from runtime metadata 2026-07-25 18:43:19 -07:00
lawrencecchen 373410b4ee test(tui): require runtime-owned inventory metadata 2026-07-25 18:32:51 -07:00
lawrencecchen 60ca64ba49 fix(tui): exclude comments from event discovery 2026-07-25 18:24:22 -07:00
lawrencecchen 10a03284b6 test(tui): ignore comment-only wire events 2026-07-25 18:22:32 -07:00
lawrencecchen bd4bbc2b79 docs(tui): enforce authority and stream routes 2026-07-25 18:18:08 -07:00
lawrencecchen 24c53b3c53 test(tui): cover inventory routing contracts 2026-07-25 18:15:48 -07:00
lawrencecchen ba9b19e564 docs(tui): correct short ID example 2026-07-25 18:10:21 -07:00
lawrencecchen 4075b1b90b docs(tui): make current protocol risks explicit 2026-07-25 18:03:27 -07:00
lawrencecchen 3c1340ad10 docs(tui): resolve programmability review gaps 2026-07-25 18:02:14 -07:00
lawrencecchen ab864f093a test(tui): cover trailing enum variants 2026-07-25 17:59:07 -07:00
lawrencecchen d898471b3f Merge remote-tracking branch 'origin/main' into task-cmux-tui-spec-completeness 2026-07-25 17:55:45 -07:00
lawrencecchen 76af0dc225 docs(tui): enforce programmability inventory 2026-07-24 03:00:19 -07:00
Owen JohnsonandClaude Fable 5 7b159391e7 fix(control-socket): validate split orientation and de-flake tree layout test
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]>
2026-07-08 13:15:15 -04:00
owenjohnsonandClaude Opus 4.8 ad34dc6991 feat(control-socket): emit split-layout geometry in system.tree
`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
+1
View File
@@ -19,4 +19,5 @@ self-hosted-runner:
# Linux: Blacksmith primary (LINUX_RUNNER), WarpBuild overflow fallback.
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-8vcpu-ubuntu-2404
- blacksmith-32vcpu-ubuntu-2404
- warp-ubuntu-latest-x64-4x
@@ -0,0 +1,57 @@
name: Set up cmux-tui Rust
description: Install the repository-pinned cmux-tui Rust toolchain.
runs:
using: composite
steps:
- name: Install pinned Rust toolchain
shell: bash
env:
TOOLCHAIN_FILE: ${{ github.action_path }}/../../../cmux-tui/rust-toolchain.toml
run: |
if command -v python3 >/dev/null 2>&1; then
python_cmd=python3
elif command -v python >/dev/null 2>&1; then
python_cmd=python
else
echo "::error::Python is required to read the pinned Rust toolchain" >&2
exit 1
fi
"$python_cmd" <<'PY'
import os
import pathlib
import re
import subprocess
contents = pathlib.Path(os.environ["TOOLCHAIN_FILE"]).read_text(encoding="utf-8")
def value(name):
match = re.search(rf'^\s*{name}\s*=\s*"([^"]+)"', contents, re.MULTILINE)
if match is None:
raise SystemExit(f"missing {name} in cmux-tui/rust-toolchain.toml")
return match.group(1)
channel = value("channel")
profile = value("profile")
components_match = re.search(r"^\s*components\s*=\s*\[([^]]*)\]", contents, re.MULTILINE)
if components_match is None:
raise SystemExit("missing components in cmux-tui/rust-toolchain.toml")
components = re.findall(r'"([^"]+)"', components_match.group(1))
command = ["rustup", "toolchain", "install", channel, "--profile", profile]
for component in components:
command.extend(["--component", component])
subprocess.run(command, check=True)
subprocess.run(["rustup", "default", channel], check=True)
cargo = subprocess.run(
["rustup", "which", "cargo"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as path_file:
print(pathlib.Path(cargo).parent, file=path_file)
PY
cargo --version
rustc --version
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Run each cmux-tui-core Rust test in a fresh process."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import subprocess
import sys
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("cargo_messages", type=Path)
parser.add_argument("core_root", type=Path)
return parser.parse_args()
def is_below(path: Path, root: Path) -> bool:
try:
path.relative_to(root)
except ValueError:
return False
return True
def test_binaries(cargo_messages: Path, core_root: Path) -> list[Path]:
binaries: dict[Path, set[str]] = {}
with cargo_messages.open(encoding="utf-8") as messages:
for line in messages:
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
if message.get("reason") != "compiler-artifact":
continue
if not message.get("profile", {}).get("test"):
continue
target = message.get("target", {})
kinds = set(target.get("kind", []))
if not kinds.intersection({"lib", "test"}):
continue
source = target.get("src_path")
executable = message.get("executable")
if not source or not executable:
continue
if not is_below(Path(source).resolve(), core_root):
continue
binaries.setdefault(Path(executable).resolve(), set()).update(kinds)
library_binaries = [path for path, kinds in binaries.items() if "lib" in kinds]
if len(library_binaries) != 1:
raise SystemExit(
"expected one cmux-tui-core library test binary, "
f"found {len(library_binaries)}"
)
if not binaries:
raise SystemExit("cargo did not report any cmux-tui-core test binaries")
return sorted(binaries)
def tests_in(binary: Path) -> list[str]:
result = subprocess.run(
[str(binary), "--list"],
check=True,
stdout=subprocess.PIPE,
text=True,
)
tests = []
for line in result.stdout.splitlines():
name, separator, kind = line.rpartition(": ")
if separator and kind == "test":
tests.append(name)
if not tests:
raise SystemExit(f"{binary.name} did not list any tests")
if len(tests) != len(set(tests)):
raise SystemExit(f"{binary.name} listed duplicate test names")
return tests
def main() -> int:
args = parse_args()
core_root = args.core_root.resolve()
binaries = test_binaries(args.cargo_messages, core_root)
total = 0
for binary in binaries:
tests = tests_in(binary)
print(f"Running {len(tests)} tests from {binary.name} in fresh processes")
for index, test_name in enumerate(tests, start=1):
print(f"[{index}/{len(tests)}] {test_name}", flush=True)
subprocess.run(
[str(binary), test_name, "--exact", "--test-threads=1"],
check=True,
)
total += 1
print(f"Passed {total} isolated cmux-tui-core tests")
return 0
if __name__ == "__main__":
sys.exit(main())
+22 -10
View File
@@ -9,25 +9,37 @@ jobs:
fail-fast: false
matrix:
include:
- os: macos-14-large
- os: macos-14
timeout: 60
run_unit_tests: false
run_mobile_transport_tests: true
startup_smoke: true
virtual_display: true
skip_zig: false
expected_arch: arm64
expected_os_major: "14"
- os: macos-15-intel
timeout: 90
run_unit_tests: false
run_mobile_transport_tests: true
startup_smoke: true
virtual_display: true
skip_zig: false
expected_arch: x86_64
expected_os_major: "14"
expected_os_major: "15"
- os: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout: 60
run_unit_tests: true
timeout: 120
run_unit_tests: false
run_mobile_transport_tests: true
startup_smoke: true
virtual_display: true
skip_zig: false
expected_arch: ""
expected_os_major: ""
- os: ${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }}
timeout: 60
run_unit_tests: true
timeout: 120
run_unit_tests: false
run_mobile_transport_tests: true
startup_smoke: true
virtual_display: false
skip_zig: true # zig 0.15.2 MachO linker can't resolve libSystem on macOS 26
@@ -147,14 +159,14 @@ jobs:
sleep $((attempt * 5))
done
- name: Run mobile transport package tests on Intel Sonoma
if: matrix.expected_arch == 'x86_64'
- name: Run mobile transport package tests on compatibility hosts
if: matrix.run_mobile_transport_tests
run: |
./scripts/ci/run-swift-testing-suites.sh Packages/Shared/CMUXMobileCore
./scripts/ci/run-swift-testing-suites.sh Packages/Shared/CmuxIrohTransport
- name: Run mobile transport tests in x86_64 iOS Simulator on Intel Sonoma
if: matrix.expected_arch == 'x86_64'
- name: Run mobile transport tests in the compatibility iOS Simulator
if: matrix.run_mobile_transport_tests
env:
CMUX_EXPECTED_SIMULATOR_ARCH: ${{ matrix.expected_arch }}
run: ./scripts/ci/run-iroh-ios-simulator-package-tests.sh
+125 -3
View File
@@ -124,6 +124,9 @@ jobs:
- name: Install workflow guard Python dependencies
run: python3 -m pip install --disable-pip-version-check --no-input PyYAML==6.0.3 bashlex==0.18
- name: Validate Blacksmith Testbox broker trust boundary
run: python3 tests/test_ci_testbox_broker_guard.py
- name: Validate nightly prune Python compatibility
run: PYTHON_BIN=python3.9 bash ./tests/test_ci_nightly_prune_python_compat.sh
@@ -258,6 +261,9 @@ jobs:
- name: Validate pbxproj test-wiring lint
run: ./tests/test_ci_pbxproj_test_wiring.sh
- name: Initialize Bonsplit for deferred-work ownership guard
run: git submodule update --init --depth 1 vendor/bonsplit
- name: Validate stored DispatchWorkItem ownership
run: |
python3 tests/test_lint_stored_dispatch_work_items.py
@@ -342,6 +348,12 @@ jobs:
- name: Web tests
run: bun run test
- name: Install browser for instant navigation tests
run: bunx playwright install --with-deps chromium
- name: Instant navigation tests
run: bun run test:instant
# Checks for in-app React webviews (currently the diff viewer; more cmux React
# surfaces will live alongside it).
react-apps-check:
@@ -625,6 +637,29 @@ jobs:
echo "::warning::Passwordless sudo unavailable; XCTest will use its default automation-mode setup"
fi
- name: Run bundled command PATH regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# The tolerant full-suite step accepts ordinary Swift Testing failures.
# Keep the shell-resolution integration test non-tolerant so losing
# cmux's bundled commands from PATH cannot pass a shard.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
if ! command -v fish >/dev/null 2>&1; then
HOMEBREW_NO_AUTO_UPDATE=1 brew install fish
fi
command -v fish >/dev/null 2>&1
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/CmuxBundledBinPathIntegrationTests \
test
- name: Run agent chat transcript lifecycle regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
@@ -690,6 +725,27 @@ jobs:
-only-testing:cmuxTests/GhosttySurfaceOverlayTests/testFiveTabRendererFootprintReturnsToOneRendererTargetAcrossHideRevealCycles \
test
- name: Run PTY spawn starvation regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# The tolerant full-suite step can accept ordinary assertion
# failures. Keep the issue #9769 coverage — background-prime work
# must exclude never-startable surfaces, and `cmux send` must
# surface queued delivery — on a non-tolerant focused invocation.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/BackgroundPrimeStartableSurfaceTests \
-only-testing:cmuxTests/CLISendQueuedOutputTests \
test
- name: Run notification routing regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
@@ -922,6 +978,45 @@ jobs:
-only-testing:cmuxTests/NotificationScrollRestoreRecoveryTests \
test
- name: Run sidebar workspace-switch layout regression
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# The tolerant full-suite step can accept Swift Testing assertion
# failures when XCTest reports "0 unexpected". Keep #9612's real
# window-switch workload non-tolerant so NSHostingView layout
# reentry cannot silently return.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/SidebarWorkspaceSwitchLayoutFaultTests \
test
- name: Run terminal portal visibility regressions
if: ${{ matrix.shard == fromJSON(env.CMUX_APP_HOST_FOCUSED_REGRESSION_SHARD) }}
run: |
# These tests mutate the process-wide terminal portal registry and
# include Swift Testing assertions that the tolerant broad shard can
# otherwise classify as expected. Give them a dedicated app host.
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
-project cmux.xcodeproj -scheme cmux-unit -configuration Debug \
-derivedDataPath "$CMUX_DERIVED_DATA_PATH" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-destination "platform=macOS" \
CMUX_SKIP_ZIG_BUILD=1 \
-only-testing:cmuxTests/GhosttyTerminalViewVisibilityPolicyTests \
test
- name: Run unit tests
run: |
set -euo pipefail
@@ -1072,6 +1167,7 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_cli_socket_autodiscovery.py
python3 tests/test_codex_wrapper_resume_hooks.py
python3 tests/test_claude_wrapper_hooks.py
python3 tests/test_hermes_wrapper_hooks.py
python3 tests/test_claude_wrapper_mutual_shim_loop.py
python3 tests/test_claude_wrapper_shim_root_survives_tmpdir_change.py
python3 tests/test_claude_wrapper_user_binary_resolution.py
@@ -1109,7 +1205,9 @@ jobs:
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_dispatch.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_compacted_feed.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_codex_permission_prompt_notification.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_omp_extension_install.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_omp_subagent_lifecycle.py
CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_campfire_extension_install.py
- name: Clean up isolated app-host home
@@ -1305,6 +1403,29 @@ jobs:
run: |
./scripts/install-rust-ci.sh
- name: Run Bonsplit package tests
run: |
set -euo pipefail
# Blacksmith macOS runners intermittently abort a package's test
# runner at startup. Retry exactly once only for the known signal
# 5/6 crash immediately after build and before test output, matching
# the package loop below.
test_status=0
output="$(swift test --package-path vendor/bonsplit 2>&1)" || test_status=$?
printf '%s\n' "$output"
if [ "$test_status" -ne 0 ] \
&& printf '%s\n' "$output" | grep -Fq 'Build complete!' \
&& printf '%s\n' "$output" | grep -Eq 'Exited with unexpected signal code [56]([^0-9]|$)' \
&& ! printf '%s\n' "$output" | grep -Eq '^(Test Suite|Test Case|◇ |↳ |✔ |✘ )'; then
echo "Test runner crashed at startup (runner flake); retrying Bonsplit once."
test_status=0
output="$(swift test --package-path vendor/bonsplit 2>&1)" || test_status=$?
printf '%s\n' "$output"
fi
if [ "$test_status" -ne 0 ]; then
exit "$test_status"
fi
- name: Run Swift package unit tests
run: |
set -euo pipefail
@@ -1391,13 +1512,14 @@ jobs:
# test runner at startup (signal 5/6 immediately after "Build
# complete!", zero test output). That is a runner flake, not a
# test failure: retry exactly once, and only when no test
# failures were reported.
# output was emitted.
test_status=0
output="$(swift test --package-path "$pkgdir" 2>&1)" || test_status=$?
printf '%s\n' "$output"
if [ "$test_status" -ne 0 ] \
&& printf '%s\n' "$output" | grep -q 'Exited with unexpected signal code' \
&& ! printf '%s\n' "$output" | grep -Eq 'with [1-9][0-9]* failures?'; then
&& printf '%s\n' "$output" | grep -Fq 'Build complete!' \
&& printf '%s\n' "$output" | grep -Eq 'Exited with unexpected signal code [56]([^0-9]|$)' \
&& ! printf '%s\n' "$output" | grep -Eq '^(Test Suite|Test Case|◇ |↳ |✔ |✘ )'; then
echo "Test runner crashed at startup (runner flake); retrying $pkg once."
test_status=0
output="$(swift test --package-path "$pkgdir" 2>&1)" || test_status=$?
+21 -1
View File
@@ -140,9 +140,29 @@ jobs:
legacy_assets="assets-legacy"
mkdir -p "$legacy_assets"
for file in assets/cmux-tui/cmux-tui-*; do
if [[ "$(basename "$file")" == cmux-tui-hook-* ]]; then
continue
fi
cp "$file" "$legacy_assets/$(basename "$file" | sed 's/^cmux-tui-/cmux-mux-/')"
done
cp assets/cmux-tui/manifest.json "$legacy_assets/manifest.json"
python3 - "$GITHUB_SHA" "$legacy_assets" <<'PY'
import hashlib
import json
import pathlib
import sys
from datetime import datetime, timezone
root = pathlib.Path(sys.argv[2])
files = sorted(path for path in root.glob("cmux-mux-*") if path.is_file())
manifest = {
"commit": sys.argv[1],
"builtAt": datetime.now(timezone.utc).isoformat(),
"binaries": {
path.name: hashlib.sha256(path.read_bytes()).hexdigest() for path in files
},
}
(root / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
PY
publish_legacy_prefix() {
local prefix="$1"
local cache="$2"
+198 -59
View File
@@ -27,6 +27,36 @@ on:
required: false
default: false
type: boolean
target_set:
description: "Binary targets to build: all or macos-arm64"
required: false
default: all
type: string
build_cloudflare_relay:
description: "Build and verify the Cloudflare relay"
required: false
default: true
type: boolean
verify_linux_arm64:
description: "Run package entrypoint verification on native Linux ARM64"
required: false
default: true
type: boolean
macos_runner:
description: "Optional macOS runner label override"
required: false
default: ""
type: string
linux_runner:
description: "Optional Linux x64 runner label override"
required: false
default: ""
type: string
windows_runner:
description: "Optional Windows runner label override"
required: false
default: ""
type: string
checkout_ref:
description: "Optional git ref to build instead of the caller ref"
required: false
@@ -35,44 +65,114 @@ on:
permissions: {}
env:
RUST_TOOLCHAIN: "1.95.0"
jobs:
plan-build:
name: select binary targets
runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
outputs:
matrix: ${{ steps.targets.outputs.matrix }}
linux_package_matrix: ${{ steps.targets.outputs.linux_package_matrix }}
steps:
- name: Select binary targets
id: targets
env:
TARGET_SET: ${{ inputs.target_set }}
PACKAGE_NPM: ${{ inputs.package_npm }}
PACKAGE_PYPI: ${{ inputs.package_pypi }}
MACOS_RUNNER: ${{ inputs.macos_runner != '' && inputs.macos_runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
LINUX_RUNNER: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
LINUX_ARM64_RUNNER: ${{ vars.LINUX_ARM64_RUNNER || 'ubuntu-24.04-arm' }}
VERIFY_LINUX_ARM64: ${{ inputs.verify_linux_arm64 }}
shell: bash
run: |
python3 <<'PY' >> "$GITHUB_OUTPUT"
import json
import os
targets = [
{
"target": "aarch64-apple-darwin",
"build_target": "aarch64-apple-darwin",
"runner": os.environ["MACOS_RUNNER"],
"cross": False,
"ext": "",
"compatibility_target": "",
},
{
"target": "x86_64-apple-darwin",
"build_target": "x86_64-apple-darwin",
"runner": os.environ["MACOS_RUNNER"],
"cross": True,
"ext": "",
"compatibility_target": "",
},
{
"target": "x86_64-unknown-linux-musl",
"build_target": "x86_64-unknown-linux-musl",
"runner": os.environ["LINUX_RUNNER"],
"cross": True,
"ext": "",
"compatibility_target": "x86_64-unknown-linux-gnu",
},
{
"target": "aarch64-unknown-linux-musl",
"build_target": "aarch64-unknown-linux-musl",
"runner": os.environ["LINUX_RUNNER"],
"cross": True,
"ext": "",
"compatibility_target": "aarch64-unknown-linux-gnu",
},
]
target_set = os.environ["TARGET_SET"]
if target_set == "all":
selected = targets
elif target_set == "macos-arm64":
selected = [
target for target in targets
if target["target"] == "aarch64-apple-darwin"
]
else:
raise SystemExit(f"unsupported target_set: {target_set!r}")
packages_requested = (
os.environ["PACKAGE_NPM"] == "true"
or os.environ["PACKAGE_PYPI"] == "true"
)
if packages_requested and target_set != "all":
raise SystemExit("npm and PyPI packaging require target_set=all")
print("matrix=" + json.dumps({"include": selected}, separators=(",", ":")))
linux_package_targets = [
{
"architecture": "x64",
"runner": os.environ["LINUX_RUNNER"],
}
]
if os.environ["VERIFY_LINUX_ARM64"] == "true":
linux_package_targets.append(
{
"architecture": "arm64",
"runner": os.environ["LINUX_ARM64_RUNNER"],
}
)
print(
"linux_package_matrix="
+ json.dumps({"include": linux_package_targets}, separators=(",", ":"))
)
PY
build:
name: build ${{ matrix.target }}
needs: plan-build
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
build_target: aarch64-apple-darwin
runner: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
cross: false
ext: ""
compatibility_target: ""
- target: x86_64-apple-darwin
build_target: x86_64-apple-darwin
runner: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
cross: true
ext: ""
compatibility_target: ""
- target: x86_64-unknown-linux-musl
build_target: x86_64-unknown-linux-musl
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
cross: true
ext: ""
compatibility_target: x86_64-unknown-linux-gnu
- target: aarch64-unknown-linux-musl
build_target: aarch64-unknown-linux-musl
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
cross: true
ext: ""
compatibility_target: aarch64-unknown-linux-gnu
matrix: ${{ fromJSON(needs.plan-build.outputs.matrix) }}
steps:
- name: Checkout caller ref
if: inputs.checkout_ref == ''
@@ -108,12 +208,8 @@ jobs:
with:
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
rustc --version
- name: Set up pinned Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: Install Rust target
shell: bash
@@ -142,7 +238,7 @@ jobs:
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (Linux cross)
@@ -163,7 +259,7 @@ jobs:
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo zigbuild -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo zigbuild -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target ${{ matrix.build_target }}
cargo zigbuild -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Build cmux-tui (macOS cross)
@@ -184,7 +280,7 @@ jobs:
CMUX_TUI_NPM_BOOTSTRAP_VERSION="$CMUX_TUI_DISTRIBUTION_VERSION"
export CMUX_TUI_NPM_BOOTSTRAP_VERSION
fi
cargo build -p cmux-tui --bin cmux-tui --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target ${{ matrix.build_target }}
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
- name: Stage binary
@@ -192,11 +288,14 @@ jobs:
run: |
mkdir -p dist
binary="dist/cmux-tui-${{ matrix.target }}${{ matrix.ext }}"
hook_binary="dist/cmux-tui-hook-${{ matrix.target }}${{ matrix.ext }}"
relay_binary="dist/cmux-relay-${{ matrix.target }}${{ matrix.ext }}"
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-tui${{ matrix.ext }}" "$binary"
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-tui-hook${{ matrix.ext }}" "$hook_binary"
cp "cmux-tui/target/${{ matrix.target }}/release/cmux-relay${{ matrix.ext }}" "$relay_binary"
if [[ -n "${{ matrix.compatibility_target }}" ]]; then
cp "$binary" "dist/cmux-tui-${{ matrix.compatibility_target }}${{ matrix.ext }}"
cp "$hook_binary" "dist/cmux-tui-hook-${{ matrix.compatibility_target }}${{ matrix.ext }}"
cp "$relay_binary" "dist/cmux-relay-${{ matrix.compatibility_target }}${{ matrix.ext }}"
fi
ls -la dist
@@ -255,7 +354,8 @@ jobs:
cloudflare-relay:
name: Cloudflare Durable Object relay
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
if: inputs.build_cloudflare_relay
runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 30
env:
RUSTUP_TOOLCHAIN: "1.91.0"
@@ -311,8 +411,10 @@ jobs:
build-windows:
name: build x86_64-pc-windows-gnu
if: inputs.include_windows
runs-on: ${{ vars.WINDOWS_RUNNER || 'windows-latest' }}
runs-on: ${{ inputs.windows_runner != '' && inputs.windows_runner || vars.WINDOWS_RUNNER || 'windows-latest' }}
timeout-minutes: 60
env:
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUSTFLAGS: -C link-arg=-fuse-ld=lld
permissions:
contents: read
steps:
@@ -344,12 +446,8 @@ jobs:
with:
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust toolchain
shell: bash
run: |
rustup toolchain install "$RUST_TOOLCHAIN" --profile minimal
rustup default "$RUST_TOOLCHAIN"
rustc --version
- name: Set up pinned Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: Install Rust target
shell: bash
@@ -357,6 +455,10 @@ jobs:
rustup target add x86_64-pc-windows-gnu
printf '%s\n' 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH"
- name: Verify LLVM GNU linker
shell: bash
run: ld.lld --version
- name: Build libghostty-vt + cmux-tui (Windows GNU)
env:
CMUX_TUI_DISTRIBUTION_VERSION: ${{ inputs.version }}
@@ -376,7 +478,32 @@ jobs:
cd ghostty
zig build -Demit-lib-vt=true -Demit-xcframework=false -Doptimize=ReleaseFast -Dtarget=x86_64-windows-gnu --prefix "$RUNNER_TEMP/ghostty-vt-win-gnu"
cd ../cmux-tui
cargo build -p cmux-tui --bin cmux-tui --release --locked --target x86_64-pc-windows-gnu
cargo build -p cmux-tui --bin cmux-tui --bin cmux-tui-hook --release --locked --target x86_64-pc-windows-gnu
- name: Verify long Windows state path
shell: bash
run: |
python cmux-tui/scripts/smoke-windows-long-state-path.py \
--binary cmux-tui/target/x86_64-pc-windows-gnu/release/cmux-tui.exe
- name: Verify Windows terminal process exit
shell: bash
run: |
python cmux-tui/scripts/smoke-windows-process-wait.py \
--binary cmux-tui/target/x86_64-pc-windows-gnu/release/cmux-tui.exe
- name: Set up Python for ConPTY verification
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12.10"
- name: Verify Windows ConPTY resize
shell: bash
run: |
python -m pip install --disable-pip-version-check --require-hashes \
-r cmux-tui/scripts/requirements-windows-conpty.txt
python cmux-tui/scripts/smoke-windows-conpty-resize.py \
--binary cmux-tui/target/x86_64-pc-windows-gnu/release/cmux-tui.exe
- name: Verify remote commands fail clearly on Windows
shell: bash
@@ -394,20 +521,23 @@ jobs:
run: |
mkdir -p dist
cp "cmux-tui/target/x86_64-pc-windows-gnu/release/cmux-tui.exe" "dist/cmux-tui-x86_64-pc-windows-gnu.exe"
cp "cmux-tui/target/x86_64-pc-windows-gnu/release/cmux-tui-hook.exe" "dist/cmux-tui-hook-x86_64-pc-windows-gnu.exe"
ls -la dist
- name: Upload binary artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cmux-tui-x86_64-pc-windows-gnu
path: dist/cmux-tui-x86_64-pc-windows-gnu.exe
path: |
dist/cmux-tui-x86_64-pc-windows-gnu.exe
dist/cmux-tui-hook-x86_64-pc-windows-gnu.exe
if-no-files-found: error
package:
name: package distributions
needs: build
if: inputs.package_npm || inputs.package_pypi
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
runs-on: ${{ inputs.linux_runner != '' && inputs.linux_runner || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 30
permissions:
contents: read
@@ -501,6 +631,9 @@ jobs:
binary = root / name / "bin" / "cmux-tui"
if not binary.stat().st_mode & stat.S_IXUSR:
raise SystemExit(f"{binary} is not executable")
hook = root / name / "bin" / "cmux-tui-hook"
if not hook.stat().st_mode & stat.S_IXUSR:
raise SystemExit(f"{hook} is not executable")
PY
chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-musl
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --version >/tmp/cmux-tui-version.txt 2>&1 || \
@@ -544,6 +677,18 @@ jobs:
for wheel in dist/pypi-wheels/*.whl; do
python3 -m zipfile -l "$wheel" >"/tmp/$(basename "$wheel").list"
done
python3 - <<'PY'
import glob
import stat
import zipfile
for path in glob.glob("dist/pypi-wheels/*.whl"):
with zipfile.ZipFile(path) as wheel:
info = wheel.getinfo("cmux_tui/bin/cmux-tui-hook")
mode = info.external_attr >> 16
if not mode & stat.S_IXUSR:
raise SystemExit(f"{path}: bundled cmux-tui-hook is not executable")
PY
chmod +x dist/binaries/cmux-tui-x86_64-unknown-linux-musl
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --version >/tmp/cmux-tui-version.txt 2>&1 || \
dist/binaries/cmux-tui-x86_64-unknown-linux-musl --help >/tmp/cmux-tui-version.txt 2>&1
@@ -570,7 +715,9 @@ jobs:
verify-linux-packages:
name: verify Linux package entrypoints (${{ matrix.architecture }})
needs: package
needs:
- plan-build
- package
if: ${{ inputs.package_npm || inputs.package_pypi }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
@@ -578,15 +725,7 @@ jobs:
contents: read
strategy:
fail-fast: false
matrix:
include:
- architecture: x64
runner: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# Run ARM64 containers on native hardware. QEMU registration is not
# persistent on every third-party x64 runner and can disappear
# between setup and the package smoke test.
- architecture: arm64
runner: ${{ vars.LINUX_ARM64_RUNNER || 'ubuntu-24.04-arm' }} # github-hosted-required: package smoke tests need native ARM64 execution
matrix: ${{ fromJSON(needs.plan-build.outputs.linux_package_matrix) }}
env:
PYPI_VERSION: ${{ inputs.pypi_version != '' && inputs.pypi_version || inputs.version }}
steps:
+5 -7
View File
@@ -6,6 +6,7 @@ on:
- main
paths:
- "cmux-tui/**"
- ".github/actions/setup-cmux-tui-rust/**"
- ".github/workflows/cmux-tui-nightly.yml"
- ".github/workflows/cmux-tui-release-cut.yml"
- ".github/workflows/cmux-tui-release.yml"
@@ -26,6 +27,7 @@ on:
pull_request:
paths:
- "cmux-tui/**"
- ".github/actions/setup-cmux-tui-rust/**"
- ".github/workflows/cmux-tui-nightly.yml"
- ".github/workflows/cmux-tui-release-cut.yml"
- ".github/workflows/cmux-tui-release.yml"
@@ -486,11 +488,8 @@ jobs:
cache: npm
cache-dependency-path: cmux-tui/bindings/typescript/package-lock.json
- name: Set up Rust 1.95
run: |
rustup toolchain install 1.95.0 --profile minimal
cargo +1.95.0 --version
rustc +1.95.0 --version
- name: Set up pinned cmux-tui Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
@@ -519,7 +518,7 @@ jobs:
working-directory: cmux-tui
run: |
test "$(zig version)" = "0.16.0"
cargo +1.95.0 build -p cmux-tui --bin cmux-tui --locked
cargo build -p cmux-tui --bin cmux-tui --locked
- name: Set up Zig for SDK conformance
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
@@ -532,7 +531,6 @@ jobs:
CXX: clang++
CMUX_ZIG: zig
NODE_OPTIONS: --experimental-websocket
RUSTUP_TOOLCHAIN: 1.95.0
run: |
test "$(zig version)" = "0.15.2"
test "$(node -p 'typeof WebSocket')" = "function"
@@ -0,0 +1,366 @@
name: cmux-tui Rust Testbox setup
# 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
exit 1
}
[[ "$TESTBOX_ID" =~ ^tbx_[A-Za-z0-9_-]+$ ]] || {
echo "::error::malformed Testbox ID" >&2
exit 1
}
- name: Begin Testbox
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043 # v2
with:
testbox_id: ${{ inputs.testbox_id }}
# main can move while a reviewer approves the deployment. Record which
# commit this job actually hydrates and fail closed if the dispatch ref
# changed under it.
- name: Revalidate broker identity after token exposure
env:
DISPATCH_REF: ${{ github.ref }}
DISPATCH_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
[[ "$DISPATCH_REF" == "refs/heads/main" ]] || {
echo "::error::broker ref changed during setup" >&2
exit 1
}
[[ "$DISPATCH_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "::error::dispatch SHA is malformed" >&2
exit 1
}
printf 'hydrating main at %s\n' "$DISPATCH_SHA"
- name: Checkout hydration commit
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
ref: ${{ github.sha }}
- name: Require exact checkout and clean source
env:
EXPECTED_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
actual_sha="$(git rev-parse HEAD)"
[[ "$actual_sha" == "$EXPECTED_SHA" ]] || {
echo "::error::checked out $actual_sha, expected hydration SHA $EXPECTED_SHA" >&2
exit 1
}
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::source checkout is dirty before hydration" >&2
git status --short >&2
exit 1
}
source_tree_sha="$(git rev-parse 'HEAD^{tree}')"
ghostty_entry="$(git ls-tree HEAD ghostty)"
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || {
echo "::error::HEAD:ghostty is not a gitlink" >&2
exit 1
}
ghostty_gitlink_sha="$(git rev-parse 'HEAD:ghostty')"
printf 'source_sha=%s\nsource_tree_sha=%s\nghostty_gitlink_sha=%s\n' \
"$actual_sha" "$source_tree_sha" "$ghostty_gitlink_sha"
- name: Initialize Ghostty source submodule
shell: bash
run: |
set -euo pipefail
git submodule update --init --depth 1 ghostty
[[ "$(git -C ghostty rev-parse --show-toplevel)" == "$GITHUB_WORKSPACE/ghostty" ]] || {
echo "::error::ghostty did not initialize as its own submodule checkout" >&2
exit 1
}
ghostty_entry="$(git ls-tree HEAD ghostty)"
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || {
echo "::error::HEAD:ghostty is not a gitlink" >&2
exit 1
}
expected_ghostty_sha="$(git rev-parse HEAD:ghostty)"
actual_ghostty_sha="$(git -C ghostty rev-parse HEAD)"
[[ "$actual_ghostty_sha" == "$expected_ghostty_sha" ]] || {
echo "::error::Ghostty checkout $actual_ghostty_sha does not match gitlink $expected_ghostty_sha" >&2
exit 1
}
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::Ghostty submodule is dirty after initialization" >&2
git -C ghostty status --short >&2
exit 1
}
test -f ghostty/build.zig.zon
- name: Install Linux build dependencies
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang libclang-dev pkg-config
- name: Cache Zig package downloads
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/zig
key: cmux-tui-zig-${{ hashFiles('ghostty/build.zig.zon', 'ghostty/build.zig.zon.json') }}
restore-keys: |
cmux-tui-zig-
- name: Install repository-pinned Zig
shell: bash
run: ./scripts/install-zig-ci.sh
- name: Fetch Ghostty Zig dependencies without compiling
working-directory: ghostty
shell: bash
run: |
set -euo pipefail
# `--fetch` hydrates the package cache and exits before a build.
"$CMUX_ZIG" build --fetch
- name: Cache Cargo registry and git dependencies
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.cargo/registry
~/.cargo/git
key: cmux-tui-cargo-${{ runner.os }}-${{ hashFiles('cmux-tui/Cargo.lock', 'cmux-tui/rust-toolchain.toml') }}
restore-keys: |
cmux-tui-cargo-${{ runner.os }}-
- name: Set up repository-pinned cmux-tui Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: Fetch Cargo dependencies without compiling
working-directory: cmux-tui
shell: bash
run: |
set -euo pipefail
cargo fetch --locked
- name: Record runner, toolchain, and Ghostty identity
env:
SOURCE_SHA: ${{ github.sha }}
SOURCE_REF: ${{ github.ref }}
TESTBOX_ID: ${{ inputs.testbox_id }}
RUNNER_LABEL: blacksmith-32vcpu-ubuntu-2404
shell: bash
run: |
set -euo pipefail
source_tree_sha="$(git rev-parse 'HEAD^{tree}')"
ghostty_entry="$(git ls-tree HEAD ghostty)"
[[ "$ghostty_entry" =~ ^160000[[:space:]]commit[[:space:]][0-9a-f]{40}[[:space:]]ghostty$ ]] || exit 1
ghostty_gitlink_sha="$(git rev-parse 'HEAD:ghostty')"
ghostty_head_sha="$(git -C ghostty rev-parse HEAD)"
[[ "$SOURCE_SHA" == "$(git rev-parse HEAD)" ]] || exit 1
[[ "$ghostty_gitlink_sha" == "$ghostty_head_sha" ]] || exit 1
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || exit 1
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || exit 1
pushd cmux-tui >/dev/null
cargo metadata --locked --no-deps --format-version 1 > "$RUNNER_TEMP/cmux-tui-cargo-metadata.json"
test -s "$RUNNER_TEMP/cmux-tui-cargo-metadata.json"
RUST_TOOLCHAIN="$(rustup show active-toolchain)"
RUSTC_VERSION="$(rustc --version)"
CARGO_VERSION="$(cargo --version)"
popd >/dev/null
mkdir -p testbox-benchmark
SOURCE_TREE_SHA="$source_tree_sha"
GHOSTTY_GITLINK_SHA="$ghostty_gitlink_sha"
GHOSTTY_HEAD_SHA="$ghostty_head_sha"
ZIG_VERSION="$("$CMUX_ZIG" version)"
ZIG_PATH="$CMUX_ZIG"
RUNNER_UNAME="$(uname -a)"
RUNNER_CPU_COUNT="$(nproc)"
[[ "$RUNNER_ARCH" == "X64" && "$RUNNER_CPU_COUNT" == "32" ]] || {
echo "::error::expected x64 32-vCPU runner, got arch=$RUNNER_ARCH cpu_count=$RUNNER_CPU_COUNT" >&2
exit 1
}
CARGO_METADATA_SHA256="$(sha256sum "$RUNNER_TEMP/cmux-tui-cargo-metadata.json" | cut -d ' ' -f 1)"
RUST_TOOLCHAIN_FILE_SHA256="$(sha256sum cmux-tui/rust-toolchain.toml | cut -d ' ' -f 1)"
CARGO_LOCK_SHA256="$(sha256sum cmux-tui/Cargo.lock | cut -d ' ' -f 1)"
GHOSTTY_ZON_SHA256="$(sha256sum ghostty/build.zig.zon | cut -d ' ' -f 1)"
export SOURCE_TREE_SHA GHOSTTY_GITLINK_SHA GHOSTTY_HEAD_SHA RUST_TOOLCHAIN RUSTC_VERSION CARGO_VERSION ZIG_VERSION ZIG_PATH RUNNER_UNAME RUNNER_CPU_COUNT CARGO_METADATA_SHA256 RUST_TOOLCHAIN_FILE_SHA256 CARGO_LOCK_SHA256 GHOSTTY_ZON_SHA256
python3 - <<'PY' > testbox-benchmark/setup-identity.json
import json
import os
import platform
print(json.dumps({
"schema": 3,
"source": {
"ref": os.environ["SOURCE_REF"],
"commit_sha": os.environ["SOURCE_SHA"],
"tree_sha": os.environ["SOURCE_TREE_SHA"],
"ghostty_gitlink_sha": os.environ["GHOSTTY_GITLINK_SHA"],
"ghostty_head_sha": os.environ["GHOSTTY_HEAD_SHA"],
},
"broker": {
"workflow_ref": os.environ["GITHUB_REF"],
"workflow_sha": os.environ["SOURCE_SHA"],
},
"testbox": {
"id": os.environ["TESTBOX_ID"],
"setup_workflow_run_id": os.environ["GITHUB_RUN_ID"],
},
"runner": {
"label": os.environ["RUNNER_LABEL"],
"name": os.environ.get("RUNNER_NAME"),
"os": os.environ.get("RUNNER_OS"),
"arch": os.environ.get("RUNNER_ARCH"),
"hostname": platform.node(),
"uname": os.environ["RUNNER_UNAME"],
"cpu_count": int(os.environ["RUNNER_CPU_COUNT"]),
},
"toolchain": {
"rust_toolchain": os.environ["RUST_TOOLCHAIN"],
"rustc": os.environ["RUSTC_VERSION"],
"cargo": os.environ["CARGO_VERSION"],
"rust_toolchain_file_sha256": os.environ["RUST_TOOLCHAIN_FILE_SHA256"],
"cargo_lock_sha256": os.environ["CARGO_LOCK_SHA256"],
"cargo_metadata_sha256": os.environ["CARGO_METADATA_SHA256"],
"zig_path": os.environ["ZIG_PATH"],
"zig": os.environ["ZIG_VERSION"],
"ghostty_build_zig_zon_sha256": os.environ["GHOSTTY_ZON_SHA256"],
},
}, sort_keys=True, indent=2))
PY
test -s testbox-benchmark/setup-identity.json
cat testbox-benchmark/setup-identity.json
- name: Require clean hydrated source
shell: bash
run: |
set -euo pipefail
[[ -z "$(git status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::source became dirty during hydration" >&2
git status --short >&2
exit 1
}
[[ -z "$(git -C ghostty status --porcelain=v1 --untracked-files=normal)" ]] || {
echo "::error::Ghostty became dirty during hydration" >&2
git -C ghostty status --short >&2
exit 1
}
- name: Upload setup identity JSON
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: cmux-tui-testbox-setup-${{ github.run_id }}
path: testbox-benchmark/setup-identity.json
if-no-files-found: warn
retention-days: 14
# Missing `/tmp/.testbox` means begin-testbox could not register the VM.
# No auth token or API URL exists, so neither this workflow nor the pinned
# action can report hydration_failed. Fail explicitly; the CLI's bounded
# readiness wait and receipt-aware cleanup remain the only safe recovery.
- name: Require Testbox registration state
if: always()
shell: bash
run: |
set -euo pipefail
if [[ ! -d /tmp/.testbox ]]; then
echo "::error::begin-testbox produced no registration state; hydration failure cannot be reported from this runner" >&2
exit 1
fi
- name: Confirm Testbox registration and mark setup ready
if: success()
env:
EXPECTED_TESTBOX_ID: ${{ inputs.testbox_id }}
shell: bash
run: |
set -euo pipefail
state=/tmp/.testbox
rm -f /tmp/.testbox/cmux-tui-rust-setup-identity.json
for required_file in testbox_id installation_model_id auth_token api_url runner_host runner_ssh_port adopted_run_id ssh_public_key; do
test -s "$state/$required_file"
done
test -s "$state/working_directory"
test "$(<"$state/testbox_id")" = "$EXPECTED_TESTBOX_ID"
installation_model_id="$(<"$state/installation_model_id")"
[[ "$installation_model_id" =~ ^[0-9]+$ ]]
test -s "$state/ssh_public_key"
# begin-testbox deliberately continues after degraded phone-home.
# Validate the SSH handoff and install the identity marker before the
# pinned run-testbox action publishes ready. That action remains the
# sole readiness publisher, so a CLI run cannot race this marker.
marker="$state/cmux-tui-rust-setup-identity.json"
rm -f "$marker"
install -m 600 testbox-benchmark/setup-identity.json "$marker"
test -s "$marker"
# Keepalive is checked into this main-controlled workflow instead of
# importing an upstream composite that performs an unbounded duplicate
# ready curl. It reports
# hydration_failed on any failed setup or readiness path and bounds every
# phone-home request before entering its idle loop.
- name: Run trusted Testbox keepalive
if: always()
env:
JOB_STATUS: ${{ job.status }}
shell: bash
run: ./scripts/blacksmith-testbox-keepalive.sh
+339 -147
View File
@@ -1,24 +1,99 @@
name: cmux-tui
run-name: cmux-tui ${{ inputs.mode }} ${{ inputs.request_id }} @ ${{ inputs.commit }}
on:
# Temporarily manual-only beginning 2026-07-13 to pause automatic CI.
workflow_dispatch:
inputs:
commit:
description: "Exact pushed 40-character commit SHA to verify"
required: true
type: string
mode:
description: "Focused Rust tests or the full cross-platform merge gate"
required: true
type: choice
options:
- focused
- full
test_filter:
description: "Rust test-name substring; required in focused mode"
required: false
default: ""
type: string
request_id:
description: "Unique caller token used to find this run"
required: true
type: string
concurrency:
group: cmux-tui-${{ github.workflow }}-${{ github.ref }}
# Every caller waits for one exact commit. A newer request must not cancel it.
group: cmux-tui-${{ inputs.request_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
validate-inputs:
name: validate exact commit request
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Validate dispatch inputs
env:
EXACT_COMMIT: ${{ inputs.commit }}
WORKFLOW_COMMIT: ${{ github.sha }}
MODE: ${{ inputs.mode }}
TEST_FILTER: ${{ inputs.test_filter }}
REQUEST_ID: ${{ inputs.request_id }}
shell: bash
run: |
if [[ ! "$EXACT_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::commit must be a lowercase 40-character SHA" >&2
exit 1
fi
if [[ "$WORKFLOW_COMMIT" != "$EXACT_COMMIT" ]]; then
echo "::error::workflow revision $WORKFLOW_COMMIT does not match requested commit $EXACT_COMMIT" >&2
exit 1
fi
if [[ ! "$REQUEST_ID" =~ ^[A-Za-z0-9._-]{1,100}$ ]]; then
echo "::error::request_id contains unsupported characters" >&2
exit 1
fi
case "$MODE" in
focused)
if [[ ! "$TEST_FILTER" =~ ^[A-Za-z0-9_][A-Za-z0-9_:.-]{0,199}$ ]]; then
echo "::error::focused mode needs one safe Rust test-name substring" >&2
exit 1
fi
;;
full)
if [[ -n "$TEST_FILTER" ]]; then
echo "::error::full mode does not accept test_filter" >&2
exit 1
fi
;;
*)
echo "::error::unsupported mode: $MODE" >&2
exit 1
;;
esac
web-frontend:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
needs: validate-inputs
if: inputs.mode == 'full'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ inputs.commit }}
- name: Require exact checkout
env:
EXACT_COMMIT: ${{ inputs.commit }}
run: test "$(git rev-parse HEAD)" = "$EXACT_COMMIT"
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -44,19 +119,26 @@ jobs:
valgrind-leak-check-shard:
name: valgrind-leak-check (${{ matrix.shard }})
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# The core, remote, and application suites are intentionally serialized
# under instrumentation. Isolate them so none can consume another test
# binary group's runtime budget.
timeout-minutes: 60
needs: validate-inputs
if: inputs.mode == 'full'
runs-on: blacksmith-4vcpu-ubuntu-2404
# Full behavior stays in the normal macOS and Linux suites. Valgrind owns
# the bounded startup parsers and terminal replay state only.
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
shard: [core, remote, tui, remainder]
shard: [startup]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ inputs.commit }}
- name: Require exact checkout
env:
EXACT_COMMIT: ${{ inputs.commit }}
run: test "$(git rev-parse HEAD)" = "$EXACT_COMMIT"
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
@@ -69,13 +151,8 @@ jobs:
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Rust version
run: |
rustc --version || true
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- name: Set up pinned Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: Build test binaries
working-directory: cmux-tui
@@ -95,19 +172,15 @@ jobs:
import sys
shard = os.environ["VALGRIND_SHARD"]
known_shards = {"core", "remote", "tui", "remainder"}
known_shards = {"startup"}
if shard not in known_shards:
raise SystemExit(f"unknown Valgrind shard: {shard}")
def shard_for(executable):
name = os.path.basename(executable)
if re.fullmatch(r"cmux_tui_core-[0-9a-f]+", name):
return "core"
if re.fullmatch(r"cmux_remote-[0-9a-f]+", name):
return "remote"
if re.fullmatch(r"cmux_tui-[0-9a-f]+", name):
return "tui"
return "remainder"
if re.fullmatch(r"(?:cmux_tui|terminal)-[0-9a-f]+", name):
return "startup"
return None
seen = set()
selected = []
@@ -141,19 +214,34 @@ jobs:
)
PY
- name: Verify baseline terminal replay behavior
if: matrix.shard == 'startup'
working-directory: cmux-tui
run: |
matches=0
while IFS= read -r bin; do
[ -n "$bin" ] || continue
case "$(basename "$bin")" in
terminal-*)
if ! "$bin" --list \
| grep -Fqx 'pending_wrap_replay_preserves_cursor_with_origin_mode: test'; then
echo "::error::terminal replay test binary does not contain the exact baseline test" >&2
exit 1
fi
"$bin" pending_wrap_replay_preserves_cursor_with_origin_mode \
--exact \
--test-threads=1
matches=$((matches + 1))
;;
esac
done < target/valgrind-test-binaries.txt
if [[ "$matches" -ne 1 ]]; then
echo "::error::expected one terminal integration test binary, found $matches" >&2
exit 1
fi
- name: Run test binaries under valgrind
working-directory: cmux-tui
env:
# Valgrind's ~30x slowdown breaks the 50ms ws-latency budget on an
# otherwise-correct build; the guarded regression (events serialized
# behind a 100ms read poll) inflates far past this bound anyway.
CMUX_TEST_WS_LATENCY_BUDGET_MS: "2000"
# Scale test-fixture deadlines and their observation windows together
# under instrumentation. Production defaults and normal CI remain strict.
CMUX_TEST_TIMEOUT_SCALE: "4"
# Retain the full 128 MiB fairness workload under Valgrind with
# explicit instrumentation-only bounds. Normal CI stays strict.
CMUX_TEST_PERF_INSTRUMENTED: "valgrind"
run: |
run_valgrind() {
local bin="$1"
@@ -167,73 +255,57 @@ jobs:
-- "$bin" "$@"
}
while IFS= read -r bin; do
[ -n "$bin" ] || continue
echo "Running valgrind for $bin"
valgrind_args=(--track-origins=yes)
test_args=()
case "$(basename "$bin")" in
pty-*|cmux_tui_core-*|cmux_tui-[[:xdigit:]]*)
# These tests own bounded worker pools, PTYs, sockets, and
# deadline-sensitive readers. Valgrind serializes their CPU
# work internally, so test-harness parallelism only creates
# scheduler starvation and wall-clock timeout races.
test_args+=(--test-threads=1)
;;
cmux_remote-[[:xdigit:]]*)
# Remote-runtime tests also own real schedulers, sockets, and
# deadline checks. Serial execution prevents the instrumented
# harness from starving its own observation deadlines.
test_args+=(--test-threads=1)
;;
terminal_host_recovery-*)
# Valgrind instruments this client harness but not the hidden
# terminal-host child it launches. The normal-speed child can
# fill the socket while the instrumented reader is descheduled,
# correctly triggering the production stalled-client timeout
# mid-frame. Normal Linux and macOS CI retain this ordering test;
# every other recovery case remains under Valgrind.
test_args+=(--skip exit_follows_all_final_pty_bytes_on_the_live_stream)
;;
direct_wss_e2e-*|relay_wss_diagnostic-*)
# ring's AES-GCM backend exposes initialized output through a
# partially initialized SIMD buffer. Valgrind reports its
# padding at Rustls writev. Keep leak and address checks for
# TLS integration binaries while scoping undefined-value
# suppression to those binaries.
valgrind_args=(--undef-value-errors=no)
;;
esac
if [[ "$(basename "$bin")" == cmux_remote-[[:xdigit:]]* ]]; then
# Iroh's Rustls/ring and noq UDP paths expose initialized data
# through buffers with uninitialized SIMD or sockaddr padding.
# Run only those tests without undefined-value diagnostics while
# retaining address and leak checks. Every other remote test
# keeps the complete Valgrind diagnostic set.
if ! run_valgrind "$bin" --skip 'provider::iroh::' "${test_args[@]}"; then
echo "Valgrind failed for $bin outside the Iroh provider" >&2
exit 1
fi
valgrind_args=(--undef-value-errors=no)
if ! run_valgrind "$bin" 'provider::iroh::' "${test_args[@]}"; then
echo "Valgrind failed for $bin in the Iroh provider" >&2
exit 1
fi
continue
fi
if ! run_valgrind "$bin" "${test_args[@]}"; then
echo "Valgrind failed for $bin" >&2
require_exact_test() {
local bin="$1"
local test_name="$2"
if ! "$bin" --list | grep -Fqx "$test_name: test"; then
echo "::error::$(basename "$bin") does not contain exact test '$test_name'" >&2
exit 1
fi
}
cmux_tui_bin=""
terminal_bin=""
while IFS= read -r bin; do
case "$(basename "$bin")" in
cmux_tui-*) cmux_tui_bin="$bin" ;;
terminal-*) terminal_bin="$bin" ;;
esac
done < target/valgrind-test-binaries.txt
if [[ -z "$cmux_tui_bin" || -z "$terminal_bin" ]]; then
echo "::error::startup memory check did not find both test binaries" >&2
exit 1
fi
valgrind_args=(--track-origins=yes)
require_exact_test \
"$terminal_bin" \
pending_wrap_replay_preserves_cursor_with_origin_mode
run_valgrind \
"$terminal_bin" \
pending_wrap_replay_preserves_cursor_with_origin_mode \
--exact \
--test-threads=1
startup_tests=(
config::tests::load_uses_file_ghostty_defaults_without_invoking_external_resolver
config::tests::ghostty_file_reader_enforces_byte_limit_during_read
config::tests::ghostty_config_helper_output_reader_enforces_byte_limit
)
for test_name in "${startup_tests[@]}"; do
require_exact_test "$cmux_tui_bin" "$test_name"
run_valgrind \
"$cmux_tui_bin" \
"$test_name" \
--exact \
--test-threads=1
done
valgrind-leak-check:
name: valgrind-leak-check
if: always()
if: always() && inputs.mode == 'full'
needs: valgrind-leak-check-shard
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 2
steps:
- name: Require every Valgrind shard
@@ -243,14 +315,27 @@ jobs:
test:
name: test (${{ matrix.os }})
runs-on: ${{ matrix.os == 'macos' && (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || 'ubuntu-latest' }}
needs: validate-inputs
runs-on: ${{ matrix.runner }}
timeout-minutes: 40
strategy:
fail-fast: false
matrix:
os: [macos, linux]
include:
- os: macos
runner: blacksmith-6vcpu-macos-15
- os: linux
runner: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ inputs.commit }}
- name: Require exact checkout
env:
EXACT_COMMIT: ${{ inputs.commit }}
run: test "$(git rev-parse HEAD)" = "$EXACT_COMMIT"
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
@@ -264,47 +349,133 @@ jobs:
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Rust version
- name: Ghostty VT cursor replay checks
if: inputs.mode == 'full'
working-directory: ghostty
run: |
rustc --version || true
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
"$CMUX_ZIG" build test-lib-vt -Dtest-filter="Terminal vt"
- name: Set up pinned Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: cargo fmt
id: rustfmt-check
working-directory: cmux-tui
run: cargo fmt --check
- name: Prepare hosted rustfmt patch
if: ${{ failure() && steps.rustfmt-check.outcome == 'failure' }}
working-directory: cmux-tui
run: |
cargo fmt
git diff --binary -- . > "$RUNNER_TEMP/rustfmt.patch"
test -s "$RUNNER_TEMP/rustfmt.patch"
- name: Upload hosted rustfmt patch
if: ${{ failure() && steps.rustfmt-check.outcome == 'failure' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfmt-${{ runner.os }}-${{ github.sha }}
path: ${{ runner.temp }}/rustfmt.patch
if-no-files-found: error
retention-days: 1
- name: cargo clippy
if: inputs.mode == 'full'
working-directory: cmux-tui
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: focused Linux journal process-fence test
if: runner.os == 'Linux'
working-directory: cmux-tui
run: >-
cargo test -p cmux-tui-core --lib
journal_hooks::tests::unix_hook_tree_kills_a_descendant_that_created_a_new_session
-- --exact
- name: focused macOS journal process-fence tests
if: runner.os == 'macOS'
working-directory: cmux-tui
run: cargo test -p cmux-tui-core --lib unix_process_scope::tests::mac_process_scope_
- name: focused journal writer shutdown test
working-directory: cmux-tui
run: >-
cargo test -p cmux-tui-core --lib
journal_ingress::tests::shutdown_returns_when_an_admitted_commit_stays_blocked
-- --exact
- name: focused final journal ownership tests
working-directory: cmux-tui
run: |
cargo test -p cmux-tui-core --lib \
journal_ingress::tests::shutdown_closes_admission_while_a_terminal_producer_waits_for_space \
-- --exact
cargo test -p cmux-tui-core --lib \
journal_checkpoint::tests::replaying_an_old_producer_put_keeps_the_current_validator \
-- --exact
cargo test -p cmux-tui-core --test browser_runtime \
socket_browser_attach_streams_frames_input_and_cell_pixels \
-- --exact
- name: cargo test
working-directory: cmux-tui
run: cargo test --workspace --locked
env:
MODE: ${{ inputs.mode }}
TEST_FILTER: ${{ inputs.test_filter }}
shell: bash
run: |
if [[ "$MODE" == "full" ]]; then
cargo test --workspace --exclude cmux-tui-core --locked
mkdir -p target
cargo test -p cmux-tui-core --locked --no-run --message-format=json \
> target/cmux-tui-core-test-binaries.jsonl
python3 ../.github/scripts/run-cmux-tui-core-tests-isolated.py \
target/cmux-tui-core-test-binaries.jsonl \
crates/cmux-tui-core
cargo test -p cmux-tui-core --doc --locked
exit 0
fi
cargo test --workspace --locked "$TEST_FILTER" -- --list \
| tee "$RUNNER_TEMP/cmux-tui-focused-tests.txt"
if ! grep -Eq ': test$' "$RUNNER_TEMP/cmux-tui-focused-tests.txt"; then
echo "::error::test_filter '$TEST_FILTER' selected no Rust tests" >&2
exit 1
fi
cargo test --workspace --locked "$TEST_FILTER"
- name: crossterm parser tests
if: inputs.mode == 'full'
working-directory: cmux-tui
run: cargo test --manifest-path vendor/crossterm/Cargo.toml --lib
- name: TUI smoke test (scripted pty)
if: inputs.mode == 'full'
working-directory: cmux-tui
run: |
cargo build -p cmux-tui
python3 scripts/smoke-tui.py
- name: Detach/reattach smoke test
if: inputs.mode == 'full'
working-directory: cmux-tui
run: python3 scripts/smoke-attach.py
bindings-e2e:
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
needs: validate-inputs
if: inputs.mode == 'full'
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 40
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
ref: ${{ inputs.commit }}
- name: Require exact checkout
env:
EXACT_COMMIT: ${{ inputs.commit }}
run: test "$(git rev-parse HEAD)" = "$EXACT_COMMIT"
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -328,13 +499,8 @@ jobs:
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Rust version
run: |
rustc --version || true
if ! command -v cargo >/dev/null; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
fi
- name: Set up pinned Rust
uses: ./.github/actions/setup-cmux-tui-rust
- name: Java version
run: |
@@ -368,6 +534,10 @@ jobs:
ZIG_FORCE_LOCAL_INSTALL: "1"
run: ./scripts/install-zig-ci.sh
- name: Install TypeScript adapter build tools
working-directory: cmux-tui/bindings/typescript
run: npm ci --no-audit --no-fund
- name: Python conformance fixtures
run: |
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
@@ -378,42 +548,64 @@ jobs:
test "$("$CMUX_ZIG" version)" = "${{ steps.zig-sdk-version.outputs.version }}"
bash cmux-tui/bindings/conformance/e2e.sh --require python,typescript,rust,go,java
windows-experimental:
name: windows experimental (x86_64-gnu)
runs-on: windows-latest
timeout-minutes: 40
continue-on-error: true
build-artifacts:
name: release-path dogfood artifacts
needs: validate-inputs
permissions:
contents: read
uses: ./.github/workflows/cmux-tui-build-package.yml
with:
version: 0.0.0-nightly.19700101.0
pypi_version: 0.0.0.dev197001010
package_npm: ${{ inputs.mode == 'full' }}
package_pypi: ${{ inputs.mode == 'full' }}
include_windows: ${{ inputs.mode == 'full' }}
target_set: ${{ inputs.mode == 'full' && 'all' || 'macos-arm64' }}
build_cloudflare_relay: false
verify_linux_arm64: ${{ inputs.mode == 'full' }}
macos_runner: blacksmith-6vcpu-macos-15
linux_runner: blacksmith-4vcpu-ubuntu-2404
windows_runner: windows-latest
checkout_ref: ${{ inputs.commit }}
hosted-verification:
name: ${{ inputs.mode == 'full' && 'hosted verification' || 'focused hosted verification' }}
if: always()
needs:
- validate-inputs
- web-frontend
- valgrind-leak-check
- test
- bindings-e2e
- build-artifacts
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
env:
MODE: ${{ inputs.mode }}
VALIDATE_RESULT: ${{ needs.validate-inputs.result }}
WEB_RESULT: ${{ needs.web-frontend.result }}
VALGRIND_RESULT: ${{ needs.valgrind-leak-check.result }}
TEST_RESULT: ${{ needs.test.result }}
BINDINGS_RESULT: ${{ needs.bindings-e2e.result }}
ARTIFACT_RESULT: ${{ needs.build-artifacts.result }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Init ghostty submodule
run: git submodule update --init --depth 1 ghostty
- name: Resolve Ghostty Zig version
id: ghostty-zig-version
- name: Require selected verification jobs
shell: bash
run: |
version="$(bash ./scripts/ghostty-zig-version.sh)"
echo "version=$version" >> "$GITHUB_OUTPUT"
require_success() {
local name="$1"
local result="$2"
if [[ "$result" != "success" ]]; then
echo "::error::$name finished with result '$result'" >&2
exit 1
fi
}
- name: Install zig
uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5
with:
version: ${{ steps.ghostty-zig-version.outputs.version }}
- name: Install Rust GNU target
shell: bash
run: |
rustup target add x86_64-pc-windows-gnu
printf '%s\n' 'C:\msys64\mingw64\bin' >> "$GITHUB_PATH"
- name: Build libghostty-vt for Windows GNU
shell: bash
working-directory: ghostty
run: |
zig build -Demit-lib-vt=true -Demit-xcframework=false -Doptimize=ReleaseFast -Dtarget=x86_64-windows-gnu --prefix "$RUNNER_TEMP/ghostty-vt-win-gnu"
zig ar t "$RUNNER_TEMP/ghostty-vt-win-gnu/lib/ghostty-vt-static.lib" | head
- name: cargo build cmux-tui for Windows GNU
working-directory: cmux-tui
run: cargo build -p cmux-tui --target x86_64-pc-windows-gnu --locked
require_success "input validation" "$VALIDATE_RESULT"
require_success "Rust tests" "$TEST_RESULT"
require_success "dogfood artifact build" "$ARTIFACT_RESULT"
if [[ "$MODE" == "full" ]]; then
require_success "web frontend" "$WEB_RESULT"
require_success "Valgrind" "$VALGRIND_RESULT"
require_success "binding end-to-end tests" "$BINDINGS_RESULT"
fi
+14 -2
View File
@@ -55,9 +55,19 @@ jobs:
run: ./scripts/ci/run-iroh-tailscale-compatibility-gate.sh
simulator-e2e:
needs: tailscale-version-skew
env:
# The app gates compile optimized Swift for both endpoints. Xcode 26's
# AArch64 GlobalISel path is not reliable for that exact build shape, so
# use the same supported workaround as tagged cloud reloads and streamed
# validation instead of allowing a compiler failure to masquerade as a
# transport verdict.
CMUX_SWIFT_FRONTEND_WORKAROUND: "1"
strategy:
fail-fast: false
# The three app-backed modes share one staging account. Serial execution
# prevents one mode's zero-touch registration from replacing another
# mode's active Mac route while its authenticated RPC probe is running.
max-parallel: 1
matrix:
mode: ${{ fromJSON(inputs.mode == 'all' && '["automatic","relay-only","relay-expiry","direct-only","private-path"]' || format('["{0}"]', inputs.mode)) }}
runs-on: ${{ vars.MACOS_RUNNER_STREAMED_VALIDATION || 'warp-macos-15-arm64-6x' }}
@@ -120,6 +130,8 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: iroh-release-gate-${{ matrix.mode }}
path: ${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}.json
path: |
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}.json
${{ runner.temp }}/iroh-release-gate-${{ matrix.mode }}-mac.cmuxdiag
if-no-files-found: warn
retention-days: 7
+638 -28
View File
@@ -63,6 +63,9 @@ jobs:
env:
# The ghostty CLI helper zig build is skipped; GhosttyKit comes prebuilt.
CMUX_SKIP_ZIG_BUILD: "1"
# A degraded cache must lose to a cold build instead of stalling this
# latency-sensitive lane for actions/cache's ten-minute default.
SEGMENT_DOWNLOAD_TIMEOUT_MINS: "2"
SWIFT_BACKTRACE: "interactive=no,timeout=0s,symbolicate=off,color=no"
steps:
- name: Checkout source ref
@@ -80,12 +83,333 @@ jobs:
set -euo pipefail
./scripts/select-ci-xcode.sh
- name: Install zig
run: ./scripts/install-zig-ci.sh
- name: Provision GhosttyKit (macOS)
- name: Prepare macOS cache metadata
if: ${{ inputs.platform == 'macos' }}
run: ./scripts/download-prebuilt-ghosttykit.sh || ./scripts/ensure-ghosttykit.sh
id: cache_meta
env:
SOURCE_REF: ${{ inputs.ref || github.ref_name }}
run: |
set -euo pipefail
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
xcode_build="$(xcodebuild -version | awk 'NR == 2 { print $3 }')"
xcode_key="$(printf 'xcode-%s-%s' "$xcode_version" "$xcode_build" | tr -c 'A-Za-z0-9._-' '-')"
branch_name="${SOURCE_REF#refs/heads/}"
branch_name="${branch_name#refs/tags/}"
if [ -z "$branch_name" ]; then
branch_name="$(git branch --show-current)"
fi
if [ -z "$branch_name" ]; then
branch_name="detached"
fi
branch_slug="$(printf '%s' "$branch_name" | tr -c 'A-Za-z0-9._-' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//')"
branch_digest="$(printf '%s' "$branch_name" | shasum -a 256 | awk '{ print substr($1, 1, 12) }')"
if [ "$branch_name" = "main" ]; then
branch_key="main"
else
branch_key="${branch_slug:0:48}-${branch_digest}"
fi
# Xcode's SourcePackages workspace state and DerivedData build
# database contain absolute checkout paths. Keep exact-state caches
# tied to the path contract; a different runner workspace must cold
# resolve/build instead of consuming stale absolute paths.
workspace_digest="$(printf '%s' "$GITHUB_WORKSPACE" | shasum -a 256 | awk '{ print substr($1, 1, 12) }')"
workspace_key="workspace-${workspace_digest}"
runner_os="$(printf '%s' "$(uname -s)" | tr -c 'A-Za-z0-9._-' '-')"
runner_os_version="$(sw_vers -productVersion 2>/dev/null || uname -r)"
runner_os_version="$(printf '%s' "$runner_os_version" | tr -c 'A-Za-z0-9._-' '-')"
runner_arch="$(printf '%s' "$(uname -m)" | tr -c 'A-Za-z0-9._-' '-')"
runner_key="${runner_os}-${runner_os_version}-${runner_arch}"
resolved_file="cmux.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved"
source_sha="$(git rev-parse HEAD)"
# Cache entries are immutable. A run-scoped save key plus a stable
# source prefix lets a cold retry publish a newer repaired entry.
cache_run_key="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
spm_prefix="reload-build-macos-spm-v2-${xcode_key}-${workspace_key}-"
# DerivedData contains ONLY_ACTIVE_ARCH products and object files;
# never restore those across OS/architecture changes.
derived_prefix="reload-build-macos-deriveddata-v3-${xcode_key}-${runner_key}-${workspace_key}-"
if [ -f "$resolved_file" ]; then
resolved_hash="$(shasum -a 256 "$resolved_file" | awk '{ print $1 }')"
spm_cache_enabled=true
spm_key_base="${spm_prefix}${resolved_hash}"
spm_key="${spm_key_base}-run-${cache_run_key}"
spm_source_prefix="${spm_key_base}-run-"
else
echo "::warning::$resolved_file absent in source ref; disabling the SPM cache"
spm_cache_enabled=false
spm_key_base=""
spm_key=""
spm_source_prefix=""
fi
derived_data_key_base="${derived_prefix}${branch_key}-${source_sha}"
{
echo "xcode_key=$xcode_key"
echo "branch_key=$branch_key"
echo "workspace_key=$workspace_key"
echo "runner_key=$runner_key"
echo "source_sha=$source_sha"
echo "spm_cache_enabled=$spm_cache_enabled"
echo "spm_prefix=$spm_prefix"
echo "spm_key_base=$spm_key_base"
echo "spm_key=$spm_key"
echo "spm_source_prefix=$spm_source_prefix"
echo "derived_data_key_base=$derived_data_key_base"
echo "derived_data_key=${derived_data_key_base}-run-${cache_run_key}"
echo "derived_data_source_prefix=${derived_data_key_base}-run-"
echo "derived_data_branch_prefix=${derived_prefix}${branch_key}-"
echo "derived_data_main_prefix=${derived_prefix}main-"
} >> "$GITHUB_OUTPUT"
# The verified prebuilt is the happy path for both platforms. Zig is
# installed only after this attempt fails, immediately before the local
# ensure-ghosttykit.sh fallback that needs it.
- name: Download prebuilt GhosttyKit
id: ghosttykit_prebuilt
run: |
set -euo pipefail
start=$(date +%s)
# Keep a transient release outage from consuming the whole 60-minute
# job before the Zig-backed fallback gets a chance to run. The script
# still owns checksum/archive validation and preserves its normal
# defaults for local and fleet callers.
if GHOSTTYKIT_DOWNLOAD_RETRIES=2 \
GHOSTTYKIT_DOWNLOAD_RETRY_DELAY=5 \
GHOSTTYKIT_DOWNLOAD_MAX_TIME=60 \
./scripts/download-prebuilt-ghosttykit.sh; then
available=true
else
available=false
echo "::warning::Prebuilt GhosttyKit download failed; enabling the Zig-backed fallback"
fi
end=$(date +%s)
echo "available=$available" >> "$GITHUB_OUTPUT"
echo "seconds=$(( end - start ))" >> "$GITHUB_OUTPUT"
- name: Install zig for GhosttyKit fallback
if: ${{ steps.ghosttykit_prebuilt.outputs.available != 'true' }}
id: zig_fallback
run: |
set -euo pipefail
start=$(date +%s)
./scripts/install-zig-ci.sh
end=$(date +%s)
seconds="$(awk -v start="$start" -v end="$end" 'BEGIN { print end - start }')"
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
- name: Build fallback GhosttyKit
if: ${{ steps.ghosttykit_prebuilt.outputs.available != 'true' }}
id: ghosttykit_fallback
env:
# The bounded download step already proved the prebuilt path failed;
# do not let ensure-ghosttykit retry that URL with its long defaults.
CMUX_GHOSTTYKIT_NO_PREBUILT: "1"
run: |
set -euo pipefail
start=$(date +%s)
./scripts/ensure-ghosttykit.sh
end=$(date +%s)
echo "seconds=$(( end - start ))" >> "$GITHUB_OUTPUT"
- name: Verify provisioned GhosttyKit
run: test -d GhosttyKit.xcframework
- name: Start SPM cache restore timer
if: ${{ inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' }}
id: spm_restore_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Restore SPM SourcePackages cache
if: ${{ inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' }}
id: restore_spm
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .ci-source-packages
key: ${{ steps.cache_meta.outputs.spm_key }}
restore-keys: ${{ steps.cache_meta.outputs.spm_source_prefix }}
- name: Finish SPM cache restore
if: ${{ always() && inputs.platform == 'macos' }}
id: spm_restore
env:
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
CACHE_SOURCE_PREFIX: ${{ steps.cache_meta.outputs.spm_source_prefix }}
START_EPOCH: ${{ steps.spm_restore_start.outputs.epoch }}
CACHE_OUTCOME: ${{ steps.restore_spm.outcome }}
CACHE_HIT: ${{ steps.restore_spm.outputs.cache-hit }}
CACHE_MATCHED_KEY: ${{ steps.restore_spm.outputs.cache-matched-key }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if ! [[ "$start" =~ ^[0-9]+$ ]] || [ "$start" -eq 0 ]; then
start=$now
fi
cache_hit=${CACHE_HIT:-false}
matched_key=${CACHE_MATCHED_KEY:-}
exact_hit=false
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
status=disabled_missing_resolved
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
elif [ "${CACHE_OUTCOME:-failure}" != "success" ]; then
status=restore_error
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
elif [ "$cache_hit" = "true" ] || { [ -n "${CACHE_SOURCE_PREFIX:-}" ] && [[ "$matched_key" == "$CACHE_SOURCE_PREFIX"* ]]; }; then
status=exact_hit
exact_hit=true
elif [ -n "$matched_key" ]; then
status=fallback_hit
else
status=miss
fi
mkdir -p "$GITHUB_WORKSPACE/.ci-source-packages"
{
echo "seconds=$(( now - start ))"
echo "status=$status"
echo "exact_hit=$exact_hit"
echo "matched_key=$matched_key"
} >> "$GITHUB_OUTPUT"
- name: Sanitize restored SPM cache
if: ${{ inputs.platform == 'macos' }}
id: spm_sanitize
env:
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
CACHE_STATUS: ${{ steps.spm_restore.outputs.status }}
run: |
set -euo pipefail
status=${CACHE_STATUS:-restore_error}
discarded=false
sanitizer=scripts/ci/sanitize-xcode-source-packages-cache.py
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
status=disabled_missing_resolved
elif [ "$status" = "exact_hit" ]; then
# The exact key is tied to Package.resolved, Xcode, source SHA, and
# the fixed Blacksmith checkout path. Preserve its workspace state
# so Xcode can reuse the resolved package graph; a bad state still
# trips the guarded cold retry below.
echo "preserving exact-hit SourcePackages workspace state"
elif [ ! -f "$sanitizer" ]; then
echo "::warning::$sanitizer absent in source ref; discarding the restored SPM cache"
status=sanitize_error
discarded=true
elif ! python3 "$sanitizer" .ci-source-packages; then
echo "::warning::SPM cache sanitize failed; discarding it and building cold"
status=sanitize_error
discarded=true
fi
if [ "$discarded" = "true" ]; then
rm -rf "$GITHUB_WORKSPACE/.ci-source-packages"
mkdir -p "$GITHUB_WORKSPACE/.ci-source-packages"
fi
echo "status=$status" >> "$GITHUB_OUTPUT"
echo "discarded=$discarded" >> "$GITHUB_OUTPUT"
- name: Start DerivedData cache restore timer
if: ${{ inputs.platform == 'macos' }}
id: derived_data_restore_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Restore DerivedData cache
if: ${{ inputs.platform == 'macos' }}
id: restore_derived_data
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
# Keep the build database, compiler outputs, and Debug products that
# Xcode needs for a true incremental build. Archives, indexes, logs,
# SourcePackages, and every non-Debug product remain excluded.
path: |
.ci-reload-derived-data/Build/Intermediates.noindex
.ci-reload-derived-data/Build/Products/Debug
.ci-reload-derived-data/ModuleCache.noindex
.ci-reload-derived-data/SDKStatCaches.noindex
key: ${{ steps.cache_meta.outputs.derived_data_key }}
restore-keys: |
${{ steps.cache_meta.outputs.derived_data_source_prefix }}
${{ steps.cache_meta.outputs.derived_data_branch_prefix }}
${{ steps.cache_meta.outputs.derived_data_main_prefix }}
- name: Finish DerivedData cache restore
if: ${{ always() && inputs.platform == 'macos' }}
id: derived_data_restore
env:
CACHE_SOURCE_PREFIX: ${{ steps.cache_meta.outputs.derived_data_source_prefix }}
START_EPOCH: ${{ steps.derived_data_restore_start.outputs.epoch }}
CACHE_OUTCOME: ${{ steps.restore_derived_data.outcome }}
CACHE_HIT: ${{ steps.restore_derived_data.outputs.cache-hit }}
CACHE_MATCHED_KEY: ${{ steps.restore_derived_data.outputs.cache-matched-key }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if ! [[ "$start" =~ ^[0-9]+$ ]] || [ "$start" -eq 0 ]; then
start=$now
fi
cache_hit=${CACHE_HIT:-false}
matched_key=${CACHE_MATCHED_KEY:-}
exact_hit=false
if [ "${CACHE_OUTCOME:-failure}" != "success" ]; then
status=restore_error
rm -rf "$GITHUB_WORKSPACE/.ci-reload-derived-data"
elif [ "$cache_hit" = "true" ] || { [ -n "${CACHE_SOURCE_PREFIX:-}" ] && [[ "$matched_key" == "$CACHE_SOURCE_PREFIX"* ]]; }; then
status=exact_hit
exact_hit=true
elif [ -n "$matched_key" ]; then
status=fallback_hit
else
status=miss
fi
mkdir -p "$GITHUB_WORKSPACE/.ci-reload-derived-data"
{
echo "seconds=$(( now - start ))"
echo "status=$status"
echo "exact_hit=$exact_hit"
echo "matched_key=$matched_key"
} >> "$GITHUB_OUTPUT"
# actions/checkout gives every tracked source a fresh mtime, newer than
# restored compiler outputs. Xcode then recompiles an exact cache hit as
# if it were cold. Derive a stable, pre-2020 mtime from each Git blob id:
# unchanged files stay unchanged across runs, while changed blobs receive
# a different file signature and are still invalidated correctly.
- name: Normalize tracked source mtimes for Xcode
if: ${{ inputs.platform == 'macos' }}
run: |
set -euo pipefail
python3 - <<'PY'
import os
import subprocess
# 2001-01-01 plus at most fifteen years keeps source mtimes safely
# behind newly generated outputs while preserving nanosecond entropy.
base_seconds = 978_307_200
span_seconds = 15 * 365 * 24 * 60 * 60
records = subprocess.check_output(
["git", "ls-files", "--recurse-submodules", "--stage", "-z"]
).split(b"\0")
normalized = 0
for record in records:
if not record:
continue
metadata, raw_path = record.split(b"\t", 1)
mode, object_id, stage = metadata.split()
if mode == b"160000" or stage != b"0":
continue
path = os.fsdecode(raw_path)
if not os.path.lexists(path):
continue
digest = object_id.decode("ascii")
seconds = base_seconds + int(digest[:12], 16) % span_seconds
nanoseconds = int(digest[12:20], 16) % 1_000_000_000
mtime_ns = seconds * 1_000_000_000 + nanoseconds
os.utime(path, ns=(mtime_ns, mtime_ns), follow_symlinks=False)
normalized += 1
print(f"normalized {normalized} Git-tracked source mtimes")
PY
- name: Mark deps-ready
id: t1
@@ -95,12 +419,64 @@ jobs:
- name: Build tagged macOS app
if: ${{ inputs.platform == 'macos' }}
id: build_macos
env:
BUILD_TAG: ${{ inputs.tag }}
CMUX_GHOSTTYKIT_PREPROVISIONED: "1"
CMUX_SOURCE_PACKAGES_DIR: ${{ github.workspace }}/.ci-source-packages
SPM_CACHE_STATUS: ${{ steps.spm_sanitize.outputs.status }}
DERIVED_DATA_CACHE_STATUS: ${{ steps.derived_data_restore.outputs.status }}
run: |
set -euo pipefail
# Per-run log path: /tmp/reload.log collides across tenants on the
# multi-tenant self-hosted fleet Macs (tee: Permission denied).
log="$RUNNER_TEMP/reload.log"
./scripts/reload.sh --tag "${{ inputs.tag }}" --swift-frontend-workaround 2>&1 | tee "$log"
derived_data="$GITHUB_WORKSPACE/.ci-reload-derived-data"
echo "cache_retry=false" >> "$GITHUB_OUTPUT"
run_reload() {
if [ "$SPM_CACHE_STATUS" = "exact_hit" ]; then
# The exact Package.resolved/state pair is safe to reuse. If it
# is stale or corrupt, the caller clears both caches and changes
# this marker before retrying from a normal cold resolve.
export CMUX_DISABLE_AUTOMATIC_PACKAGE_RESOLUTION=1
else
export CMUX_DISABLE_AUTOMATIC_PACKAGE_RESOLUTION=0
fi
./scripts/reload.sh \
--tag "$BUILD_TAG" \
--derived-data "$derived_data" \
--swift-frontend-workaround
}
set +e
run_reload 2>&1 | tee "$log"
build_status=${PIPESTATUS[0]}
set -e
# A successfully restored but stale/corrupt cache must cost at most
# one failed attempt: clear both cache roots and retry once from cold.
if [ "$build_status" -ne 0 ] && {
[ "$SPM_CACHE_STATUS" = "exact_hit" ] ||
[ "$SPM_CACHE_STATUS" = "fallback_hit" ] ||
[ "$DERIVED_DATA_CACHE_STATUS" = "exact_hit" ] ||
[ "$DERIVED_DATA_CACHE_STATUS" = "fallback_hit" ];
}; then
echo "::warning::Cached macOS build failed; clearing SPM and DerivedData caches and retrying cold"
rm -rf "$CMUX_SOURCE_PACKAGES_DIR" "$derived_data"
mkdir -p "$CMUX_SOURCE_PACKAGES_DIR" "$derived_data"
SPM_CACHE_STATUS=miss
DERIVED_DATA_CACHE_STATUS=miss
: > "$log"
set +e
run_reload 2>&1 | tee "$log"
build_status=${PIPESTATUS[0]}
set -e
echo "cache_retry=true" >> "$GITHUB_OUTPUT"
fi
if [ "$build_status" -ne 0 ]; then
exit "$build_status"
fi
app_path="$(awk '/^App path:/{getline; sub(/^ /,""); print; exit}' "$log")"
[ -n "$app_path" ] && [ -d "$app_path" ] || { echo "could not locate built app" >&2; exit 1; }
echo "app_path=$app_path" >> "$GITHUB_OUTPUT"
@@ -133,6 +509,7 @@ jobs:
id: build_ios
env:
BUILD_TAG: ${{ inputs.tag }}
CMUX_XCODEBUILD_NONINTERACTIVE_HEARTBEAT_SECONDS: "60"
run: |
set -euo pipefail
slug="$(printf '%s' "$BUILD_TAG" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed 's/-\{2,\}/-/g; s/^-//; s/-$//')"
@@ -144,6 +521,16 @@ jobs:
api_base_url="${CMUX_IOS_API_BASE_URL:-${CMUX_DEV_API_BASE_URL:-https://cmux-staging.vercel.app}}"
iroh_broker_base_url="${CMUX_IOS_IROH_BROKER_BASE_URL:-${CMUX_IROH_BROKER_BASE_URL:-https://cmux-staging.vercel.app}}"
# Match ios/scripts/reload.sh --swift-frontend-workaround. Optimized
# Debug builds need this Xcode 26 codegen path disabled on every lane.
# shellcheck disable=SC2016 # Xcode expands $(inherited), not this shell.
swift_workaround_args=(
SWIFT_ENABLE_BATCH_MODE=NO
DEBUG_INFORMATION_FORMAT=
GCC_GENERATE_DEBUGGING_SYMBOLS=NO
'OTHER_SWIFT_FLAGS=$(inherited) -Xllvm -aarch64-enable-global-isel-at-O=-1'
)
# Register the iOS platform if the runner only has macOS provisioned.
ios_ready() { xcrun simctl runtime list 2>/dev/null | grep -qiE "iOS [0-9].*\(Ready\)"; }
if ! ios_ready; then
@@ -152,12 +539,12 @@ jobs:
ios_ready || { echo "iOS platform still not registered; archive would fail" >&2; exit 1; }
fi
./scripts/ensure-ghosttykit.sh
test -d GhosttyKit.xcframework
out="$GITHUB_WORKSPACE/build"
mkdir -p "$out"
archive="$out/cmux-ios-$slug.xcarchive"
rm -rf "$archive"
xcodebuild archive \
scripts/ci/xcodebuild_noninteractive.py xcodebuild archive \
-workspace ios/cmux.xcworkspace \
-scheme cmux-ios \
-configuration Debug \
@@ -176,18 +563,22 @@ jobs:
CODE_SIGN_IDENTITY="" \
SWIFT_OPTIMIZATION_LEVEL=-O \
SWIFT_COMPILATION_MODE=wholemodule \
GCC_OPTIMIZATION_LEVEL=s
GCC_OPTIMIZATION_LEVEL=s \
"${swift_workaround_args[@]}"
[ -d "$archive" ] || { echo "archive not produced: $archive" >&2; exit 1; }
# Keep Blacksmith device reloads equivalent to the fleet path: one
# build supplies both the unsigned phone archive and the exact same
# source revision for an isolated Simulator verification.
xcodebuild build \
# The prebuilt GhosttyKit simulator slice is arm64-only.
scripts/ci/xcodebuild_noninteractive.py xcodebuild build \
-workspace ios/cmux.xcworkspace \
-scheme cmux-ios \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-derivedDataPath "$RUNNER_TEMP/cmux-ios-dd" \
ARCHS=arm64 \
ONLY_ACTIVE_ARCH=YES \
PRODUCT_BUNDLE_IDENTIFIER="$bundle_id" \
PRODUCT_DISPLAY_NAME="$display_name" \
CMUX_GIT_SHA="$(git rev-parse --short HEAD)" \
@@ -200,7 +591,8 @@ jobs:
CODE_SIGN_IDENTITY="" \
SWIFT_OPTIMIZATION_LEVEL=-O \
SWIFT_COMPILATION_MODE=wholemodule \
GCC_OPTIMIZATION_LEVEL=s
GCC_OPTIMIZATION_LEVEL=s \
"${swift_workaround_args[@]}"
sim_app="$RUNNER_TEMP/cmux-ios-dd/Build/Products/Debug-iphonesimulator/cmux.app"
[ -d "$sim_app" ] || { echo "simulator app not produced: $sim_app" >&2; exit 1; }
@@ -212,36 +604,254 @@ jobs:
ditto "$sim_app" "$pkg/simulator/cmux.app"
ditto -c -k "$pkg" "$GITHUB_WORKSPACE/artifact/archive.zip"
- name: Mark build-ready
id: t2
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Trim tag-specific products before DerivedData cache save
if: ${{ success() && inputs.platform == 'macos' }}
run: |
set -euo pipefail
products="$GITHUB_WORKSPACE/.ci-reload-derived-data/Build/Products/Debug"
[ -d "$products" ] || exit 0
# reload.sh stages the requested tagged app beside the stable base
# product. Keep only the base product in the cache; otherwise every
# tag accumulates another app bundle under the branch/SHA key.
for product in "$products"/cmux\ DEV\ *.app "$products"/.cmux\ DEV\ *.reload-*.app; do
[ -d "$product" ] || continue
[ "$(basename "$product")" = "cmux DEV.app" ] || rm -rf -- "$product"
done
- name: Start SPM cache save timer
if: ${{ success() && inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' && (steps.spm_restore.outputs.exact_hit != 'true' || steps.spm_sanitize.outputs.discarded == 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: spm_save_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Save SPM SourcePackages cache
if: ${{ success() && inputs.platform == 'macos' && steps.cache_meta.outputs.spm_cache_enabled == 'true' && (steps.spm_restore.outputs.exact_hit != 'true' || steps.spm_sanitize.outputs.discarded == 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: save_spm
continue-on-error: true
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .ci-source-packages
key: ${{ steps.cache_meta.outputs.spm_key }}
- name: Finish SPM cache save
if: ${{ always() && inputs.platform == 'macos' }}
id: spm_save
env:
CACHE_ENABLED: ${{ steps.cache_meta.outputs.spm_cache_enabled }}
START_EPOCH: ${{ steps.spm_save_start.outputs.epoch }}
SAVE_OUTCOME: ${{ steps.save_spm.outcome }}
RESTORE_EXACT_HIT: ${{ steps.spm_restore.outputs.exact_hit }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if [[ "$start" =~ ^[0-9]+$ ]] && [ "$start" -gt 0 ]; then
seconds=$(( now - start ))
else
seconds=0
fi
case "${SAVE_OUTCOME:-skipped}" in
success) status=saved ;;
failure) status=save_error ;;
*)
if [ "${CACHE_ENABLED:-false}" != "true" ]; then
status=skipped_disabled
elif [ "${RESTORE_EXACT_HIT:-false}" = "true" ]; then
status=skipped_exact_hit
else
status=skipped
fi
;;
esac
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
echo "status=$status" >> "$GITHUB_OUTPUT"
- name: Start DerivedData cache save timer
if: ${{ success() && inputs.platform == 'macos' && (steps.derived_data_restore.outputs.exact_hit != 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: derived_data_save_start
run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"
- name: Save DerivedData cache
if: ${{ success() && inputs.platform == 'macos' && (steps.derived_data_restore.outputs.exact_hit != 'true' || steps.build_macos.outputs.cache_retry == 'true') }}
id: save_derived_data
continue-on-error: true
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
# Match the bounded restore set; only Debug products are included.
path: |
.ci-reload-derived-data/Build/Intermediates.noindex
.ci-reload-derived-data/Build/Products/Debug
.ci-reload-derived-data/ModuleCache.noindex
.ci-reload-derived-data/SDKStatCaches.noindex
key: ${{ steps.cache_meta.outputs.derived_data_key }}
- name: Finish DerivedData cache save
if: ${{ always() && inputs.platform == 'macos' }}
id: derived_data_save
env:
START_EPOCH: ${{ steps.derived_data_save_start.outputs.epoch }}
SAVE_OUTCOME: ${{ steps.save_derived_data.outcome }}
RESTORE_EXACT_HIT: ${{ steps.derived_data_restore.outputs.exact_hit }}
run: |
set -euo pipefail
now=$(date +%s)
start=${START_EPOCH:-0}
if [[ "$start" =~ ^[0-9]+$ ]] && [ "$start" -gt 0 ]; then
seconds=$(( now - start ))
else
seconds=0
fi
case "${SAVE_OUTCOME:-skipped}" in
success) status=saved ;;
failure) status=save_error ;;
*)
if [ "${RESTORE_EXACT_HIT:-false}" = "true" ]; then
status=skipped_exact_hit
else
status=skipped
fi
;;
esac
echo "seconds=$seconds" >> "$GITHUB_OUTPUT"
echo "status=$status" >> "$GITHUB_OUTPUT"
- name: Write timings.json
if: ${{ always() }}
env:
TIMING_TAG: ${{ inputs.tag }}
TIMING_PLATFORM: ${{ inputs.platform }}
TIMING_RUNNER: ${{ inputs.runner }}
TIMING_REF: ${{ inputs.ref || github.ref }}
TIMING_T0: ${{ steps.t0.outputs.epoch || '0' }}
TIMING_T1: ${{ steps.t1.outputs.epoch || '0' }}
TIMING_T2: ${{ steps.t2.outputs.epoch || '0' }}
GHOSTTYKIT_PREBUILT: ${{ steps.ghosttykit_prebuilt.outputs.available || 'false' }}
GHOSTTYKIT_DOWNLOAD_SECONDS: ${{ steps.ghosttykit_prebuilt.outputs.seconds || '0' }}
ZIG_INSTALL_SECONDS: ${{ steps.zig_fallback.outputs.seconds || '0' }}
GHOSTTYKIT_FALLBACK_SECONDS: ${{ steps.ghosttykit_fallback.outputs.seconds || '0' }}
CACHE_XCODE_KEY: ${{ steps.cache_meta.outputs.xcode_key }}
CACHE_BRANCH_KEY: ${{ steps.cache_meta.outputs.branch_key }}
CACHE_WORKSPACE_KEY: ${{ steps.cache_meta.outputs.workspace_key }}
CACHE_RUNNER_KEY: ${{ steps.cache_meta.outputs.runner_key }}
SPM_CACHE_PRIMARY_KEY: ${{ steps.cache_meta.outputs.spm_key }}
SPM_CACHE_STATUS: ${{ steps.spm_sanitize.outputs.status || 'not_applicable' }}
SPM_CACHE_EXACT_HIT: ${{ steps.spm_sanitize.outputs.status == 'exact_hit' }}
SPM_CACHE_MATCHED_KEY: ${{ steps.spm_restore.outputs.matched_key }}
SPM_CACHE_RESTORE_OUTCOME: ${{ steps.restore_spm.outcome || 'not_applicable' }}
SPM_CACHE_RESTORE_SECONDS: ${{ steps.spm_restore.outputs.seconds || '0' }}
SPM_CACHE_SAVE_STATUS: ${{ steps.spm_save.outputs.status || 'not_applicable' }}
SPM_CACHE_SAVE_SECONDS: ${{ steps.spm_save.outputs.seconds || '0' }}
DERIVED_DATA_CACHE_PRIMARY_KEY: ${{ steps.cache_meta.outputs.derived_data_key }}
DERIVED_DATA_CACHE_STATUS: ${{ steps.derived_data_restore.outputs.status || 'not_applicable' }}
DERIVED_DATA_CACHE_EXACT_HIT: ${{ steps.derived_data_restore.outputs.exact_hit || 'false' }}
DERIVED_DATA_CACHE_MATCHED_KEY: ${{ steps.derived_data_restore.outputs.matched_key }}
DERIVED_DATA_CACHE_RESTORE_OUTCOME: ${{ steps.restore_derived_data.outcome || 'not_applicable' }}
DERIVED_DATA_CACHE_RESTORE_SECONDS: ${{ steps.derived_data_restore.outputs.seconds || '0' }}
DERIVED_DATA_CACHE_SAVE_STATUS: ${{ steps.derived_data_save.outputs.status || 'not_applicable' }}
DERIVED_DATA_CACHE_SAVE_SECONDS: ${{ steps.derived_data_save.outputs.seconds || '0' }}
CACHE_COLD_RETRY: ${{ steps.build_macos.outputs.cache_retry || 'false' }}
run: |
set -euo pipefail
mkdir -p artifact
now=$(date +%s)
t0=${{ steps.t0.outputs.epoch }}
t1=${{ steps.t1.outputs.epoch || 0 }}
cat > artifact/timings.json <<JSON
{
"tag": "${{ inputs.tag }}",
"platform": "${{ inputs.platform }}",
"runner": "${{ inputs.runner }}",
"ref": "${{ inputs.ref }}",
"deps_seconds": $(( t1 > t0 ? t1 - t0 : 0 )),
"build_seconds": $(( t1 > 0 ? now - t1 : 0 )),
"post_checkout_total_seconds": $(( now - t0 ))
export TIMING_NOW=$now
python3 - <<'PY' > artifact/timings.json
import json
import os
import sys
def integer(name: str) -> int:
try:
return int(os.environ.get(name, "0"))
except ValueError:
return 0
def boolean(name: str) -> bool:
return os.environ.get(name, "false").lower() == "true"
t0 = integer("TIMING_T0")
t1 = integer("TIMING_T1")
t2 = integer("TIMING_T2")
now = integer("TIMING_NOW")
timing_clock_valid = t0 > 0
spm_status = os.environ["SPM_CACHE_STATUS"]
derived_status = os.environ["DERIVED_DATA_CACHE_STATUS"]
payload = {
"tag": os.environ["TIMING_TAG"],
"platform": os.environ["TIMING_PLATFORM"],
"runner": os.environ["TIMING_RUNNER"],
"ref": os.environ["TIMING_REF"],
"deps_seconds": t1 - t0 if timing_clock_valid and t1 > t0 else 0,
"build_seconds": t2 - t1 if timing_clock_valid and t1 > t0 and t2 > t1 else 0,
"post_checkout_total_seconds": now - t0 if timing_clock_valid and now > t0 else 0,
"ghosttykit_prebuilt": boolean("GHOSTTYKIT_PREBUILT"),
"ghosttykit_download_seconds": integer("GHOSTTYKIT_DOWNLOAD_SECONDS"),
"zig_install_seconds": integer("ZIG_INSTALL_SECONDS"),
"ghosttykit_fallback_seconds": integer("GHOSTTYKIT_FALLBACK_SECONDS"),
"cache_xcode_key": os.environ.get("CACHE_XCODE_KEY", ""),
"cache_branch_key": os.environ.get("CACHE_BRANCH_KEY", ""),
"cache_workspace_key": os.environ.get("CACHE_WORKSPACE_KEY", ""),
"cache_runner_key": os.environ.get("CACHE_RUNNER_KEY", ""),
"spm_cache_primary_key": os.environ.get("SPM_CACHE_PRIMARY_KEY", ""),
"spm_cache_status": spm_status,
"spm_cache_hit": spm_status in {"exact_hit", "fallback_hit"},
"spm_cache_exact_hit": boolean("SPM_CACHE_EXACT_HIT"),
"spm_cache_matched_key": os.environ.get("SPM_CACHE_MATCHED_KEY", ""),
"spm_cache_restore_outcome": os.environ["SPM_CACHE_RESTORE_OUTCOME"],
"spm_cache_restore_seconds": integer("SPM_CACHE_RESTORE_SECONDS"),
"spm_cache_save_status": os.environ["SPM_CACHE_SAVE_STATUS"],
"spm_cache_save_seconds": integer("SPM_CACHE_SAVE_SECONDS"),
"derived_data_cache_primary_key": os.environ.get("DERIVED_DATA_CACHE_PRIMARY_KEY", ""),
"derived_data_cache_status": derived_status,
"derived_data_cache_hit": derived_status in {"exact_hit", "fallback_hit"},
"derived_data_cache_exact_hit": boolean("DERIVED_DATA_CACHE_EXACT_HIT"),
"derived_data_cache_matched_key": os.environ.get("DERIVED_DATA_CACHE_MATCHED_KEY", ""),
"derived_data_cache_restore_outcome": os.environ["DERIVED_DATA_CACHE_RESTORE_OUTCOME"],
"derived_data_cache_restore_seconds": integer("DERIVED_DATA_CACHE_RESTORE_SECONDS"),
"derived_data_cache_save_status": os.environ["DERIVED_DATA_CACHE_SAVE_STATUS"],
"derived_data_cache_save_seconds": integer("DERIVED_DATA_CACHE_SAVE_SECONDS"),
"cache_cold_retry": boolean("CACHE_COLD_RETRY"),
}
JSON
json.dump(payload, fp=sys.stdout, indent=2, sort_keys=True)
print()
PY
t0=${TIMING_T0:-0}
t1=${TIMING_T1:-0}
t2=${TIMING_T2:-0}
if (( t0 > 0 && t1 > t0 )); then
deps_seconds=$(( t1 - t0 ))
else
deps_seconds=0
fi
if (( t0 > 0 && t1 > t0 && t2 > t1 )); then
build_seconds=$(( t2 - t1 ))
else
build_seconds=0
fi
if (( t0 > 0 && now > t0 )); then
total_seconds=$(( now - t0 ))
else
total_seconds=0
fi
{
echo "### reload-build timings"
echo ""
echo "- runner: \`${{ inputs.runner }}\`"
echo "- platform: \`${{ inputs.platform }}\`"
echo "- deps: $(( t1 > t0 ? t1 - t0 : 0 ))s"
echo "- build: $(( t1 > 0 ? now - t1 : 0 ))s"
echo "- post-checkout total: $(( now - t0 ))s"
echo "- runner: \`$TIMING_RUNNER\`"
echo "- platform: \`$TIMING_PLATFORM\`"
echo "- deps: ${deps_seconds}s"
echo "- build: ${build_seconds}s"
echo "- SPM cache: ${SPM_CACHE_STATUS} (restore ${SPM_CACHE_RESTORE_SECONDS}s, save ${SPM_CACHE_SAVE_SECONDS}s / ${SPM_CACHE_SAVE_STATUS})"
echo "- DerivedData cache: ${DERIVED_DATA_CACHE_STATUS} (restore ${DERIVED_DATA_CACHE_RESTORE_SECONDS}s, save ${DERIVED_DATA_CACHE_SAVE_SECONDS}s / ${DERIVED_DATA_CACHE_SAVE_STATUS})"
echo "- cache-triggered cold retry: ${CACHE_COLD_RETRY}"
echo "- GhosttyKit prebuilt: ${GHOSTTYKIT_PREBUILT} (download ${GHOSTTYKIT_DOWNLOAD_SECONDS}s, Zig fallback ${ZIG_INSTALL_SECONDS}s)"
echo "- post-checkout total: ${total_seconds}s"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload artifact
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: reload-${{ inputs.tag }}-${{ inputs.platform }}
+40 -46
View File
@@ -502,11 +502,10 @@ jobs:
# one xcodebuild invocation; a filter can select a suite living in either.
xctest = [int(value) for value in re.findall(r"Executed\s+(\d+)\s+tests?\b", text)]
swift_testing = [int(value) for value in re.findall(r"Test run with\s+(\d+)\s+tests?\b", text)]
counts = []
if xctest:
counts.append(xctest[-1])
if swift_testing:
counts.append(swift_testing[-1])
# App-hosted Swift Testing can emit a selected run followed by an
# auxiliary zero-test run. Count every summary so the later host
# teardown cannot erase a test that actually executed.
counts = xctest + swift_testing
if counts:
print(max(counts))
PY
@@ -604,17 +603,18 @@ jobs:
| xargs -I{} echo "Duration: {}s"
- name: Upload recording artifact
id: recording
if: ${{ always() && steps.filter.outputs.record_video == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-recording
path: /tmp/test-recording.mp4
if-no-files-found: warn
retention-days: 30
- name: Post results to cmux-dev-artifacts
- name: Publish test summary
if: always()
env:
GH_TOKEN: ${{ secrets.DEV_ARTIFACTS_TOKEN }}
TEST_RESULT: ${{ steps.tests.outputs.test_result || 'failed' }}
TEST_SUMMARY: ${{ steps.tests.outputs.test_summary }}
TEST_OUTPUT: ${{ steps.tests.outputs.test_output }}
@@ -622,10 +622,10 @@ jobs:
COMMIT_SHA: ${{ steps.sha.outputs.sha }}
RUN_ID: ${{ github.run_id }}
RECORD_VIDEO: ${{ steps.filter.outputs.record_video }}
RECORDING_URL: ${{ steps.recording.outputs.artifact-url }}
run: |
set -euo pipefail
LABEL="$TEST_RESULT"
if [ "$TEST_RESULT" = "passed" ]; then
STATUS_EMOJI="PASSED"
else
@@ -634,47 +634,41 @@ jobs:
REF_DISPLAY="${{ inputs.ref || github.ref_name }}"
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/$RUN_ID"
ARTIFACT_URL="$RUN_URL#artifacts"
{
echo "## E2E test result"
echo
echo "**Status:** $STATUS_EMOJI"
echo "**Ref:** \`$REF_DISPLAY\`"
echo "**SHA:** [\`${COMMIT_SHA:0:12}\`](https://github.com/${{ github.repository }}/commit/$COMMIT_SHA)"
echo "**Test:** \`$TEST_FILTER\`"
echo "**Workflow run:** $RUN_URL"
BODY="**Status:** $STATUS_EMOJI
**Ref:** \`$REF_DISPLAY\`
**SHA:** [\`${COMMIT_SHA:0:12}\`](https://github.com/${{ github.repository }}/commit/$COMMIT_SHA)
**Test:** \`$TEST_FILTER\`
**Workflow run:** $RUN_URL"
if [ "$RECORD_VIDEO" = "true" ]; then
if [ -n "$RECORDING_URL" ]; then
echo "**Recording:** [Download artifact]($RECORDING_URL) (retained for 30 days)"
else
echo "**Recording:** unavailable"
fi
fi
if [ "$RECORD_VIDEO" = "true" ]; then
BODY="$BODY
**Recording:** [Download from artifacts]($ARTIFACT_URL)"
fi
if [ -n "$TEST_OUTPUT" ]; then
echo
echo "<details><summary>Test output (last 200 lines)</summary>"
echo
echo '```'
printf '%s\n' "$TEST_OUTPUT"
echo '```'
echo
echo "</details>"
fi
if [ -n "$TEST_OUTPUT" ]; then
BODY="$BODY
<details><summary>Test output (last 200 lines)</summary>
\`\`\`
$TEST_OUTPUT
\`\`\`
</details>"
fi
if [ -n "$TEST_SUMMARY" ]; then
BODY="$BODY
\`\`\`
$TEST_SUMMARY
\`\`\`"
fi
ISSUE_URL=$(gh issue create \
--repo manaflow-ai/cmux-dev-artifacts \
--title "[$STATUS_EMOJI] $TEST_FILTER @ ${COMMIT_SHA:0:7} ($REF_DISPLAY)" \
--body "$BODY" \
--label "$LABEL")
echo "Issue posted: $ISSUE_URL"
echo "::notice title=Test Result Issue::$ISSUE_URL"
if [ -n "$TEST_SUMMARY" ]; then
echo
echo '```'
printf '%s\n' "$TEST_SUMMARY"
echo '```'
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Clean owned DerivedData
if: always()
+12 -5
View File
@@ -105,7 +105,9 @@ jobs:
mobile-core-package:
needs: detect-ios-changes
if: ${{ needs.detect-ios-changes.outputs.should_run == 'true' }}
if: >-
${{ needs.detect-ios-changes.outputs.should_run == 'true'
&& !(github.event_name == 'workflow_dispatch' && inputs.test_filter != '') }}
runs-on: ${{ (!inputs.runner || inputs.runner == 'auto') && (vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26') || inputs.runner }}
timeout-minutes: 10
steps:
@@ -377,6 +379,9 @@ jobs:
-derivedDataPath "$IOS_DERIVED_DATA"
)
if [ -n "${TEST_FILTER:-}" ]; then
if [[ "$TEST_FILTER" == cmuxUITests/* ]]; then
XCODEBUILD_ARGS+=(-testPlan cmux-ui)
fi
XCODEBUILD_ARGS+=(-only-testing:"$TEST_FILTER")
else
# Full UI tests currently exceed the pull-request simulator budget
@@ -393,9 +398,11 @@ jobs:
# section is not always flushed into the tee'd log, and without the
# ✘ patterns a real Swift Testing failure exiting 65 was
# misclassified as a runner cleanup failure and turned the job green.
grep -Eq "Test Suite 'Selected tests' passed|Test Suite 'cmuxUITests' passed" "$log_path" &&
grep -Eq "Executed [1-9][0-9]* tests, with 0 failures \\(0 unexpected\\)" "$log_path" &&
! grep -Eq "Test Suite '.*' failed|Test Case '.*' failed|Assertion Failure|Failing tests:|with [1-9][0-9]* failures|with [0-9]+ failures \\([1-9][0-9]* unexpected\\)|✘ Test|✘ Suite" "$log_path"
(
grep -Eq "Executed [1-9][0-9]* tests, with 0 failures \\(0 unexpected\\)" "$log_path" ||
grep -Eq "Test run with [1-9][0-9]* tests?( in [1-9][0-9]* suites?)? passed" "$log_path"
) &&
! grep -Eq "Test Suite '.*' failed|Test Case '.*' failed|Assertion Failure|Failing tests:|with [1-9][0-9]* failures|with [0-9]+ failures \\([1-9][0-9]* unexpected\\)|✘ Test|✘ Suite|Test run with [0-9]+ tests?.* failed" "$log_path"
}
for attempt in 1 2; do
LOG_PATH="$IOS_DERIVED_DATA/logs/attempt-${attempt}.log"
@@ -412,7 +419,7 @@ jobs:
fi
status="${PIPESTATUS[0]}"
if selected_tests_passed_despite_xcodebuild_status "$LOG_PATH"; then
echo "xcodebuild exited $status after XCTest reported the selected tests passed with zero failures; treating this as a runner cleanup failure."
echo "xcodebuild exited $status after the selected tests passed; treating this as a runner cleanup failure."
exit 0
fi
if [ "$attempt" -lt 2 ] && grep -Eq "Timed out while launching application via Xcode|Failed to send signal 19|DTXMessage" "$LOG_PATH"; then
@@ -0,0 +1,62 @@
name: Testbox broker guard
# The main CI suite is dispatch-only right now, so the trust-boundary guard for
# the Blacksmith Testbox lane gets its own always-on workflow. It must run on
# every pull request, with no path filter: a path filter is exactly the thing a
# change that moves the guard could slip past.
on:
pull_request:
push:
branches:
- main
permissions: {}
concurrency:
group: testbox-broker-guard-${{ github.ref }}
cancel-in-progress: true
jobs:
guard:
name: Testbox broker trust boundary
runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.12"
- name: Install guard dependencies
run: python3 -m pip install --disable-pip-version-check --no-input PyYAML==6.0.3
- name: Validate Blacksmith Testbox broker trust boundary
run: python3 tests/test_ci_testbox_broker_guard.py
- name: Validate receipt-bound cleanup ownership check
run: ./tests/test_testbox_cleanup_receipt_ref.sh
- name: Lint the Testbox lane helpers
run: shellcheck scripts/blacksmith-bounded-command.sh scripts/blacksmith-cmux-tui-testbox-stage.sh scripts/blacksmith-testbox-cleanup.sh scripts/blacksmith-testbox-keepalive.sh
- name: Lint the Testbox warmup workflow
env:
ACTIONLINT_VERSION: "1.7.7"
ACTIONLINT_SHA256: "023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757"
run: |
set -euo pipefail
archive="$RUNNER_TEMP/actionlint.tar.gz"
curl -fsSL -o "$archive" \
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
echo "${ACTIONLINT_SHA256} ${archive}" | sha256sum --check --strict
tar -xzf "$archive" -C "$RUNNER_TEMP" actionlint
"$RUNNER_TEMP/actionlint" \
.github/workflows/cmux-tui-testbox-warmup.yml \
.github/workflows/testbox-broker-guard.yml
+5
View File
@@ -18,6 +18,7 @@ ios/Config/AppStoreConnect.local.plist
# Swift Package Manager
.swiftpm/
.ci-source-packages/
.ci-reload-derived-data/
# GhosttyKit binary (built from ghostty submodule via scripts/setup.sh)
GhosttyKit.xcframework
@@ -73,3 +74,7 @@ artifacts/
# tmux verbose debug logs (tmux -v) that land in the cwd
tmux-*.log
# Remote-only Blacksmith cmux-tui benchmark output
/testbox-benchmark/
/.cmux-scratch/
@@ -1,15 +1,13 @@
{
"images" : [
{
"filename" : "HermesAgent.svg",
"idiom" : "universal"
"filename" : "HermesAgent.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#101820"/>
<path d="M20 47V17h7v12h10V17h7v30h-7V35H27v12h-7Z" fill="#F4F7FA"/>
<path d="M15 13h34v6H15z" fill="#4AD7D1"/>
<path d="M15 45h34v6H15z" fill="#CFA9FF"/>
<path d="M18 13l5-6 5 6M36 13l5-6 5 6M18 51l5 6 5-6M36 51l5 6 5-6" fill="none" stroke="#F4F7FA" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 472 B

+29 -3
View File
@@ -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:
```bash
scripts/verify-remote.sh ios --tag <tag>
scripts/verify-remote.sh mac --tag <tag>
scripts/verify-remote.sh capacity # all-purpose slots
```
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
`tests/test_testbox_*` guards stay here. Quickest path: `./scripts/blacksmith-testbox-demo.sh`.
+1 -1
View File
@@ -187,7 +187,7 @@ enum AgentHookNotificationClassifier {
}
enum AgentHookNotificationPolicy {
static let dedupeEligibleAgents: Set<String> = ["grok", "antigravity"]
static let dedupeEligibleAgents: Set<String> = ["grok", "antigravity", "hermes-agent"]
static func notificationTitle(
agentName: String,
+212 -168
View File
@@ -9,7 +9,8 @@ enum CLIExecutableLocator {
if size > 0 {
var buffer = Array<CChar>(repeating: 0, count: Int(size))
if _NSGetExecutablePath(&buffer, &size) == 0 {
return URL(fileURLWithPath: String(cString: buffer))
let pathBytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
return URL(fileURLWithPath: String(decoding: pathBytes, as: UTF8.self))
.resolvingSymlinksInPath()
.standardizedFileURL
}
@@ -73,13 +74,55 @@ enum CLIExecutableLocator {
}
}
enum CLISocketPathSource {
enum CLISocketPathSource: Equatable, Sendable {
case explicitFlag
case environment
case implicitDefault
}
enum CLISocketPathResolver {
/// The observable result of resolving an implicit CLI socket path.
struct CLISocketPathResolution: Sendable {
let source: CLISocketPathSource
let requestedPath: String
let candidatePaths: [String]
let selectedPath: String?
/// Whether resolution may proceed to the socket client.
///
/// Explicit paths are intentionally not probed here: their identity is
/// pinned and the normal client error must report the requested path.
var hasLiveSocket: Bool {
source != .implicitDefault || selectedPath != nil
}
/// Whether discovery selected a different path than the one the caller expected.
var didReroute: Bool {
guard let selectedPath else { return false }
return !CLISocketPathResolver.pathsMatchForDiagnostics(requestedPath, selectedPath)
}
/// A user-facing diagnostic for an implicit discovery failure.
var failureMessage: String {
let header = String(
localized: "cli.socket.error.discoveryFailed",
defaultValue: "No live cmux socket found. Tried:"
)
let paths = candidatePaths.map { " \($0)" }.joined(separator: "\n")
return paths.isEmpty ? header : "\(header)\n\(paths)"
}
/// A user-facing notice explaining a deterministic implicit reroute.
var rerouteNotice: String? {
guard let selectedPath, didReroute else { return nil }
let template = String(
localized: "cli.socket.notice.rerouted",
defaultValue: "cmux: default socket %@ is unavailable; using %@."
)
return String.localizedStringWithFormat(template, requestedPath, selectedPath)
}
}
struct CLISocketPathResolver {
enum SocketPathEntry {
case missing
case socket(ownerUserID: uid_t)
@@ -93,6 +136,36 @@ enum CLISocketPathResolver {
private static let nightlySocketPath = "/tmp/cmux-nightly.sock"
private static let stagingSocketPath = "/tmp/cmux-staging.sock"
private let environment: [String: String]
private let bundleIdentifier: String?
private let currentUserID: uid_t
private let inspectSocketPathEntry: (String) -> SocketPathEntry
private let socketAcceptsConnections: (String) -> Bool
private let stateDirectory: URL
/// Creates a resolver with explicit discovery inputs and filesystem probes.
///
/// The inputs are captured once so command dispatch uses one deterministic
/// resolution pass and tests can provide an isolated probe implementation.
init(
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = Self.currentAppBundleIdentifier(),
currentUserID: uid_t = getuid(),
inspectSocketPathEntry: @escaping (String) -> SocketPathEntry = Self.inspectSocketPathEntry,
socketAcceptsConnections: @escaping (String) -> Bool = Self.socketAcceptsConnections,
fileManager: FileManager = .default,
stateDirectory: URL? = nil
) {
self.environment = environment
self.bundleIdentifier = bundleIdentifier
self.currentUserID = currentUserID
self.inspectSocketPathEntry = inspectSocketPathEntry
self.socketAcceptsConnections = socketAcceptsConnections
self.stateDirectory = stateDirectory ?? CmuxStateDirectory.url(
homeDirectory: fileManager.homeDirectoryForCurrentUser
)
}
static func defaultSocketPath(
bundleIdentifier: String?,
environment: [String: String] = ProcessInfo.processInfo.environment
@@ -115,6 +188,22 @@ enum CLISocketPathResolver {
return stablePath ?? legacyDefaultSocketPath
}
private var resolvedStableDefaultSocketPath: String {
stateDirectory.appendingPathComponent(Self.stableSocketFileName, isDirectory: false).path
}
private func resolvedDefaultSocketPath() -> String {
SocketPathMarkerFiles.defaultSocketPath(
bundleIdentifier: bundleIdentifier,
environment: environment,
isDebugBuild: false,
stableSocketPath: resolvedStableDefaultSocketPath,
debugSocketPath: Self.fallbackSocketPath,
nightlySocketPath: Self.nightlySocketPath,
stagingSocketPath: Self.stagingSocketPath
)
}
private static func userScopedStableSocketPath(currentUserID: uid_t = getuid()) -> String {
stableSocketDirectoryURL()?
.appendingPathComponent("cmux-\(currentUserID).sock", isDirectory: false)
@@ -136,83 +225,81 @@ enum CLISocketPathResolver {
)
}
static func resolve(
/// Resolves a socket using one ordered, liveness-aware discovery pass.
///
/// Explicit flag and environment paths are deliberately returned verbatim and are
/// never probed or rerouted. Implicit discovery only selects a path after a real
/// non-blocking connect succeeds; a stale socket file is never handed to the client.
func resolve(
requestedPath: String,
source: CLISocketPathSource,
environment: [String: String] = ProcessInfo.processInfo.environment,
bundleIdentifier: String? = currentAppBundleIdentifier(),
currentUserID: uid_t = getuid(),
inspectSocketPathEntry: (String) -> SocketPathEntry = inspectSocketPathEntry
) -> String {
source: CLISocketPathSource
) -> CLISocketPathResolution {
guard source == .implicitDefault else {
return requestedPath
return CLISocketPathResolution(
source: source,
requestedPath: requestedPath,
candidatePaths: [requestedPath],
selectedPath: requestedPath
)
}
let variant = SocketPathMarkerFiles.variant(bundleIdentifier: bundleIdentifier, environment: environment)
if case .stable = variant,
canConnect(to: requestedPath, currentUserID: currentUserID, inspectSocketPathEntry: inspectSocketPathEntry) {
return requestedPath
let candidates = Self.dedupe(candidatePaths(requestedPath: requestedPath))
let selectedPath = candidates.first { path in
canConnect(to: path)
}
let candidates = dedupe(candidatePaths(
return CLISocketPathResolution(
source: source,
requestedPath: requestedPath,
environment: environment,
bundleIdentifier: bundleIdentifier
))
// Prefer sockets that are currently accepting connections.
for path in candidates where canConnect(
to: path,
currentUserID: currentUserID,
inspectSocketPathEntry: inspectSocketPathEntry
) {
return path
}
// If the listener is still starting, prefer existing socket files.
for path in candidates where isOwnedSocketFile(
path,
currentUserID: currentUserID,
inspectSocketPathEntry: inspectSocketPathEntry
) {
return path
}
return candidates.first ?? requestedPath
candidatePaths: candidates,
selectedPath: selectedPath
)
}
private static func candidatePaths(
requestedPath: String,
environment: [String: String],
bundleIdentifier: String?
) -> [String] {
private func candidatePaths(requestedPath: String) -> [String] {
var candidates: [String] = []
let variant = SocketPathMarkerFiles.variant(bundleIdentifier: bundleIdentifier, environment: environment)
let defaultPath = defaultSocketPath(bundleIdentifier: bundleIdentifier, environment: environment)
let ownDefaultPath = resolvedDefaultSocketPath()
candidates.append(defaultPath)
if let last = readLastSocketPath(bundleIdentifier: bundleIdentifier, environment: environment) {
candidates.append(last)
}
// Keep the current variant first. For a tagged debug CLI this is the
// tag-specific socket; for the stable CLI it is the primary stable socket.
candidates.append(ownDefaultPath)
// A dead dev socket must not strand ambient commands. The stable primary
// socket is the deterministic machine-wide fallback before any marker.
candidates.append(resolvedStableDefaultSocketPath)
// Markers are an ordered list, not a single pointer: the state-directory
// marker and its legacy /tmp mirror can disagree after a reload.
candidates.append(contentsOf: readLastSocketPaths(
bundleIdentifier: bundleIdentifier,
environment: environment
))
// A dev process may be the last writer for its own marker while the
// stable app's marker still names a user-scoped stable listener. Walk
// those markers too, in deterministic order, rather than treating one
// variant's file as the entire discovery state.
candidates.append(contentsOf: readLastSocketPaths(
bundleIdentifier: SocketPathMarkerFiles.stableBundleIdentifier,
environment: [:]
))
// Preserve legacy/user-scoped stable aliases after the primary and marker
// candidates. They remain useful on machines migrating from older releases.
candidates.append(contentsOf: implicitFallbackCandidatePaths(for: variant))
// A caller that supplies a non-default implicit path still gets that path
// tried, but it never displaces the current variant's own socket.
if shouldIncludeImplicitRequestedPath(
requestedPath,
defaultPath: defaultPath,
defaultPath: ownDefaultPath,
variant: variant
) {
candidates.append(requestedPath)
}
candidates.append(contentsOf: implicitFallbackCandidatePaths(for: variant))
if shouldDiscoverTaggedSockets(
variant: variant,
bundleIdentifier: bundleIdentifier,
environment: environment
) {
candidates.append(contentsOf: discoverTaggedSockets(limit: 12))
}
return candidates
}
private static func shouldIncludeImplicitRequestedPath(
private func shouldIncludeImplicitRequestedPath(
_ requestedPath: String,
defaultPath: String,
variant: SocketPathVariant
@@ -221,91 +308,43 @@ enum CLISocketPathResolver {
case .stable:
return true
case .nightly, .staging, .dev:
return pathsMatch(requestedPath, defaultPath)
|| !containsPath(stableImplicitDefaultPaths(), requestedPath)
return Self.pathsMatch(requestedPath, defaultPath)
|| !Self.containsPath(resolvedStableImplicitDefaultPaths(), requestedPath)
}
}
private static func implicitFallbackCandidatePaths(for variant: SocketPathVariant) -> [String] {
private func implicitFallbackCandidatePaths(for variant: SocketPathVariant) -> [String] {
switch variant {
case .stable:
return stableImplicitDefaultPaths()
case .nightly, .staging, .dev:
return []
case .stable, .nightly, .staging, .dev:
return resolvedStableImplicitDefaultPaths()
}
}
private static func shouldDiscoverTaggedSockets(
variant: SocketPathVariant,
private func resolvedStableImplicitDefaultPaths() -> [String] {
Self.dedupe([
resolvedStableDefaultSocketPath,
Self.legacyDefaultSocketPath,
stateDirectory.appendingPathComponent("cmux-\(currentUserID).sock", isDirectory: false).path,
Self.legacyUserScopedStableSocketPath(currentUserID: currentUserID),
])
}
private func readLastSocketPaths(
bundleIdentifier: String?,
environment: [String: String]
) -> Bool {
switch variant {
case .dev(slug: nil):
return true
case .dev(slug: .some):
let bundleId = bundleIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return bundleId == SocketPathMarkerFiles.defaultBaseDebugBundleIdentifier
&& normalized(environment["CMUX_TAG"]) != nil
case .stable, .nightly, .staging:
return false
}
}
private static func readLastSocketPath(
bundleIdentifier: String?,
environment: [String: String]
) -> String? {
) -> [String] {
let candidates = lastSocketPathFiles(bundleIdentifier: bundleIdentifier, environment: environment)
var values: [String] = []
for candidate in candidates {
guard let data = try? String(contentsOfFile: candidate, encoding: .utf8) else {
continue
}
if let value = normalized(data) {
return value
guard let contents = boundedMarkerContents(at: candidate) else { continue }
if let value = Self.normalized(contents) {
values.append(value)
}
}
return nil
return values
}
private static func discoverTaggedSockets(limit: Int) -> [String] {
var discovered: [(path: String, mtime: TimeInterval)] = []
for directory in socketDiscoveryDirectories() {
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: directory) else {
continue
}
discovered.reserveCapacity(min(limit, discovered.count + entries.count))
for name in entries where name.hasPrefix("cmux-debug-") && name.hasSuffix(".sock") {
let path = URL(fileURLWithPath: directory)
.appendingPathComponent(name, isDirectory: false)
.path
var st = stat()
guard lstat(path, &st) == 0 else { continue }
guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { continue }
if isKnownDefaultSocketPath(path) {
continue
}
let modified = TimeInterval(st.st_mtimespec.tv_sec) + TimeInterval(st.st_mtimespec.tv_nsec) / 1_000_000_000
discovered.append((path: path, mtime: modified))
}
}
discovered.sort { $0.mtime > $1.mtime }
return dedupe(discovered.prefix(limit).map(\.path))
}
private static func isSocketFile(_ path: String) -> Bool {
if case .socket = inspectSocketPathEntry(path) {
return true
}
return false
}
private static func isOwnedSocketFile(
_ path: String,
currentUserID: uid_t,
inspectSocketPathEntry: (String) -> SocketPathEntry
) -> Bool {
private func isOwnedSocketFile(_ path: String) -> Bool {
if case .socket(let ownerUserID) = inspectSocketPathEntry(path) {
return ownerUserID == currentUserID
}
@@ -326,18 +365,14 @@ enum CLISocketPathResolver {
return .other(ownerUserID: st.st_uid)
}
private static func canConnect(
to path: String,
currentUserID: uid_t,
inspectSocketPathEntry: (String) -> SocketPathEntry
) -> Bool {
guard isOwnedSocketFile(
path,
currentUserID: currentUserID,
inspectSocketPathEntry: inspectSocketPathEntry
) else {
private func canConnect(to path: String) -> Bool {
guard isOwnedSocketFile(path) else {
return false
}
return socketAcceptsConnections(path)
}
private static func socketAcceptsConnections(_ path: String) -> Bool {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return false }
defer { Darwin.close(fd) }
@@ -349,6 +384,9 @@ enum CLISocketPathResolver {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = MemoryLayout.size(ofValue: addr.sun_path)
guard path.utf8.count < maxLength else {
return false
}
path.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
@@ -387,6 +425,30 @@ enum CLISocketPathResolver {
return optionResult == 0 && socketError == 0
}
/// Reads at most one short socket marker without accepting unbounded input.
private func boundedMarkerContents(at path: String) -> String? {
var info = stat()
guard lstat(path, &info) == 0,
(info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG),
info.st_uid == currentUserID,
info.st_nlink == 1,
info.st_size >= 0,
info.st_size <= off_t(SocketPathMarkerStore.maximumMarkerBytes)
else {
return nil
}
let url = URL(fileURLWithPath: path, isDirectory: false)
guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }
defer { try? handle.close() }
guard let data = try? handle.read(upToCount: SocketPathMarkerStore.maximumMarkerBytes + 1),
data.count <= SocketPathMarkerStore.maximumMarkerBytes
else {
return nil
}
return String(data: data, encoding: .utf8)
}
private static func knownImplicitDefaultPaths(
bundleIdentifier: String?,
environment: [String: String]
@@ -410,22 +472,6 @@ enum CLISocketPathResolver {
])
}
private static func allKnownDefaultSocketPaths() -> Set<String> {
Set(dedupe([
stableDefaultSocketPath,
legacyDefaultSocketPath,
userScopedStableSocketPath(),
legacyUserScopedStableSocketPath(),
fallbackSocketPath,
nightlySocketPath,
stagingSocketPath,
]))
}
private static func isKnownDefaultSocketPath(_ path: String) -> Bool {
containsPath(Array(allKnownDefaultSocketPaths()), path)
}
private static func containsPath(_ paths: [String], _ path: String) -> Bool {
paths.contains { pathsMatch($0, path) }
}
@@ -441,6 +487,12 @@ enum CLISocketPathResolver {
}
}
/// Keeps diagnostic value semantics available to the result type without exposing
/// the resolver's path-normalization implementation as public API.
fileprivate static func pathsMatchForDiagnostics(_ lhs: String, _ rhs: String) -> Bool {
pathsMatch(lhs, rhs)
}
private static func pathComparisonForms(_ path: String) -> [String] {
let baseForms = [
(path as NSString).standardizingPath,
@@ -457,14 +509,14 @@ enum CLISocketPathResolver {
return dedupe(forms)
}
private static func lastSocketPathFiles(
private func lastSocketPathFiles(
bundleIdentifier: String?,
environment: [String: String]
) -> [String] {
SocketPathMarkerFiles.paths(
bundleIdentifier: bundleIdentifier,
environment: environment,
directory: stableSocketDirectoryURL()
directory: stateDirectory
)
}
@@ -482,9 +534,9 @@ enum CLISocketPathResolver {
}
#if DEBUG
return "com.cmuxterm.app.debug"
return SocketPathMarkerFiles.defaultBaseDebugBundleIdentifier
#else
return "com.cmuxterm.app"
return SocketPathMarkerFiles.stableBundleIdentifier
#endif
}
@@ -505,14 +557,6 @@ enum CLISocketPathResolver {
CmuxStateDirectory.url(homeDirectory: FileManager.default.homeDirectoryForCurrentUser)
}
private static func socketDiscoveryDirectories() -> [String] {
let stateSocketDirectory: String = stableSocketDirectoryURL()?.path ?? ""
return dedupe([
"/tmp",
stateSocketDirectory,
])
}
private static func dedupe(_ paths: [String]) -> [String] {
var seen: Set<String> = []
var ordered: [String] = []
+3
View File
@@ -39,6 +39,7 @@ extension CMUXCLI {
.init(agentEvent: "Notification", cmuxSubcommand: "notification"),
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
dispatch: .pinned(marker: "cmux-grok-hook-v2"),
publishesStopNotification: false,
sessionEndIsTurnBoundary: true,
feedHookEvents: ["PreToolUse"]
@@ -142,6 +143,7 @@ extension CMUXCLI {
.init(agentEvent: "SessionEnd", cmuxSubcommand: "session-end"),
],
aliases: ["agy"],
dispatch: .pinned(marker: "cmux-antigravity-hook-v2"),
sessionEndIsTurnBoundary: true
),
AgentHookDef(
@@ -159,6 +161,7 @@ extension CMUXCLI {
AgentHookDef(
name: "hermes-agent", displayName: "Hermes Agent", statusKey: "hermes-agent",
configDir: ".hermes", configFile: "config.yaml", configDirEnvOverride: "HERMES_HOME",
createConfigDirIfMissing: true,
binaryName: "hermes",
sessionStoreSuffix: "hermes-agent", disableEnvVar: "CMUX_HERMES_AGENT_HOOKS_DISABLED",
hookMarker: "cmux hooks hermes-agent", format: .hermesAgentYAML,
+30 -14
View File
@@ -26,6 +26,12 @@ extension CMUXCLI {
let format: HookFormat
let events: [HookEvent]
let aliases: Set<String>
/// How installed hooks find the cmux instance that owns them.
///
/// `.ambient` is appropriate when an agent preserves the launch environment. `.pinned`
/// embeds the installing CLI and socket, which is required for agents that sanitize hook
/// subprocess environments and keeps callbacks attributed to the correct tagged app.
let dispatch: HookDispatch
let publishesStopNotification: Bool
/// Whether this agent's `SessionEnd`/`session-end` hook fires once per
/// conversation turn rather than at a true session teardown.
@@ -62,6 +68,11 @@ extension CMUXCLI {
case tomlArrayTable // ~/.kimi/config.toml [[hooks]] array-of-tables
}
enum HookDispatch {
case ambient
case pinned(marker: String)
}
struct HookEvent {
let agentEvent: String
let cmuxSubcommand: String
@@ -109,6 +120,7 @@ extension CMUXCLI {
sessionStoreSuffix: String, disableEnvVar: String, hookMarker: String,
format: HookFormat, events: [HookEvent],
aliases: Set<String> = [],
dispatch: HookDispatch = .ambient,
publishesStopNotification: Bool = true,
sessionEndIsTurnBoundary: Bool = false,
feedHookEvents: [String] = [],
@@ -123,6 +135,7 @@ extension CMUXCLI {
self.binaryName = binaryName ?? name
self.sessionStoreSuffix = sessionStoreSuffix; self.disableEnvVar = disableEnvVar
self.hookMarker = hookMarker; self.format = format; self.events = events
self.dispatch = dispatch
self.publishesStopNotification = publishesStopNotification
self.sessionEndIsTurnBoundary = sessionEndIsTurnBoundary
self.aliases = Set(aliases.compactMap { alias in
@@ -246,15 +259,12 @@ extension CMUXCLI {
return "{ \(command); }"
}
private static let grokPinnedHookMarker = "cmux-grok-hook-v2"
private static let antigravityPinnedHookMarker = "cmux-antigravity-hook-v2"
private static func agentHookShellCommand(
_ command: String,
for def: AgentHookDef,
noOpCommand: String = "echo '{}'"
) -> String {
if usesPinnedHookDispatch(def) {
if case .pinned = def.dispatch {
return pinnedAgentHookShellCommand(command, for: def, noOpCommand: noOpCommand)
}
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
@@ -272,19 +282,14 @@ extension CMUXCLI {
return "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi; if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments); else \"$cmux_cli\" \(routedArguments); fi; status=$?; if [ \"$status\" -eq 2 ]; then exit 2; fi; if [ \"$status\" -ne 0 ]; then \(noOpSnippet); fi; else \(noOpSnippet); fi"
}
private static func usesPinnedHookDispatch(_ def: AgentHookDef) -> Bool {
def.name == "grok" || def.name == "antigravity"
}
private static func pinnedHookMarker(for def: AgentHookDef) -> String {
def.name == "antigravity" ? antigravityPinnedHookMarker : grokPinnedHookMarker
}
private static func pinnedAgentHookShellCommand(
_ command: String,
for def: AgentHookDef,
noOpCommand: String = "echo '{}'"
) -> String {
guard case .pinned(let marker) = def.dispatch else {
return agentHookShellCommand(command, for: def, noOpCommand: noOpCommand)
}
let routedArguments = command.hasPrefix("cmux ") ? String(command.dropFirst("cmux ".count)) : command
let socketPath = pinnedAgentHookSocketPath()
let noOpSnippet = shellNoOpSnippet(noOpCommand)
@@ -324,7 +329,7 @@ extension CMUXCLI {
} else {
dispatch = "command -v cmux >/dev/null 2>&1 && \(fallbackInvocation) || \(noOpSnippet)"
}
return ": \(pinnedHookMarker(for: def)); \(shellTraceStart); printenv \(def.disableEnvVar) | grep -qx 1 && { \(shellTraceDisabled); \(noOpSnippet); } || { \(dispatch); cmux_hook_status=$?; \(shellTraceExit); exit $cmux_hook_status; }"
return ": \(marker); \(shellTraceStart); printenv \(def.disableEnvVar) | grep -qx 1 && { \(shellTraceDisabled); \(noOpSnippet); } || { \(dispatch); cmux_hook_status=$?; \(shellTraceExit); exit $cmux_hook_status; }"
}
private static func pinnedHookInvocation(
@@ -389,6 +394,17 @@ extension CMUXCLI {
return "/tmp/cmux-debug-\(slug).sock"
}
static func validateHookInstallDispatch(for def: AgentHookDef) throws {
guard case .pinned = def.dispatch,
pinnedAgentHookSocketPath() == nil else {
return
}
throw CLIError(message: String(
localized: "cli.hooks.error.pinnedTargetMissing",
defaultValue: "cmux could not connect this hook installation to a running app. Open a cmux workspace and run this command again."
))
}
private static func pinnedHookShellTraceCommand(
agentName: String,
phase: String,
@@ -434,7 +450,7 @@ extension CMUXCLI {
}
static func isCmuxOwnedHookCommand(_ command: String, for def: AgentHookDef, includeLegacy: Bool = true) -> Bool {
if usesPinnedHookDispatch(def), command.contains(pinnedHookMarker(for: def)) {
if case .pinned(let marker) = def.dispatch, command.contains(marker) {
return true
}
if def.name == "codex", isCmuxOwnedCodexHookScriptCommand(command) {
@@ -0,0 +1,82 @@
import Foundation
import CMUXAgentLaunch
import OSLog
nonisolated private let codexResumeBindingLogger = Logger(
subsystem: "com.cmuxterm.cli",
category: "CodexResumeBinding"
)
extension CMUXCLI {
/// Verifies a hook checkpoint before the standalone CLI publishes it.
///
/// Hook publication is intentionally decided in this short-lived CLI
/// process, not on cmux's MainActor or socket worker. The verifier starts
/// with one indexed SQLite lookup and applies hard byte, line, and fallback
/// candidate limits to the legacy rollout path, so the bounded inspection
/// cannot turn an app UI/socket lane into a history loader.
func codexResumeBindingVerification(
sessionId: String,
transcriptPath: String?,
launchCommand: AgentHookLaunchCommandRecord?
) -> CodexSessionResumeVerification {
let environment = ProcessInfo.processInfo.environment
let codexHome = codexResumeBindingEffectiveHome(
launchEnvironment: launchCommand?.environment,
launchVerificationHome: launchCommand?.verificationHome,
ambientEnvironment: environment
)
return CodexSessionResumeVerifier().verify(
sessionId: sessionId,
transcriptPath: transcriptPath,
codexHome: codexHome
)
}
func codexResumeBindingEffectiveHome(
launchEnvironment: [String: String]?,
launchVerificationHome: String? = nil,
ambientEnvironment: [String: String] = ProcessInfo.processInfo.environment
) -> String {
if let launchHome = normalizedHookValue(launchEnvironment?["CODEX_HOME"]) {
return (launchHome as NSString).expandingTildeInPath
}
if let launchHome = normalizedHookValue(launchVerificationHome)
?? normalizedHookValue(launchEnvironment?["HOME"]) {
return URL(fileURLWithPath: (launchHome as NSString).expandingTildeInPath, isDirectory: true)
.appendingPathComponent(".codex", isDirectory: true)
.path
}
if let ambientHome = normalizedHookValue(ambientEnvironment["CODEX_HOME"]) {
return (ambientHome as NSString).expandingTildeInPath
}
let home = normalizedHookValue(ambientEnvironment["HOME"]) ?? NSHomeDirectory()
return URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(".codex", isDirectory: true)
.path
}
func logCodexResumeBindingRejection(
reason: String,
sessionId: String,
incoming: AgentResumeEvidenceProvenance?,
existing: AgentResumeEvidenceProvenance?,
telemetry: CLISocketSentryTelemetry?
) {
let shortSessionID = String(sessionId.prefix(12))
let incomingValue = incoming?.logValue ?? "none"
let existingValue = existing?.logValue ?? "none"
codexResumeBindingLogger.notice(
"Codex resume binding publish rejected reason=\(reason, privacy: .public) session=\(shortSessionID, privacy: .private(mask: .hash)) incoming=\(incomingValue, privacy: .public) existing=\(existingValue, privacy: .public)"
)
telemetry?.breadcrumb(
"codex-resume-binding.publish-rejected",
data: [
"reason": reason,
"incoming_provenance": incomingValue,
"existing_provenance": existingValue,
"has_session_id": !sessionId.isEmpty,
]
)
}
}
+6 -4
View File
@@ -369,10 +369,10 @@ extension CMUXCLI {
docs Print the same output as `cmux docs settings`.
Targets:
account, app, terminal, sidebar-appearance, custom-sidebars,
automation, browser, browser-import, global-hotkey,
keyboard-shortcuts, shortcuts, workspace-colors, cmux-json,
json, reset
account, app, terminal, networking, sidebar-appearance,
custom-sidebars, automation, browser, browser-import,
global-hotkey, keyboard-shortcuts, shortcuts, workspace-colors,
cmux-json, json, reset
Config file:
\(Self.primarySettingsDisplayPath)
@@ -411,6 +411,8 @@ extension CMUXCLI {
return "automation"
case "browser":
return "browser"
case "networking", "network", "iroh":
return "networking"
case "browser-import", "browserimport", "import-browser-data":
return "browserImport"
case "global-hotkey", "globalhotkey", "hotkey":
+19 -12
View File
@@ -37,16 +37,14 @@ extension CMUXCLI {
return prefix.contains("cmux claude wrapper - injects hooks and session tracking")
}
func isCmuxClaudeCommandShim(at path: String) -> Bool {
func isCmuxAgentCommandShim(at path: String) -> Bool {
let candidate = URL(fileURLWithPath: path, isDirectory: false)
.standardizedFileURL
.path
let environment = ProcessInfo.processInfo.environment
let shimPaths = [
environment["CMUX_CLAUDE_WRAPPER_SHIM"],
]
for shimPath in shimPaths {
guard let shimPath else { continue }
for (key, rawPath) in environment where key.hasSuffix("_WRAPPER_SHIM") {
let shimPath = rawPath.trimmingCharacters(in: .whitespacesAndNewlines)
guard !shimPath.isEmpty else { continue }
let standardizedShim = URL(fileURLWithPath: shimPath, isDirectory: false)
.standardizedFileURL
.path
@@ -55,16 +53,22 @@ extension CMUXCLI {
}
}
let shimRoots: [String?] = [
environment["CMUX_CLAUDE_WRAPPER_SHIM_ROOT"],
var shimRoots = environment.compactMap { key, rawPath -> String? in
guard key == "CMUX_AGENT_COMMAND_SHIM_ROOT" || key.hasSuffix("_WRAPPER_SHIM_ROOT") else {
return nil
}
return rawPath
}
shimRoots.append(contentsOf: [
URL(fileURLWithPath: environment["TMPDIR"] ?? NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent("cmux-cli-shims", isDirectory: true)
.standardizedFileURL
.path,
"/tmp/cmux-cli-shims",
]
])
for shimRoot in shimRoots {
guard let shimRoot else { continue }
let shimRoot = shimRoot.trimmingCharacters(in: .whitespacesAndNewlines)
guard !shimRoot.isEmpty else { continue }
let standardizedRoot = URL(fileURLWithPath: shimRoot, isDirectory: true)
.standardizedFileURL
.path
@@ -94,6 +98,7 @@ extension CMUXCLI {
!isDirectory.boolValue,
FileManager.default.isExecutableFile(atPath: candidate) else { continue }
guard !isBundledProviderExecutable(at: candidate) else { continue }
guard !isCmuxAgentCommandShim(at: candidate) else { continue }
if let skip, skip(candidate) { continue }
return candidate
}
@@ -109,7 +114,7 @@ extension CMUXCLI {
!isDirectory.boolValue,
FileManager.default.isExecutableFile(atPath: trimmed),
!isBundledProviderExecutable(at: trimmed),
!isCmuxClaudeCommandShim(at: trimmed),
!isCmuxAgentCommandShim(at: trimmed),
!isCmuxClaudeWrapper(at: trimmed) else { continue }
return URL(fileURLWithPath: trimmed, isDirectory: false).standardizedFileURL.path
}
@@ -121,7 +126,7 @@ extension CMUXCLI {
resolveExecutableInSearchPath(
"claude",
searchPath: searchPath,
skip: { self.isCmuxClaudeCommandShim(at: $0) || self.isCmuxClaudeWrapper(at: $0) }
skip: { self.isCmuxClaudeWrapper(at: $0) }
)
}
@@ -302,6 +307,8 @@ extension CMUXCLI {
for key in ClaudeSessionEnvironmentPolicy().inheritedIndependentLaunchKeys {
unsetenv(key)
}
unsetenv(ClaudeTeamsRespawnEnvironmentTransport.environmentKey)
unsetenv("CMUX_CLAUDE_TEAMS_WRAPPER_LAUNCH")
}
private func providerExecutableSearchDirectories(searchPath: String?) -> [String] {
+218 -12
View File
@@ -1,7 +1,191 @@
import Foundation
import CMUXAgentLaunch
import Darwin
import Foundation
extension CMUXCLI {
private static let hermesTUIActiveSessionFilePrefix = "hermes-tui-active-session-"
private static let hermesTUIActiveSessionMaximumBytes: Int64 = 4_096
private func updateHermesAgentAllowlist(
atPath allowlistPath: String,
transform: (Data?) throws -> Data
) throws -> Bool {
let lockPath = "\(allowlistPath).lock"
let descriptor = Darwin.open(
lockPath,
O_RDWR | O_CREAT | O_CLOEXEC,
S_IRUSR | S_IWUSR
)
guard descriptor >= 0 else {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
}
defer { _ = Darwin.close(descriptor) }
var result: Int32
repeat {
result = flock(descriptor, LOCK_EX)
} while result != 0 && errno == EINTR
guard result == 0 else {
throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno))
}
defer { _ = flock(descriptor, LOCK_UN) }
let oldAllowlist = FileManager.default.contents(atPath: allowlistPath)
let newAllowlist = try transform(oldAllowlist)
guard oldAllowlist != newAllowlist else { return false }
try newAllowlist.write(to: URL(fileURLWithPath: allowlistPath), options: .atomic)
return true
}
private func hermesAgentApprovalPayload(
def: AgentHookDef,
input: ClaudeHookParsedInput
) -> (event: String, extra: [String: Any])? {
guard def.name == "hermes-agent",
let object = input.rawObject ?? input.object,
let event = firstString(
in: object,
keys: ["hook_event_name", "hookEventName", "event", "event_name"]
)?.lowercased(),
event == "pre_approval_request" || event == "post_approval_response" else {
return nil
}
return (event, (object["extra"] as? [String: Any]) ?? [:])
}
func hermesAgentApprovalSessionId(
def: AgentHookDef,
input: ClaudeHookParsedInput
) -> String? {
guard let payload = hermesAgentApprovalPayload(def: def, input: input) else { return nil }
return normalizedHookValue(firstString(in: payload.extra, keys: ["session_key", "sessionKey"]))
}
/// Hermes 0.20's TUI approval gateway omits both the top-level session id
/// and `extra.session_key`. The cmux wrapper gives each TUI invocation a
/// private TMPDIR containing the same active-session file consumed by its
/// lifecycle watcher, so missing-id callbacks can still use Hermes's own
/// conversation identity instead of mistaking the cmux surface UUID for it.
func hermesAgentTUIActiveSessionId(
def: AgentHookDef,
env: [String: String]
) -> String? {
guard def.name == "hermes-agent",
env["CMUX_HERMES_TUI_HOOK_BOOTSTRAP"] == "1",
let temporaryDirectory = normalizedHookValue(env["TMPDIR"]),
temporaryDirectory.hasPrefix("/") else {
return nil
}
let directoryURL = URL(
fileURLWithPath: temporaryDirectory,
isDirectory: true
).standardizedFileURL
guard Self.isHermesTUIInvocationDirectoryName(directoryURL.lastPathComponent),
Self.isRegularFileWithoutFollowingSymbolicLinks(
atPath: directoryURL.path,
expectedType: S_IFDIR
),
let names = try? FileManager.default.contentsOfDirectory(atPath: directoryURL.path) else {
return nil
}
let candidates = names.filter {
$0.hasPrefix(Self.hermesTUIActiveSessionFilePrefix) && $0.hasSuffix(".json")
}
guard candidates.count == 1 else { return nil }
let activeSessionURL = directoryURL.appendingPathComponent(
candidates[0],
isDirectory: false
)
guard let data = Self.readHermesTUIActiveSessionFile(atPath: activeSessionURL.path),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let sessionId = normalizedHookValue(object["session_id"] as? String),
Self.isValidHermesTUISessionId(sessionId) else {
return nil
}
return sessionId
}
private static func isHermesTUIInvocationDirectoryName(_ name: String) -> Bool {
let prefix = "cmux-hermes-tui."
guard name.hasPrefix(prefix) else { return false }
let suffix = name.dropFirst(prefix.count)
return suffix.utf8.count == 6 && suffix.utf8.allSatisfy(Self.isASCIIAlphaNumeric)
}
private static func isValidHermesTUISessionId(_ sessionId: String) -> Bool {
let bytes = Array(sessionId.utf8)
guard (1...128).contains(bytes.count),
let first = bytes.first,
isASCIIAlphaNumeric(first) else {
return false
}
return bytes.dropFirst().allSatisfy {
isASCIIAlphaNumeric($0) || $0 == 46 || $0 == 95 || $0 == 58 || $0 == 45
}
}
private static func isASCIIAlphaNumeric(_ byte: UInt8) -> Bool {
(48...57).contains(byte) || (65...90).contains(byte) || (97...122).contains(byte)
}
private static func isRegularFileWithoutFollowingSymbolicLinks(
atPath path: String,
expectedType: mode_t
) -> Bool {
var metadata = stat()
return lstat(path, &metadata) == 0 && (metadata.st_mode & S_IFMT) == expectedType
}
private static func readHermesTUIActiveSessionFile(atPath path: String) -> Data? {
guard isRegularFileWithoutFollowingSymbolicLinks(atPath: path, expectedType: S_IFREG) else {
return nil
}
let descriptor = Darwin.open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW)
guard descriptor >= 0 else { return nil }
defer { _ = Darwin.close(descriptor) }
var metadata = stat()
guard fstat(descriptor, &metadata) == 0,
(metadata.st_mode & S_IFMT) == S_IFREG,
metadata.st_size > 0,
metadata.st_size <= hermesTUIActiveSessionMaximumBytes else {
return nil
}
var bytes = [UInt8](repeating: 0, count: Int(metadata.st_size))
var offset = 0
while offset < bytes.count {
let remaining = bytes.count - offset
let count = bytes.withUnsafeMutableBytes { buffer -> Int in
guard let baseAddress = buffer.baseAddress else { return -1 }
return Darwin.read(
descriptor,
baseAddress.advanced(by: offset),
remaining
)
}
if count < 0, errno == EINTR {
continue
}
guard count > 0 else { return nil }
offset += count
}
return Data(bytes)
}
func isHermesAgentAutomaticApprovalObservation(
def: AgentHookDef,
input: ClaudeHookParsedInput
) -> Bool {
guard let payload = hermesAgentApprovalPayload(def: def, input: input) else { return false }
return firstString(in: payload.extra, keys: ["surface"])?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() == "smart"
}
func hermesAgentShellCommand(_ script: String) -> String {
"sh -c \(shellQuote(script))"
}
@@ -32,9 +216,31 @@ extension CMUXCLI {
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
guard fm.fileExists(atPath: configDir) else {
print("\(configDir) does not exist. Install \(def.displayName) first.")
return
let configDirectoryFileError = String.localizedStringWithFormat(
String(
localized: "cli.hooks.error.configDirectoryIsFile",
defaultValue: "cmux could not create the hooks directory: a file exists at %@. Remove or rename the conflicting file, then run `cmux hooks setup` again."
),
configDir
)
let configDirectoryCreateError = String.localizedStringWithFormat(
String(
localized: "cli.hooks.error.configDirectoryCreateFailed",
defaultValue: "cmux could not create the hooks directory at %@. Check the parent directory permissions and try again."
),
configDir
)
var isConfigDirectory: ObjCBool = false
if fm.fileExists(atPath: configDir, isDirectory: &isConfigDirectory) {
guard isConfigDirectory.boolValue else {
throw CLIError(message: configDirectoryFileError)
}
} else {
do {
try fm.createDirectory(atPath: configDir, withIntermediateDirectories: true)
} catch {
throw CLIError(message: configDirectoryCreateError)
}
}
let events = hermesAgentEvents(def: def)
@@ -61,10 +267,10 @@ extension CMUXCLI {
print("\(def.displayName) hooks already up to date at \(filePath)")
}
let oldAllowlist = fm.contents(atPath: allowlistPath)
let newAllowlist = try HermesAgentHookAllowlist.installing(events: events, in: oldAllowlist)
if oldAllowlist != newAllowlist {
try newAllowlist.write(to: URL(fileURLWithPath: allowlistPath), options: .atomic)
let updatedAllowlist = try updateHermesAgentAllowlist(atPath: allowlistPath) { oldAllowlist in
try HermesAgentHookAllowlist.installing(events: events, in: oldAllowlist)
}
if updatedAllowlist {
print("Approved \(def.displayName) cmux shell hooks in \(allowlistPath)")
}
}
@@ -90,10 +296,10 @@ extension CMUXCLI {
}
guard fm.fileExists(atPath: allowlistPath) else { return }
let oldAllowlist = fm.contents(atPath: allowlistPath)
let newAllowlist = try HermesAgentHookAllowlist.uninstalling(events: events, from: oldAllowlist)
if oldAllowlist != newAllowlist {
try newAllowlist.write(to: URL(fileURLWithPath: allowlistPath), options: .atomic)
let updatedAllowlist = try updateHermesAgentAllowlist(atPath: allowlistPath) { oldAllowlist in
try HermesAgentHookAllowlist.uninstalling(events: events, from: oldAllowlist)
}
if updatedAllowlist {
print("Removed Hermes Agent cmux shell hook approvals from \(allowlistPath)")
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ extension CMUXCLI {
let configDirectoryFileError = String.localizedStringWithFormat(
String(
localized: "cli.hooks.error.configDirectoryIsFile",
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."
),
configDir
)
+8
View File
@@ -78,6 +78,14 @@ extension CMUXCLI {
remoteMoshProbeFailedMessage: String(
localized: "cli.ssh.mosh.probeFailed",
defaultValue: "[cmux] Could not verify remote Mosh support; continuing over SSH."
),
remoteBootstrapInstallFailedMessage: String(
localized: "cli.ssh.mosh.bootstrapInstallFailed",
defaultValue: "[cmux] Remote bootstrap install failed; continuing over SSH."
),
remoteMoshAddressFallbackMessage: String(
localized: "cli.ssh.mosh.addressFallback",
defaultValue: "[cmux] Remote SSH advertised an unusable address; resolving the Mosh address through the SSH connection."
)
).command()
}
+69 -2
View File
@@ -4,7 +4,7 @@ extension CMUXCLI {
private static let ompExtensionMarker = "cmux-omp-session-extension-marker"
private static let ompExtensionFilename = "cmux-omp-session.ts"
private static let ompExtensionSource = #"""
// cmux-omp-session-extension-marker v1
// cmux-omp-session-extension-marker v2
// Bridges OMP session lifecycle events into cmux's restorable session store.
// Installed by `cmux hooks omp install` or `cmux hooks setup`.
// DO NOT EDIT MANUALLY. cmux upgrades this file in place.
@@ -316,20 +316,87 @@ function sendHook(subcommand: string, ctx: ExtensionContext, extra: Record<strin
return Promise.resolve();
}
// The pane's lifecycle in cmux must be owned by exactly one OMP session: the
// top-level session driving the terminal. Subagents spawned by the task tool
// run in the same process and inherit CMUX_SURFACE_ID, but each has its own
// session id; without an ownership check, every subagent's agent_end reports
// the whole pane idle while the main agent is still mid-turn, and Agent
// Hibernation then SIGHUPs the live pane (issue #9591).
//
// Ownership must live on globalThis, not in module scope: OMP loads a fresh
// copy of this module for every session in the process (each extension import
// uses a unique ?mtime= cache-busting URL), so module state is per-session
// while the pane is per-process.
interface CmuxOmpPaneOwnership {
sessionId: string | null;
}
const cmuxOmpGlobals = globalThis as typeof globalThis & {
__cmuxOmpPaneOwnership?: CmuxOmpPaneOwnership;
};
function paneOwnership(): CmuxOmpPaneOwnership {
cmuxOmpGlobals.__cmuxOmpPaneOwnership ??= { sessionId: null };
return cmuxOmpGlobals.__cmuxOmpPaneOwnership;
}
function contextSessionId(ctx: ExtensionContext): string | null {
return firstString(ctx.sessionManager.getSessionId());
}
function isOwnerContext(ctx: ExtensionContext): boolean {
const sessionId = contextSessionId(ctx);
return sessionId !== null && sessionId === paneOwnership().sessionId;
}
export default function cmuxOmpSessionExtension(api: ExtensionAPI) {
api.on("session_start", async (_event, ctx) => {
// The top-level session bootstraps before any subagent can exist in this
// process, so the first session_start pins ownership. Later session_start
// events with a different session id are subagent bootstraps.
const ownership = paneOwnership();
if (ownership.sessionId === null) ownership.sessionId = contextSessionId(ctx);
if (!isOwnerContext(ctx)) return;
await sendHook("session-start", ctx);
});
// In-process session transitions (/new, fork, resume, handoff) fire only on
// the top-level runtime; subagent sessions never switch or branch. Re-pin
// ownership to the new session id and rebind the surface in cmux.
const adoptSwitchedSession = async (_event: unknown, ctx: ExtensionContext) => {
const sessionId = contextSessionId(ctx);
if (!sessionId) return;
const ownership = paneOwnership();
// OMP's reload() re-emits session_switch for the unchanged session file.
// A same-id "switch" is not an ownership transition: a spurious
// session-start would mark an idle pane running with no agent_end coming.
if (ownership.sessionId === sessionId) return;
ownership.sessionId = sessionId;
await sendHook("session-start", ctx);
};
api.on("session_switch", adoptSwitchedSession);
api.on("session_branch", adoptSwitchedSession);
api.on("before_agent_start", async (event, ctx) => {
if (!isOwnerContext(ctx)) return;
await sendHook("prompt-submit", ctx, { prompt: boundedHookText(event.prompt) });
});
api.on("agent_end", async (event, ctx) => {
if (!isOwnerContext(ctx)) return;
// OMP emits agent_end as the universal terminal settle. willContinue marks
// a scheduled automatic continuation (auto-retry, queued messages,
// session_stop continuations, background jobs), so the turn is still
// logically running and the pane must not report idle yet. Read it
// defensively: AgentEndEvent predates the field on older OMP versions.
if ((event as { willContinue?: unknown }).willContinue === true) return;
await sendHook("stop", ctx, { last_assistant_message: boundedHookText(lastAssistantMessage(event)) });
});
api.on("session_shutdown", async () => {
api.on("session_shutdown", async (_event, ctx) => {
// A subagent session's teardown must not drain (and drop queued
// prompt-submit entries of) the owner session's hook queue.
if (!isOwnerContext(ctx)) return;
await awaitHookQueueDrain();
});
}
+1
View File
@@ -1,6 +1,7 @@
extension CMUXCLI {
static let piExtensionSource = [
piExtensionSourcePart1,
piExtensionSourceDiagnostics,
piExtensionSourceDispatch,
piExtensionSourcePart2,
].joined(separator: "\n")
@@ -0,0 +1,221 @@
extension CMUXCLI {
static let piExtensionSourceDiagnostics = #"""
type CommandFailureReason = "timeout" | "nonzero-exit" | "spawn-error" | "cancelled";
type CommandTerminationReason = "timeout" | "cancelled";
// Loaded repositories have produced successful 9s+ lifecycle hooks. Leave
// headroom above that observed tail without allowing a stuck child to block a
// session's serialized control queue indefinitely.
const defaultPiHookTimeoutMilliseconds = 15_000;
const maximumPiHookTimeoutMilliseconds = 60_000;
// Feed's CLI owns a four-second end-to-end deadline. Give the wrapper enough
// headroom that the child reports that outcome itself instead of being killed
// mid-deadline, while lifecycle tuning still cannot pin the shared Feed pool.
const maximumPiFeedCommandTimeoutMilliseconds = 4_500;
// Diagnostics are best effort and may hold a serialized hook queue only briefly.
const piHookDiagnosticWriteDeadlineMilliseconds = 100;
function piHookTimeoutMilliseconds(
rawValue: string | undefined = process.env.CMUX_PI_HOOK_TIMEOUT_MS,
): number {
const normalized = rawValue?.trim();
if (!normalized || !/^\d+$/.test(normalized)) return defaultPiHookTimeoutMilliseconds;
const parsed = Number(normalized);
if (parsed >= maximumPiHookTimeoutMilliseconds) return maximumPiHookTimeoutMilliseconds;
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : defaultPiHookTimeoutMilliseconds;
}
function piCommandTimeoutMilliseconds(
args: string[],
rawValue: string | undefined = process.env.CMUX_PI_HOOK_TIMEOUT_MS,
): number {
const configured = piHookTimeoutMilliseconds(rawValue);
return args[0] === "hooks" && args[1] === "feed"
? Math.min(configured, maximumPiFeedCommandTimeoutMilliseconds)
: configured;
}
function commandFailureReason(
status: number | null,
error: unknown,
terminationReason?: CommandTerminationReason,
): CommandFailureReason | undefined {
if (terminationReason) return terminationReason;
if (status === 0) return undefined;
if (status !== null && status !== 0) return "nonzero-exit";
return "spawn-error";
}
function boundedPiHookName(value: string): string {
return utf8Prefix(value, 128) || "unknown";
}
function piHookName(args: string[]): string {
if (args[0] === "hooks" && args[1] === "pi") {
return boundedPiHookName(firstString(args[2]) || "unknown");
}
if (args[0] === "hooks" && args[1] === "feed") {
const eventIndex = args.indexOf("--event");
const eventName = eventIndex >= 0 ? firstString(args[eventIndex + 1]) : null;
return boundedPiHookName(eventName ? `feed:${eventName}` : "feed");
}
if (args[0] === "--json" && args[1] === "surface" && args[2] === "resume") {
return boundedPiHookName(`surface-resume-${firstString(args[3]) || "unknown"}`);
}
return "cmux-command";
}
function expandedPiHookLogPath(value: string, home: string | undefined = process.env.HOME): string {
if (value === "~") return home || value;
if (value.startsWith("~/") && home) {
return path.join(home, value.slice(2));
}
return value;
}
function isOwnedRegularPiHookFile(metadata: fs.Stats): boolean {
return metadata.isFile()
&& typeof process.getuid === "function"
&& metadata.uid === process.getuid();
}
let activePiHookDiagnosticWrite: Promise<void> | undefined;
async function runPiHookDiagnosticWrite(operation: () => Promise<void>): Promise<void> {
// Retain at most one file operation. If it stalls after the caller's deadline,
// later diagnostics are dropped instead of accumulating promises or handles.
if (activePiHookDiagnosticWrite) return;
let tracked: Promise<void>;
tracked = Promise.resolve()
.then(operation)
.catch(() => {})
.finally(() => {
if (activePiHookDiagnosticWrite === tracked) activePiHookDiagnosticWrite = undefined;
});
activePiHookDiagnosticWrite = tracked;
let deadline: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
tracked,
new Promise<void>((resolve) => {
deadline = setTimeout(resolve, piHookDiagnosticWriteDeadlineMilliseconds);
}),
]);
} finally {
if (deadline !== undefined) clearTimeout(deadline);
}
}
function piHookDiagnosticPath(
environment: Record<string, string | undefined> = process.env,
lastDebugLogPathFile = "/tmp/cmux-last-debug-log-path",
fallbackLogPath = "/tmp/cmux-debug.log",
): string {
const explicit = firstString(environment.CMUX_DEBUG_LOG);
if (explicit) return expandedPiHookLogPath(explicit, environment.HOME);
const socketPath = firstString(environment.CMUX_SOCKET_PATH, environment.CMUX_SOCKET);
if (socketPath) {
const socketName = path.basename(socketPath);
if (socketName.startsWith("cmux-debug-") && socketName.endsWith(".sock")) {
return path.join("/tmp", `${socketName.slice(0, -".sock".length)}.log`);
}
}
let pointerDescriptor: number | undefined;
try {
// The shared pointer is untrusted: inspect a nonblocking descriptor and
// bound the read so a special or oversized file cannot stall Pi.
pointerDescriptor = fs.openSync(
lastDebugLogPathFile,
fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW,
);
if (isOwnedRegularPiHookFile(fs.fstatSync(pointerDescriptor))) {
const pointerContents = Buffer.alloc(4096);
const bytesRead = fs.readSync(
pointerDescriptor,
pointerContents,
0,
pointerContents.byteLength,
0,
);
const lastPath = firstString(pointerContents.subarray(0, bytesRead).toString("utf8"));
if (lastPath) return expandedPiHookLogPath(lastPath, environment.HOME);
}
} catch (_) {
} finally {
if (pointerDescriptor !== undefined) {
try { fs.closeSync(pointerDescriptor); } catch (_) {}
}
}
return fallbackLogPath;
}
async function appendPiHookDiagnostic(
payload: Record<string, unknown>,
environment: Record<string, string | undefined> = process.env,
lastDebugLogPathFile = "/tmp/cmux-last-debug-log-path",
fallbackLogPath = "/tmp/cmux-debug.log",
): Promise<void> {
let line: string;
try {
line = JSON.stringify({ timestamp: new Date().toISOString(), ...payload });
} catch (_) {
line = JSON.stringify({
timestamp: new Date().toISOString(),
source: "cmux-pi-extension",
level: "warning",
message: "failed to serialize Pi hook diagnostic",
hook_name: "extension",
reason: "serialization-error",
timeout_ms: piHookTimeoutMilliseconds(),
elapsed_ms: 0,
});
}
try {
// Read/write permits checking the existing JSONL boundary, while O_NONBLOCK
// keeps special files such as a FIFO from stalling Pi's lifecycle queue.
const flags = fs.constants.O_RDWR
| fs.constants.O_APPEND
| fs.constants.O_CREAT
| fs.constants.O_NONBLOCK
| fs.constants.O_NOFOLLOW;
const handle = await fs.promises.open(
piHookDiagnosticPath(environment, lastDebugLogPathFile, fallbackLogPath),
flags,
0o600,
);
try {
const metadata = await handle.stat();
// cmux diagnostics are files; drop device, socket, and pipe destinations.
if (!isOwnedRegularPiHookFile(metadata)) return;
let prefix = "";
if (metadata.size > 0) {
const trailingByte = Buffer.alloc(1);
const { bytesRead } = await handle.read(trailingByte, 0, 1, metadata.size - 1);
if (bytesRead !== 1 || trailingByte[0] !== 0x0a) prefix = "\n";
}
await handle.writeFile(`${prefix}${line}\n`, "utf8");
} finally {
try { await handle.close(); } catch (_) {}
}
} catch (_) {}
}
function commandFailureDetails(
args: string[],
result: CommandResult,
): Record<string, unknown> {
return {
hook_name: piHookName(args),
reason: result.reason || commandFailureReason(result.status, result.error) || "spawn-error",
timeout_ms: result.timeoutMs,
elapsed_ms: result.elapsedMs,
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
};
}
"""#
}
+58 -21
View File
@@ -335,7 +335,7 @@ class PiCmuxCommandDispatcher {
this.failTerminalFeedForSession(sessionId);
this.discardFeedForSession(sessionId);
}
} else if (result.error instanceof Error && result.error.message.includes("timed out after")) {
} else if (result.reason === "timeout") {
const sessionId = command.context.sessionId;
if (sessionId) {
this.failTerminalFeedForSession(sessionId);
@@ -367,18 +367,20 @@ class PiCmuxCommandDispatcher {
}
const result = await this.spawnCmux(args, cwd, input, cancellation);
if (this.isSurfaceResolutionFailure(result)) {
const shouldWarn = !sessionId || !this.unavailableSessions.has(sessionId);
if (sessionId) this.unavailableSessions.add(sessionId);
if (shouldWarn) {
warn(context, "cmux hook command failed", {
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
surface_unavailable: true,
dispatch_disabled: true,
});
}
const surfaceUnavailable = this.isSurfaceResolutionFailure(result);
let shouldLogFailure = true;
if (surfaceUnavailable && sessionId) {
// Claim synchronously so overlapping Feed/control failures emit one diagnostic.
shouldLogFailure = !this.unavailableSessions.has(sessionId);
this.unavailableSessions.add(sessionId);
}
if (!result.ok && result.reason !== "cancelled" && shouldLogFailure) {
await warn(context, "cmux hook command failed", {
...commandFailureDetails(args, result),
...(surfaceUnavailable ? { surface_unavailable: true, dispatch_disabled: true } : {}),
});
}
if (surfaceUnavailable) {
return { ...result, surfaceUnavailable: true };
}
return result;
@@ -391,6 +393,8 @@ class PiCmuxCommandDispatcher {
cancellation?: PiCommandCancellation,
): Promise<CommandResult> {
return new Promise<CommandResult>((resolve) => {
const startedAt = performance.now();
const timeoutMs = piCommandTimeoutMilliseconds(args);
let settled = false;
let stdout = "";
let stderr = "";
@@ -399,6 +403,7 @@ class PiCmuxCommandDispatcher {
let terminateGrace: ReturnType<typeof setTimeout> | null = null;
let forceSettleTimeout: ReturnType<typeof setTimeout> | null = null;
let terminationError: Error | undefined;
let terminationReason: CommandTerminationReason | undefined;
const appendOutput = (current: string, chunk: unknown): string => {
const limit = 1024 * 1024;
@@ -414,12 +419,18 @@ class PiCmuxCommandDispatcher {
if (cancellation) cancellation.cancel = undefined;
resolve(result);
};
const elapsedMilliseconds = (): number => (
Math.max(0, Math.round(performance.now() - startedAt))
);
const terminatedResult = (): CommandResult => ({
ok: false,
status: null,
stdout,
stderr,
error: terminationError,
reason: commandFailureReason(null, terminationError, terminationReason),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
try {
@@ -438,9 +449,10 @@ class PiCmuxCommandDispatcher {
child.stdin.on("error", (error) => {
inputError = error;
});
const beginTermination = (error: Error) => {
const beginTermination = (reason: CommandTerminationReason, error: Error) => {
if (terminationError) return;
terminationError = error;
terminationReason = reason;
child.stdin.destroy();
try {
child.kill("SIGTERM");
@@ -458,7 +470,16 @@ class PiCmuxCommandDispatcher {
}, 250);
};
child.on("error", (error) => {
settle(terminationError ? terminatedResult() : { ok: false, status: null, stdout, stderr, error });
settle(terminationError ? terminatedResult() : {
ok: false,
status: null,
stdout,
stderr,
error,
reason: commandFailureReason(null, error),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
});
child.on("close", (code) => {
if (terminationError) {
@@ -466,24 +487,38 @@ class PiCmuxCommandDispatcher {
return;
}
const status = typeof code === "number" ? code : null;
const error = inputError;
const reason = commandFailureReason(status, error);
settle({
ok: status === 0 && inputError === undefined,
ok: reason === undefined,
status,
stdout,
stderr,
error: inputError,
error,
reason,
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
});
if (cancellation) {
cancellation.cancel = () => beginTermination(new Error("cmux feed command cancelled"));
cancellation.cancel = () => beginTermination("cancelled", new Error("cmux feed command cancelled"));
if (cancellation.cancelled) cancellation.cancel();
}
timeout = setTimeout(() => {
beginTermination(new Error("cmux command timed out after 5000ms"));
}, 5000);
beginTermination("timeout", new Error(`cmux command timed out after ${timeoutMs}ms`));
}, timeoutMs);
child.stdin.end(input);
} catch (error) {
settle({ ok: false, status: null, stdout, stderr, error });
settle({
ok: false,
status: null,
stdout,
stderr,
error,
reason: commandFailureReason(null, error),
timeoutMs,
elapsedMs: elapsedMilliseconds(),
});
}
});
}
@@ -498,6 +533,8 @@ class PiCmuxCommandDispatcher {
status: null,
stdout: "",
stderr: "",
timeoutMs: piHookTimeoutMilliseconds(),
elapsedMs: 0,
surfaceUnavailable: true,
};
}
+17 -28
View File
@@ -1,6 +1,6 @@
extension CMUXCLI {
static let piExtensionSourcePart1 = #"""
// cmux-pi-session-extension-marker v2
// cmux-pi-session-extension-marker v3
// Bridges Pi session lifecycle, tool telemetry, notifications, and resume bindings into cmux.
// Installed by `cmux hooks pi install` or `cmux hooks setup`.
// DO NOT EDIT MANUALLY. cmux upgrades this file in place.
@@ -34,13 +34,15 @@ interface CommandResult {
stdout: string;
stderr: string;
error?: unknown;
reason?: CommandFailureReason;
timeoutMs: number;
elapsedMs: number;
surfaceUnavailable?: boolean;
}
interface PiExtensionContextSnapshot {
readonly sessionId: string | null;
readonly cwd: string;
readonly notifyWarning?: () => void;
}
function firstString(...values: unknown[]): string | null {
@@ -348,6 +350,7 @@ function safeCmuxEnvKey(key: string): boolean {
if (key.startsWith("CMUX_AGENT_LAUNCH_")) return !secretLikeEnvKey(key);
if (key === "CMUX_AGENT_HOOK_STATE_DIR") return true;
if (key === "CMUX_PI_CMUX_BIN" || key === "CMUX_PI_HOOKS_DISABLED") return true;
if (key === "CMUX_PI_HOOK_TIMEOUT_MS") return true;
if (key === "CMUX_SURFACE_ID" || key === "CMUX_WORKSPACE_ID" || key === "CMUX_WINDOW_ID") return true;
if (key === "CMUX_PANE_ID" || key === "CMUX_TAB_ID" || key === "CMUX_PANEL_ID") return true;
if (key === "CMUX_SOCKET" || key === "CMUX_SOCKET_PATH") return true;
@@ -460,17 +463,9 @@ function cwdFrom(ctx: ExtensionContext): string {
}
function snapshotContext(ctx: ExtensionContext): PiExtensionContextSnapshot {
let notifyWarning: (() => void) | undefined;
try {
const ui = (ctx as unknown as { ui?: { notify?: (message: string, type?: string) => void } }).ui;
if (typeof ui?.notify === "function") {
notifyWarning = () => ui.notify?.("cmux Pi integration warning - check the terminal for details", "warning");
}
} catch (_) {}
return {
sessionId: sessionIdFrom(ctx),
cwd: cwdFrom(ctx),
notifyWarning,
};
}
@@ -528,26 +523,20 @@ function settleTurn(sessionStates: Map<string, SessionState>, sessionId: string)
return completion;
}
function warn(
ctx: PiExtensionContextSnapshot | null,
async function warn(
_ctx: PiExtensionContextSnapshot | null,
message: string,
details: Record<string, unknown> = {},
notifyUser = false,
): void {
const payload = { source: "cmux-pi-extension", level: "warning", message, ...details };
try {
console.warn(JSON.stringify(payload));
} catch (_) {
console.warn(`[cmux-pi-extension] ${message}`);
}
// Hook transport is best-effort telemetry. Keep routine command failures in
// the terminal instead of interrupting Pi with a generic toast; reserve the
// UI warning for an unexpected extension-task exception.
if (notifyUser) {
try {
ctx?.notifyWarning?.();
} catch (_) {}
}
): Promise<void> {
const payload = {
source: "cmux-pi-extension",
level: "warning",
message,
hook_name: "extension",
reason: "extension-error",
...details,
};
await runPiHookDiagnosticWrite(() => appendPiHookDiagnostic(payload));
}
function cmuxExecutable(): string {
+77 -41
View File
@@ -27,14 +27,6 @@ async function sendHook(
context,
);
if (result.ok) rememberSurfaceTarget(dispatcher, sessionId, result);
if (!result.ok && !result.surfaceUnavailable) {
warn(context, "cmux hook command failed", {
subcommand,
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
});
}
return result.ok;
}
@@ -206,14 +198,7 @@ async function ensureResumeBinding(
"--",
...resumeArgv,
], cwd, undefined, context);
if (!set.ok && !set.surfaceUnavailable) {
warn(context, "failed to set Pi resume binding", {
status: set.status,
stderr_available: set.stderr.trim().length > 0,
error_available: set.error !== undefined,
});
return;
}
if (!set.ok && !set.surfaceUnavailable) return;
if (set.surfaceUnavailable) return;
const verification = await dispatcher.run(
@@ -225,7 +210,11 @@ async function ensureResumeBinding(
if (verification.surfaceUnavailable) return;
const verified = parseJSONOutput(verification);
if (!resumeBindingMatches(verified, sessionId)) {
warn(context, "Pi resume binding did not verify after write", { session_id: sessionId });
await warn(context, "Pi resume binding did not verify after write", {
session_id: sessionId,
hook_name: "surface-resume-get",
reason: "verification-failure",
});
}
}
@@ -238,7 +227,7 @@ async function clearResumeBinding(
const target = surfaceTargetArgs(dispatcher, sessionId);
if (!target) return;
const cwd = context.cwd;
const result = await dispatcher.run([
await dispatcher.run([
"--json",
"surface",
"resume",
@@ -249,14 +238,6 @@ async function clearResumeBinding(
"--source",
"agent-hook",
], cwd, undefined, context);
if (result.surfaceUnavailable) return;
if (!result.ok) {
warn(context, "failed to clear Pi resume binding", {
status: result.status,
stderr_available: result.stderr.trim().length > 0,
error_available: result.error !== undefined,
});
}
}
type PiFeedEventName =
@@ -341,6 +322,17 @@ function prepareFeedDispatch(
};
}
async function warnFeedDeliveryDropped(
context: PiExtensionContextSnapshot,
sessionId: string,
): Promise<void> {
await warn(context, "cmux feed delivery dropped", {
session_id: sessionId,
hook_name: "feed",
reason: "dispatch-dropped",
});
}
async function publishPendingCompletion(
dispatcher: PiCmuxCommandDispatcher,
sessionStates: Map<string, SessionState>,
@@ -352,9 +344,7 @@ async function publishPendingCompletion(
const state = stateFor(sessionStates, sessionId);
const feedDelivered = !state.feedDeliveryFailed;
state.feedDeliveryFailed = false;
if (!feedDelivered) {
warn(context, "cmux hook command failed", { session_id: sessionId });
}
if (!feedDelivered) await warnFeedDeliveryDropped(context, sessionId);
const stopPayload: HookExtra = {
last_assistant_message: completion.lastAssistantMessage,
turn_id: completion.turnId,
@@ -374,34 +364,77 @@ async function publishPendingCompletion(
await sendHook(dispatcher, "stop", context, stopPayload);
}
export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
const dispatcher = new PiCmuxCommandDispatcher();
const sessionStates = new Map<string, SessionState>();
const lifecycleTails = new Map<string, Promise<void>>();
// A stalled lifecycle hook may run for its full configured timeout while Pi
// keeps emitting tool events. Bound the pending tasks a session can stack
// behind it so bursts cannot pin unbounded event payloads: droppable Feed
// preparation is shed first and surfaces as a dropped delivery at completion.
const maximumPiLifecycleBacklogTasks = 32;
const enqueueLifecycleTask = (
interface PiLifecycleQueue {
enqueue(
sessionId: string,
context: PiExtensionContextSnapshot,
operation: () => Promise<unknown> | unknown,
): Promise<void>;
tryEnqueue(
sessionId: string,
context: PiExtensionContextSnapshot,
operation: () => Promise<unknown> | unknown,
): boolean;
}
function createPiLifecycleQueue(): PiLifecycleQueue {
const tails = new Map<string, Promise<void>>();
const pendingCounts = new Map<string, number>();
const enqueue = (
sessionId: string,
context: PiExtensionContextSnapshot,
operation: () => Promise<unknown> | unknown,
): Promise<void> => {
const previous = lifecycleTails.get(sessionId) || Promise.resolve();
pendingCounts.set(sessionId, (pendingCounts.get(sessionId) || 0) + 1);
const previous = tails.get(sessionId) || Promise.resolve();
let tracked: Promise<void>;
tracked = previous
.then(operation)
.then(() => undefined)
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : undefined;
warn(context, "cmux lifecycle task failed", {
return warn(context, "cmux lifecycle task failed", {
hook_name: "lifecycle-task",
reason: "extension-error",
error_available: error !== undefined,
error_message: utf8Prefix(errorMessage, 512),
}, true);
});
})
.finally(() => {
if (lifecycleTails.get(sessionId) === tracked) lifecycleTails.delete(sessionId);
const remaining = (pendingCounts.get(sessionId) || 1) - 1;
if (remaining > 0) pendingCounts.set(sessionId, remaining);
else pendingCounts.delete(sessionId);
if (tails.get(sessionId) === tracked) tails.delete(sessionId);
});
lifecycleTails.set(sessionId, tracked);
tails.set(sessionId, tracked);
return tracked;
};
return {
enqueue,
tryEnqueue(sessionId, context, operation) {
if ((pendingCounts.get(sessionId) || 0) >= maximumPiLifecycleBacklogTasks) return false;
void enqueue(sessionId, context, operation);
return true;
},
};
}
export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
const dispatcher = new PiCmuxCommandDispatcher();
const sessionStates = new Map<string, SessionState>();
const lifecycleTasks = createPiLifecycleQueue();
const enqueueLifecycleTask = (
sessionId: string,
context: PiExtensionContextSnapshot,
operation: () => Promise<unknown> | unknown,
): Promise<void> => lifecycleTasks.enqueue(sessionId, context, operation);
pi.on("session_start", (_event, ctx) => {
const context = snapshotContext(ctx);
@@ -439,7 +472,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
if (!sessionId) return;
const dispatch = prepareFeedDispatch(dispatcher, sessionStates, eventName, context, event);
if (!dispatch) return;
enqueueLifecycleTask(sessionId, context, dispatch);
if (!lifecycleTasks.tryEnqueue(sessionId, context, dispatch)) {
// A shed completion must fail visibly instead of reporting delivery.
if (isTerminalFeedEvent(eventName)) stateFor(sessionStates, sessionId).feedDeliveryFailed = true;
}
};
pi.on("tool_execution_start", (event, ctx) => {
@@ -513,7 +549,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
await dispatcher.finishFeedForSession(sessionId);
const feedDelivered = !state.feedDeliveryFailed;
state.feedDeliveryFailed = false;
if (!feedDelivered) warn(context, "cmux hook command failed", { session_id: sessionId });
if (!feedDelivered) await warnFeedDeliveryDropped(context, sessionId);
if (stopPayload) await sendHook(dispatcher, "stop", context, stopPayload);
try {
await clearResumeBinding(dispatcher, context, sessionId);
+61 -1
View File
@@ -26,6 +26,9 @@ extension CMUXCLI {
if let environment = command.environment {
payload["environment"] = environment
}
if let verificationHome = command.verificationHome {
payload["verification_home"] = verificationHome
}
if let capturedAt = command.capturedAt {
payload["captured_at"] = capturedAt
}
@@ -80,7 +83,7 @@ extension CMUXCLI {
)
)
}
let record = try restoreRecord(from: rawRecord)
var record = try restoreRecord(from: rawRecord)
if let expectedKind = selector.kind, expectedKind != record.kind {
throw loggedRestoreError(
stage: "record.kind-mismatch",
@@ -103,6 +106,14 @@ extension CMUXCLI {
)
}
if let surfaceID = params["surface_id"] as? String {
record = try recoveredHermesRestoreRecord(
record,
surfaceID: surfaceID,
processEnvironment: processEnvironment
)
}
let environment = processEnvironment.merging(record.environment) { _, restored in
restored
}
@@ -188,6 +199,54 @@ extension CMUXCLI {
)
}
/// Repairs transient Hermes TUI identities using hook process-generation
/// evidence and the durable Hermes state database.
private func recoveredHermesRestoreRecord(
_ record: RestoreRecord,
surfaceID: String,
processEnvironment: [String: String]
) throws -> RestoreRecord {
guard record.kind == "hermes-agent",
let checkpointID = record.checkpointID,
let surfaceUUID = UUID(uuidString: surfaceID) else {
return record
}
var recoveryEnvironment = processEnvironment
recoveryEnvironment.merge(record.environment) { _, restored in restored }
if let captured = record.launchCommand?.environment {
recoveryEnvironment.merge(captured) { _, restored in restored }
}
let hookStatePath = agentHookStatePath(
sessionStoreSuffix: "hermes-agent",
env: processEnvironment
)
switch HermesLegacySessionIdentityRecovery().resolve(
surfaceID: surfaceUUID,
corruptSessionID: checkpointID,
expectedWorkingDirectory: record.workingDirectory
?? record.launchCommand?.workingDirectory,
hookStateFileURL: URL(fileURLWithPath: hookStatePath),
environment: recoveryEnvironment
) {
case .valid, .legacyRestore, .unavailable:
return record
case .missing:
throw loggedRestoreError(
stage: "hermes.checkpoint.missing",
detail: checkpointID,
message: String(
localized: "cli.restore.error.noRecord",
defaultValue: "restore: this session has nothing to restore. Start the agent again in this terminal."
)
)
case .recovered(let candidate):
return record.repairingHermesCheckpoint(
candidate.sessionID,
fallbackLaunchCommand: candidate.launchCommand
)
}
}
private func currentRestoreSurfaceID(
client: SocketClient,
processEnvironment: [String: String]
@@ -445,6 +504,7 @@ extension CMUXCLI {
arguments: arguments,
workingDirectory: object["working_directory"] as? String,
environment: object["environment"] as? [String: String],
verificationHome: object["verification_home"] as? String,
capturedAt: (object["captured_at"] as? NSNumber)?.doubleValue,
source: object["source"] as? String
)
+19
View File
@@ -14,5 +14,24 @@ extension CMUXCLI {
let preparedArgumentsWorkingDirectory: String?
let permissionMode: String?
let legacyCommand: String?
func repairingHermesCheckpoint(
_ checkpointID: String,
fallbackLaunchCommand: AgentLaunchCommand?
) -> RestoreRecord {
RestoreRecord(
mode: mode,
kind: kind,
checkpointID: checkpointID,
source: source,
workingDirectory: workingDirectory,
environment: environment,
launchCommand: launchCommand ?? fallbackLaunchCommand,
preparedArguments: nil,
preparedArgumentsWorkingDirectory: nil,
permissionMode: permissionMode,
legacyCommand: legacyCommand
)
}
}
}
+151 -48
View File
@@ -130,72 +130,70 @@ extension CMUXCLI {
surfaceID: String?,
sessionID: String,
lifecycleID: String,
reconciliationConfirmedSessionEnded: inout Bool,
intentionalOnly: Bool,
sessionRunningExitCode: SSHPTYAttachExitCode = .bridgeClosedSessionRunning
sessionRunningExitCode: SSHPTYAttachExitCode = .bridgeClosedSessionRunning,
reconciliationUnavailableExitCode: SSHPTYAttachExitCode = .retryableTransient
) throws -> Bool {
let reconciliationFailure = "ssh-pty-attach: bridge closed before remote PTY exit could be confirmed"
let response: [String: Any]
do {
var params: [String: Any] = [
"workspace_id": workspaceId,
"session_id": sessionID,
"lifecycle_id": lifecycleID,
"acknowledge_lifecycle_if_session_absent": !intentionalOnly,
]
if let surfaceID {
params["surface_id"] = surfaceID
params["allow_moved_surface"] = true
}
response = try client.sendV2(method: "workspace.remote.pty_sessions", params: params)
} catch {
throw CLIError(
message: "\(reconciliationFailure): \(userFacingRemotePTYErrorMessage(error))",
exitCode: SSHPTYAttachExitCode.retryableTransient
)
var params: [String: Any] = [
"workspace_id": workspaceId,
"session_id": sessionID,
"lifecycle_id": lifecycleID,
"acknowledge_lifecycle_if_session_absent": false,
]
if let surfaceID {
params["surface_id"] = surfaceID
params["allow_moved_surface"] = true
}
let requestedLifecycle = (response["requested_session_lifecycle"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let intentionalCleanup = requestedLifecycle == "intentional_cleanup_requested" ||
requestedLifecycle == "intentionally_closed"
guard let sessions = response["sessions"] as? [[String: Any]] else {
throw CLIError(message: reconciliationFailure, exitCode: SSHPTYAttachExitCode.retryableTransient)
}
let errors: [[String: Any]]
if let rawErrors = response["errors"] {
guard let parsedErrors = rawErrors as? [[String: Any]] else {
throw CLIError(message: reconciliationFailure, exitCode: SSHPTYAttachExitCode.retryableTransient)
}
errors = parsedErrors
} else {
errors = []
}
if !intentionalCleanup, !errors.isEmpty {
throw CLIError(
message: "\(reconciliationFailure)\n\(sshSessionListFailureMessage(errors))",
exitCode: SSHPTYAttachExitCode.retryableTransient
var reconciliation = try requestValidatedSSHPTYReconciliation(
client: client,
params: params,
unavailableExitCode: reconciliationUnavailableExitCode
)
if !intentionalOnly,
!reconciliation.intentionalCleanup,
!reconciliation.sessionIDs.contains(sessionID) {
// Keep the first liveness read side-effect free. Only after its
// response is validated may the server atomically recheck absence
// and acknowledge this exact lifecycle generation.
params["acknowledge_lifecycle_if_session_absent"] = true
reconciliation = try requestValidatedSSHPTYReconciliation(
client: client,
params: params,
unavailableExitCode: reconciliationUnavailableExitCode
)
}
if intentionalOnly, !intentionalCleanup { return false }
if intentionalOnly, !reconciliation.intentionalCleanup { return false }
let sessionStillRunning = sessions.contains {
(($0["session_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") == sessionID
}
if !intentionalCleanup, sessionStillRunning {
if !reconciliation.intentionalCleanup,
reconciliation.sessionIDs.contains(sessionID) {
let message: String
if sessionRunningExitCode == .bridgeClosedWithoutProgress {
message = String(
localized: "cli.sshPtyAttach.bridgeClosedWithoutProgress",
defaultValue: "ssh-pty-attach: bridge closed without receiving new output while the remote PTY session is still running"
defaultValue: "ssh-pty-attach: bridge closed without receiving new output while the remote PTY session is still running",
bundle: CLIExecutableLocator.enclosingAppBundle() ?? .main
)
} else if sshPTYAttachWrapperWillRetry(sessionRunningExitCode) {
message = String(
localized: "cli.sshPtyAttach.bridgeClosedSessionRunningReconnecting",
defaultValue: "The SSH terminal connection ended while the remote session is still running; cmux is reconnecting.",
bundle: CLIExecutableLocator.enclosingAppBundle() ?? .main
)
} else {
message = "ssh-pty-attach: bridge closed while remote PTY session is still running"
message = String(
localized: "cli.sshPtyAttach.bridgeClosedSessionRunning",
defaultValue: "The SSH terminal connection ended; the remote session may still be running.",
bundle: CLIExecutableLocator.enclosingAppBundle() ?? .main
)
}
throw CLIError(
message: message,
exitCode: sessionRunningExitCode
)
}
reconciliationConfirmedSessionEnded = true
guard let surfaceID else { return true }
do {
_ = try client.sendV2(method: "workspace.remote.pty_attach_end", params: [
@@ -206,12 +204,117 @@ extension CMUXCLI {
} catch {
throw CLIError(
message: "ssh-pty-attach: remote PTY exited but local session cleanup failed: \(userFacingRemotePTYErrorMessage(error))",
exitCode: SSHPTYAttachExitCode.retryableTransient
exitCode: SSHPTYAttachExitCode.fatal
)
}
return true
}
private func requestValidatedSSHPTYReconciliation(
client: SocketClient,
params: [String: Any],
unavailableExitCode: SSHPTYAttachExitCode
) throws -> (intentionalCleanup: Bool, sessionIDs: [String]) {
let response: [String: Any]
do {
response = try client.sendV2(method: "workspace.remote.pty_sessions", params: params)
} catch {
throw CLIError(
message: sshPTYReconciliationUnavailableMessage(
detail: userFacingRemotePTYErrorMessage(error)
),
exitCode: unavailableExitCode
)
}
return try validatedSSHPTYReconciliation(
response,
unavailableExitCode: unavailableExitCode
)
}
private func validatedSSHPTYReconciliation(
_ response: [String: Any],
unavailableExitCode: SSHPTYAttachExitCode
) throws -> (intentionalCleanup: Bool, sessionIDs: [String]) {
let requestedLifecycle: String?
if let rawRequestedLifecycle = response["requested_session_lifecycle"] {
guard let rawRequestedLifecycle = rawRequestedLifecycle as? String else {
throw CLIError(
message: sshPTYReconciliationUnavailableMessage(detail: nil),
exitCode: unavailableExitCode
)
}
let normalizedLifecycle = rawRequestedLifecycle
.trimmingCharacters(in: .whitespacesAndNewlines)
guard [
"active",
"intentional_cleanup_requested",
"intentionally_closed",
].contains(normalizedLifecycle) else {
throw CLIError(
message: sshPTYReconciliationUnavailableMessage(detail: nil),
exitCode: unavailableExitCode
)
}
requestedLifecycle = normalizedLifecycle
} else {
requestedLifecycle = nil
}
let intentionalCleanup = requestedLifecycle == "intentional_cleanup_requested" ||
requestedLifecycle == "intentionally_closed"
guard let sessions = response["sessions"] as? [[String: Any]] else {
throw CLIError(
message: sshPTYReconciliationUnavailableMessage(detail: nil),
exitCode: unavailableExitCode
)
}
let sessionIDs = sessions.compactMap { session -> String? in
guard let rawSessionID = session["session_id"] as? String else { return nil }
let normalizedSessionID = rawSessionID.trimmingCharacters(in: .whitespacesAndNewlines)
return normalizedSessionID.isEmpty ? nil : normalizedSessionID
}
guard sessionIDs.count == sessions.count else {
throw CLIError(
message: sshPTYReconciliationUnavailableMessage(detail: nil),
exitCode: unavailableExitCode
)
}
let errors: [[String: Any]]
if let rawErrors = response["errors"] {
guard let parsedErrors = rawErrors as? [[String: Any]] else {
throw CLIError(
message: sshPTYReconciliationUnavailableMessage(detail: nil),
exitCode: unavailableExitCode
)
}
errors = parsedErrors
} else {
errors = []
}
if !intentionalCleanup, !errors.isEmpty {
throw CLIError(
message: sshPTYReconciliationUnavailableMessage(
detail: sshSessionListFailureMessage()
),
exitCode: unavailableExitCode
)
}
return (intentionalCleanup, sessionIDs)
}
private func sshPTYReconciliationUnavailableMessage(detail: String?) -> String {
let message = String(
localized: "cli.sshPtyAttach.reconciliationUnavailableReattach",
defaultValue: "The SSH terminal connection ended before the remote session state could be confirmed; preserving the remote session for reconnection.",
bundle: CLIExecutableLocator.enclosingAppBundle() ?? .main
)
guard let detail = detail?.trimmingCharacters(in: .whitespacesAndNewlines),
!detail.isEmpty else {
return message
}
return "\(message): \(detail)"
}
func readSSHPTYBridgeReady(fd: Int32) throws -> (attachmentToken: String, replayBytes: Int) {
let maxStatusBytes = 4096
// Bound only the pre-ready status wait: a bridge that accepts the TCP
+57 -5
View File
@@ -1,19 +1,71 @@
import CmuxFoundation
import Darwin
import Foundation
extension CMUXCLI {
func sshAutoReconnectNoteFormat() -> String {
let status = String(localized: "cli.ssh.autoReconnect.status", defaultValue: "[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).")
let stopHint = String(localized: "cli.ssh.autoReconnect.stopHint", defaultValue: "[cmux] close this pane or press Ctrl-C to stop reconnecting.")
let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main
let status = String(localized: "cli.ssh.autoReconnect.status", defaultValue: "[cmux] ssh exited with status %s; reconnecting (attempt %s/%s).", bundle: bundle)
let stopHint = String(localized: "cli.ssh.autoReconnect.stopHint", defaultValue: "[cmux] close this pane or press Ctrl-C to stop reconnecting.", bundle: bundle)
return "\\n\\033[33m\(status)\\033[0m\\n\\033[2m\(stopHint)\\033[0m\\n"
}
func sshManualReconnectExitPromptFormat() -> String {
let status = String(localized: "cli.ssh.manualReconnectPrompt.status", defaultValue: "[cmux] ssh exited with status %s.")
let detail = String(localized: "cli.ssh.manualReconnectPrompt.detail", defaultValue: "[cmux] the remote VM may have been paused, destroyed, or lost network.")
let prompt = String(localized: "cli.ssh.manualReconnectPrompt.prompt", defaultValue: "[cmux] press Enter to close this pane. Press r then Enter to reconnect.")
let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main
let status = String(localized: "cli.ssh.manualReconnectPrompt.status", defaultValue: "[cmux] ssh exited with status %s.", bundle: bundle)
let detail = String(localized: "cli.ssh.manualReconnectPrompt.detail", defaultValue: "[cmux] the SSH connection ended; the remote session may still be running.", bundle: bundle)
let prompt = String(localized: "cli.ssh.manualReconnectPrompt.prompt", defaultValue: "[cmux] press Enter to close this pane. Press r then Enter to reconnect.", bundle: bundle)
return "\\n\\033[31m\(status)\\033[0m\\n\\033[2m\(detail)\\033[0m\\n\\033[2m\(prompt)\\033[0m\\n"
}
func sshTerminalExitPromptFormat() -> String {
let bundle = CLIExecutableLocator.enclosingAppBundle() ?? .main
let status = String(localized: "cli.ssh.manualReconnectPrompt.status", defaultValue: "[cmux] ssh exited with status %s.", bundle: bundle)
let detail = String(localized: "cli.ssh.manualReconnectPrompt.detail", defaultValue: "[cmux] the SSH connection ended; the remote session may still be running.", bundle: bundle)
let prompt = String(localized: "cli.ssh.terminalExitPrompt.prompt", defaultValue: "[cmux] press Enter to close this pane.", bundle: bundle)
return "\\n\\033[31m\(status)\\033[0m\\n\\033[2m\(detail)\\033[0m\\n\\033[2m\(prompt)\\033[0m\\n"
}
/// Waits for a post-failure Enter without accepting queued terminal reports.
func runSSHTerminalExitPrompt(commandArgs _: [String]) {
var original = termios()
guard tcgetattr(STDIN_FILENO, &original) == 0 else {
parkSSHTerminalExitPromptAfterEOF()
}
var promptMode = original
cfmakeraw(&promptMode)
promptMode.c_lflag |= tcflag_t(ISIG)
// Changing mode and flushing are one terminal operation: no byte queued
// before this prompt boundary can later be mistaken for a fresh Enter.
guard tcsetattr(STDIN_FILENO, TCSAFLUSH, &promptMode) == 0 else {
parkSSHTerminalExitPromptAfterEOF()
}
defer { _ = tcsetattr(STDIN_FILENO, TCSANOW, &original) }
var inputFilter = SSHTerminalExitPromptInputFilter()
var buffer = [UInt8](repeating: 0, count: 256)
while true {
let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count)
if count > 0 {
if inputFilter.consume(Data(buffer.prefix(count))) {
return
}
} else if count == 0 {
parkSSHTerminalExitPromptAfterEOF()
} else if errno != EINTR {
parkSSHTerminalExitPromptAfterEOF()
}
}
}
/// Keeps a dead input bridge from dismissing the pane while remaining signal-interruptible.
private func parkSSHTerminalExitPromptAfterEOF() -> Never {
while true {
_ = Darwin.pause()
}
}
func sshRemoteReconnectShellFunction() -> String {
[
"cmux_ssh_remote_reconnect() {",
+27 -3
View File
@@ -301,7 +301,17 @@ extension CMUXCLI {
let trimmedOneTimeCommand = oneTimeCommand?.trimmingCharacters(in: .whitespacesAndNewlines)
let hasOneTimeCommand = trimmedOneTimeCommand?.isEmpty == false
let authRetryPolicy = SSHForegroundAuthenticationRetryPolicy()
let authenticationResult = authRetryPolicy.persistentAuthenticationResultShellLine(
variablePrefix: "cmux_ssh",
terminalFailureCommand: "break"
)
let backoffBuilder = SSHRetryBackoffScriptBuilder(context: .startup)
let terminalModeReset = shellQuote(SSHTerminalModeResetSequence().shellPrintfFormat)
let terminalExitPrompt = shellQuote(sshTerminalExitPromptFormat())
let terminalExitPromptCommand = [
shellQuote(resolvedExecutableURL()?.path ?? (args.first ?? "cmux")),
"__ssh-terminal-exit-prompt",
].joined(separator: " ")
var scriptLines: [String] = []
if !shellFeaturesBootstrap.isEmpty {
scriptLines.append(shellFeaturesBootstrap)
@@ -363,11 +373,13 @@ extension CMUXCLI {
] + reconnectConfiguration + [
"cmux_ssh_retry=0",
"cmux_ssh_auth_retry_limit=\(authRetryPolicy.maximumConsecutiveTransientFailures); cmux_ssh_auth_retry=0",
"cmux_ssh_auth_succeeded=0",
// Initial transient foreground-auth failures are a reconnect phase, so boot-time outages share this loop.
"cmux_ssh_reauth_required=\(hasOneTimeCommand ? 1 : 0)",
"CMUX_SSH_CHILD_PID=; CMUX_SSH_AUTH_PID=; CMUX_SSH_PENDING_SIGNAL=; CMUX_SSH_PENDING_SIGNAL_NAME=",
] + backoffBuilder.stateInitializationLines + [
"cmux_ssh_note() { if [ -t 2 ]; then printf \"$@\" >&2 || true; fi; }",
"cmux_ssh_reset_terminal_modes() { if [ -t 2 ]; then printf \(terminalModeReset) >&2 || true; fi; }",
"cmux_ssh_register_attempt() { \(lifecycleLaunching); }",
"cmux_ssh_begin_attempt() { CMUX_SSH_ATTEMPT_ID=$(/usr/bin/uuidgen | /usr/bin/tr '[:upper:]' '[:lower:]') || return 1; export CMUX_SSH_ATTEMPT_ID; cmux_ssh_attempt_registration_retry=0; while ! cmux_ssh_register_attempt; do cmux_ssh_attempt_registration_retry=$((cmux_ssh_attempt_registration_retry + 1)); if [ \"$cmux_ssh_attempt_registration_retry\" -ge 3 ]; then return 1; fi; /bin/sleep 0.1; done; }",
"cmux_ssh_session_end() { if [ \"${CMUX_SSH_SESSION_ENDED:-0}\" = 1 ]; then return; fi; CMUX_SSH_SESSION_ENDED=1; cmux_ssh_cleanup_password; \(lifecycleCleanup); }",
@@ -382,7 +394,7 @@ extension CMUXCLI {
]
if hasOneTimeCommand {
scriptLines.append(" if [ \"$cmux_ssh_reauth_required\" -eq 1 ]; then")
scriptLines += [" ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " if [ \"$cmux_ssh_status\" -eq 0 ]; then cmux_ssh_reauth_required=0; cmux_ssh_auth_retry=0; else case \"$cmux_ssh_status\" in 254) cmux_ssh_auth_retry=$((cmux_ssh_auth_retry + 1)); if [ \"$cmux_ssh_auth_retry\" -ge \"$cmux_ssh_auth_retry_limit\" ]; then cmux_ssh_status=255; break; fi ;; \(authRetryPolicy.unclassifiedFailureExitStatus)) cmux_ssh_status=255; break ;; *) break ;; esac; fi", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
scriptLines += [" ( cmux_ssh_foreground_auth ) <&0 &", " CMUX_SSH_AUTH_PID=$!; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_signal_exit \"$CMUX_SSH_PENDING_SIGNAL\" \"${CMUX_SSH_PENDING_SIGNAL_NAME:-TERM}\"; fi; wait \"$CMUX_SSH_AUTH_PID\"; cmux_ssh_status=$?; CMUX_SSH_AUTH_PID=; case \"$cmux_ssh_status\" in 129|130|143) cmux_ssh_retire_for_signal \"$cmux_ssh_status\" ;; esac; if [ -n \"${CMUX_SSH_PENDING_SIGNAL:-}\" ]; then cmux_ssh_session_end; trap - EXIT HUP INT TERM; exit \"$CMUX_SSH_PENDING_SIGNAL\"; fi", " \(authenticationResult)", " fi", " if [ \"$cmux_ssh_reauth_required\" -eq 0 ]; then"]
}
if let trimmedControlPathPreflight, !trimmedControlPathPreflight.isEmpty,
!hasOneTimeCommand {
@@ -419,6 +431,7 @@ extension CMUXCLI {
" cmux_ssh_status=$?",
" CMUX_SSH_CHILD_PID=",
" if [ \"$cmux_ssh_status\" -eq 0 ]; then break; fi",
" cmux_ssh_reset_terminal_modes",
" case \"$cmux_ssh_status\" in \(retryableStatusPattern)) ;; *) break ;; esac",
]
if retryPTYAttachStatus {
@@ -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",
" IFS= read -r _cmux_dismiss_key 2>/dev/null || true",
" \(backoffBuilder.terminalInputModeResetLine)",
" cmux_ssh_prompt_tty_state=$(/bin/stty -g <&0 2>/dev/null || true)",
" cmux_ssh_prompt_restore_tty() { if [ -n \"${cmux_ssh_prompt_tty_state:-}\" ]; then /bin/stty \"$cmux_ssh_prompt_tty_state\" <&0 2>/dev/null || true; cmux_ssh_prompt_tty_state=; fi; }",
" cmux_ssh_prompt_signal_exit() { cmux_ssh_prompt_signal_status=\"$1\"; cmux_ssh_prompt_restore_tty; trap - EXIT HUP INT TERM; exit \"$cmux_ssh_prompt_signal_status\"; }",
" trap 'cmux_ssh_prompt_restore_tty' EXIT",
" trap 'cmux_ssh_prompt_signal_exit 129' HUP",
" trap 'cmux_ssh_prompt_signal_exit 130' INT",
" trap 'cmux_ssh_prompt_signal_exit 143' TERM",
" printf \(terminalExitPrompt) \"$cmux_ssh_status\" >&2 || true",
" if [ -t 0 ]; then \(terminalExitPromptCommand) <&0; else exec \(terminalExitPromptCommand) <&0; fi",
" cmux_ssh_prompt_restore_tty",
" trap - EXIT HUP INT TERM",
"fi",
"exit $cmux_ssh_status",
]
+35 -15
View File
@@ -11,6 +11,11 @@ extension CMUXCLI {
let surfaceId: String?
}
struct ClaudeTeamsShimPlan {
let directory: URL
let managedClaudeWrapperURL: URL?
}
func tmuxCompatResolvedSocketPath(processEnvironment: [String: String]) throws -> String {
let envSocketPath = try CLISocketEnvironment.socketPath(in: processEnvironment)
let bundleIdentifier = CLISocketPathResolver.currentAppBundleIdentifier()
@@ -19,21 +24,29 @@ extension CMUXCLI {
environment: processEnvironment
)
let source: CLISocketPathSource
if let envSocketPath {
source = CLISocketPathResolver.isImplicitDefaultPath(
envSocketPath,
bundleIdentifier: bundleIdentifier,
environment: processEnvironment
) ? .implicitDefault : .environment
if envSocketPath != nil {
// Environment overrides are explicit pins. Never reinterpret a
// stable-looking value as permission to select another instance.
source = .environment
} else {
source = .implicitDefault
}
return CLISocketPathResolver.resolve(
requestedPath: requestedSocketPath,
source: source,
let resolver = CLISocketPathResolver(
environment: processEnvironment,
bundleIdentifier: bundleIdentifier
)
let resolution = resolver.resolve(
requestedPath: requestedSocketPath,
source: source
)
guard resolution.hasLiveSocket else {
throw CLIError(message: resolution.failureMessage)
}
if source == .implicitDefault,
let rerouteNotice = resolution.rerouteNotice {
cliWriteStderr(rerouteNotice + "\n")
}
return resolution.selectedPath ?? requestedSocketPath
}
func tmuxCompatLaunchContext(
@@ -199,11 +212,11 @@ extension CMUXCLI {
return environment
}
func createClaudeTeamsShimDirectory(
func createClaudeTeamsShimPlan(
processEnvironment: [String: String],
commandArgs: [String],
launchContext: TmuxCompatLaunchContext?
) throws -> URL {
) throws -> ClaudeTeamsShimPlan {
let downstreamTmuxMissing = String(
localized: "cli.tmux-compat.error.downstreamTmuxMissing",
defaultValue: "cmux tmux shim: no downstream tmux executable found"
@@ -259,7 +272,11 @@ extension CMUXCLI {
script,
to: managedRoot.appendingPathComponent("tmux", isDirectory: false)
)
return managedRoot
return ClaudeTeamsShimPlan(
directory: managedRoot,
managedClaudeWrapperURL: managedRoot
.appendingPathComponent("claude", isDirectory: false)
)
} catch {
// Informational launches do not create teammates, so they may use the
// launcher-only compatibility directory below. Real Teams sessions must
@@ -270,9 +287,12 @@ extension CMUXCLI {
guard claudeTeamsIsNonLaunchInvocation(commandArgs: commandArgs) else {
throw CLIError(message: managedTerminalRequiredMessage(displayName: "Claude Teams"))
}
return try createTmuxCompatShimDirectory(
directoryName: "claude-teams-bin",
tmuxShimScript: script
return ClaudeTeamsShimPlan(
directory: try createTmuxCompatShimDirectory(
directoryName: "claude-teams-bin",
tmuxShimScript: script
),
managedClaudeWrapperURL: nil
)
}
+37 -21
View File
@@ -1,3 +1,4 @@
import CMUXAgentLaunch
import Foundation
extension CMUXCLI {
@@ -76,26 +77,31 @@ extension CMUXCLI {
/// the pane exits before the real command runs; that is why Claude Code
/// 2.1.183 teammates never opened a split pane (issue #6447).
///
/// Every command is run through `/bin/sh -c '<command>'`, so Ghostty execs a
/// shell rather than a builtin/expression/assignment-prefix. The whole command
/// is single-quoted, so it round-trips verbatim regardless of operators or
/// quoting there is no attempt to classify which commands "need" a shell,
/// which was unreliable (tmux shell-commands can hide operators with no
/// surrounding whitespace). Commands that are already a shell invocation (e.g.
/// OMO's `/bin/sh -c ""`) are simply run through one more shell, which execs
/// straight into them.
/// Every command is run through `/bin/sh -lc '<command>'`, so Ghostty execs a
/// login shell rather than a builtin/expression/assignment-prefix. The `-l`
/// is important: Ghostty's `exec -l` only changes argv[0], and does not make
/// macOS `/bin/sh` read `/etc/profile` when it is given a non-interactive `-c`
/// command. The login shell therefore runs `path_helper` and restores the
/// user's full login PATH before the command starts (issue #10189). The whole
/// command is single-quoted, so it round-trips verbatim regardless of
/// operators or quoting there is no attempt to classify which commands
/// "need" a shell, which was unreliable (tmux shell-commands can hide
/// operators with no surrounding whitespace). Commands that are already a
/// shell invocation (e.g. OMO's `/bin/sh -c ""`) are simply run through one
/// more shell, which execs straight into them.
///
/// A POSIX shell (`/bin/sh`) is used deliberately rather than the user's
/// `$SHELL`: the commands being wrapped are POSIX `sh` syntax (Claude Code's
/// `cd && env `, and the no-command fallback `exec ${SHELL:-/bin/sh} -l`),
/// and `csh`/`tcsh` login shells cannot parse `${VAR:-default}` parameter
/// expansion or `NAME=value` command prefixes. `/bin/sh` is always present and
/// runs the bodies correctly for every user. `-l` is not passed (`/bin/sh`
/// does not take it); on macOS Ghostty already supplies a login-style argv0.
/// and `csh`/`tcsh` cannot parse `${VAR:-default}` parameter expansion or
/// `NAME=value` command prefixes. `/bin/sh` is always present and runs the
/// bodies correctly for every user; its login mode is a shell-independent way to
/// invoke macOS `path_helper` without asking the user's shell to parse a
/// POSIX command body.
func tmuxShellInvokedStartCommand(_ command: String) -> String {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return command }
return "/bin/sh -c \(tmuxShellQuote(trimmed))"
return "/bin/sh -lc \(tmuxShellQuote(trimmed))"
}
/// Like `tmuxShellInvokedStartCommand`, but first exports `prependEnv` inside
@@ -122,11 +128,14 @@ extension CMUXCLI {
///
/// Teammate panes are respawned by cmux's surface layer, not by `cmux
/// claude-teams`, so they do NOT inherit the launcher environment the lead
/// got from `configureClaudeTeamsEnvironment`. The one variable that matters
/// for startup is `CLAUDE_CODE_SANDBOXED`: Claude Code short-circuits its
/// interactive "Do you trust this folder?" gate on it, and a teammate that
/// hits that gate hangs forever (its pane opens but it never checks in
/// issue #6447). Re-supply it so teammates start the same way the lead does.
/// got from `configureClaudeTeamsEnvironment`. The launcher records a
/// replay-safe snapshot in
/// ``ClaudeTeamsRespawnEnvironmentTransport/environmentKey``;
/// re-supply that snapshot so PATH-based tools and allowlisted Claude
/// configuration match the lead without copying secrets or surface identity.
/// `CLAUDE_CODE_SANDBOXED` is handled alongside it: Claude Code short-circuits
/// its interactive "Do you trust this folder?" gate on that variable, and a
/// teammate that hits the gate hangs forever (issue #6447).
///
/// That trust gate is a real safety boundary, so it is only waived when the
/// user already opted into skipping safety prompts. The opt-in is NOT inferred
@@ -146,10 +155,17 @@ extension CMUXCLI {
/// it correctly falls back to Claude's trust prompt rather than silently bypassing
/// the trust boundary outside an explicit opt-in.
func tmuxClaudeTeamsRespawnEnvironment() -> [(key: String, value: String)] {
guard ProcessInfo.processInfo.environment["CMUX_CLAUDE_TEAMS_SANDBOXED"] == "1" else {
return []
let processEnvironment = ProcessInfo.processInfo.environment
let transport = ClaudeTeamsRespawnEnvironmentTransport()
var environment = transport.decodedEnvironment(
from: processEnvironment[ClaudeTeamsRespawnEnvironmentTransport.environmentKey]
)
if processEnvironment["CMUX_CLAUDE_TEAMS_SANDBOXED"] == "1" {
environment["CLAUDE_CODE_SANDBOXED"] = "1"
}
return environment.keys.sorted().compactMap { key in
environment[key].map { (key: key, value: $0) }
}
return [(key: "CLAUDE_CODE_SANDBOXED", value: "1")]
}
func tmuxShellWords(_ commandText: String) -> [String] {
+193 -29
View File
@@ -17,11 +17,49 @@ import Foundation
/// request and unknown / future event names default to non-actionable
/// telemetry that never notifies. Conflating a tool-start with an approval
/// is the bug behind https://github.com/manaflow-ai/cmux/issues/4985.
/// Resolved user-attention outcome for one raw agent hook event on the
/// Feed bridge path.
struct FeedEventClassification: Equatable {
/// The wire `hook_event_name` forwarded to the app via `feed.push`.
let hookEventName: String
/// Whether the Feed bridge blocks waiting for a user decision (and the
/// app may post an actionable approval card + banner).
let isActionable: Bool
/// The agent is blocked waiting for the user in its OWN approval UI
/// (e.g. Codex's approval reviewer). The feed event stays non-actionable
/// telemetry cmux must not double-prompt but the hook bridge must
/// raise the "Agent Needs Permission"-gated notification, or the blocked
/// agent is silent indefinitely.
/// https://github.com/manaflow-ai/cmux/issues/9592
let notifiesNativeApprovalPrompt: Bool
/// A tool COMPLETED for an agent whose approval prompts notify via
/// ``notifiesNativeApprovalPrompt`` execution strictly follows any
/// approval, so the prompt resolved (approved by the user or by the
/// agent's own auto-reviewer) and the bridge clears the pane's stale
/// notifications. Only tool COMPLETION qualifies: pre-tool events fire
/// when the agent intends to run a tool, with no ordering guarantee
/// against the approval-prompt hook, so clearing there could erase a
/// just-raised prompt while the agent is still blocked.
///
/// The clear is deliberately pane-wide and uncorrelated with any single
/// request: notifications carry no request identity anywhere in cmux,
/// and every agent integration clears the same way on progress signals
/// Claude's `session-start`/`prompt-submit`/`pre-tool-use` hooks, the
/// generic `.approvalResponse` action (Hermes' resolved native
/// approvals), and codex's own `prompt-submit` hook (which also clears
/// deny-without-further-tools residue at the next turn). Pane
/// notifications are attention signals; agent progress in the pane makes
/// them stale as a set.
let clearsNativeApprovalPrompt: Bool
}
struct FeedEventClassifier {
/// Classifies a raw agent hook event into our wire `hook_event_name`
/// plus an `isActionable` flag that drives whether the Feed bridge
/// blocks waiting for a user decision (and whether `FeedCoordinator`
/// posts a "needs approval" notification).
/// posts a "needs approval" notification), plus whether the bridge
/// itself must raise the permission-prompt notification for an agent
/// that blocks in its own native approval UI.
///
/// - Parameters:
/// - source: The agent id that emitted the event (`claude`, `codex`,
@@ -29,17 +67,21 @@ struct FeedEventClassifier {
/// - event: The agent's raw hook event name.
/// - toolName: The tool the event refers to, used only for the two
/// tool-dependent semantics.
/// - Returns: The wire `hook_event_name` and whether the event is
/// Feed-actionable (blocks + may notify).
static func classify(
source: String,
event: String,
toolName: String
) -> (String, Bool) {
) -> FeedEventClassification {
let semantic = feedEventSemantic(source: source, event: event)
return wireMapping(for: semantic, source: source, toolName: toolName)
}
/// Whether any of `source`'s registered events carry the
/// ``FeedEventSemantic/nativeApprovalPrompt`` semantic.
private static func sourceRaisesNativeApprovalPrompts(_ source: String) -> Bool {
feedEventSemanticRegistry[source]?.values.contains(.nativeApprovalPrompt) == true
}
/// User-attention semantic of a hook/feed event, independent of the
/// agent-specific raw event name. Notifications and blocking waits are
/// keyed off this never off raw event-name string matching so the
@@ -54,6 +96,14 @@ struct FeedEventClassifier {
/// only. Used by agents that expose a *separate* approval event
/// (Claude, Codex, Hermes) so their pre-tool hook never escalates.
case toolStart
/// The agent is blocked waiting for the user in its OWN approval
/// UI (e.g. Codex's approval reviewer). The feed event stays
/// non-actionable telemetry an actionable cmux Feed card would
/// compete with the agent's native prompt and bypass features like
/// Codex's "Approve for me" but the bridge raises the
/// "Agent Needs Permission"-gated notification so the blocked
/// agent is not silent (#9592).
case nativeApprovalPrompt
/// A tool is about to run and the agent has *no* dedicated approval
/// event, so a side-effecting tool is escalated to an approval and
/// read-only tools stay telemetry. Resolved against the tool name.
@@ -96,11 +146,11 @@ struct FeedEventClassifier {
/// Tool names that carry their own dedicated approval wire event rather
/// than the generic `PermissionRequest`. Returns the actionable wire
/// mapping for such a tool, or `nil` for ordinary tools.
private static func dedicatedApprovalEvent(for toolName: String) -> (String, Bool)? {
/// event name for such a tool, or `nil` for ordinary tools.
private static func dedicatedApprovalEvent(for toolName: String) -> String? {
switch toolName {
case "ExitPlanMode": return ("ExitPlanMode", true)
case "AskUserQuestion": return ("AskUserQuestion", true)
case "ExitPlanMode": return "ExitPlanMode"
case "AskUserQuestion": return "AskUserQuestion"
default: return nil
}
}
@@ -112,50 +162,93 @@ struct FeedEventClassifier {
for semantic: FeedEventSemantic,
source: String,
toolName: String
) -> (String, Bool) {
) -> FeedEventClassification {
switch semantic {
case .approvalRequest:
return dedicatedApprovalEvent(for: toolName) ?? ("PermissionRequest", true)
return actionable(dedicatedApprovalEvent(for: toolName) ?? "PermissionRequest")
case .toolStartMaybeApproval:
if let dedicated = dedicatedApprovalEvent(for: toolName) {
return dedicated
return actionable(dedicated)
}
// Any tool that can mutate the environment surfaces as a
// permission request so the user can approve/deny from the
// Feed sidebar. Read-only tools stay non-actionable
// telemetry so we don't flood the Actionable view.
if Self.isSideEffectingTool(toolName, source: source) {
return ("PermissionRequest", true)
return actionable("PermissionRequest")
}
return ("PreToolUse", false)
return telemetry("PreToolUse")
case .toolStart:
return ("PreToolUse", false)
// Never clears the native approval prompt: agents fire their
// pre-tool hooks when they INTEND to run a tool, with no ordering
// guarantee against the approval-prompt hook, so a start-time
// clear could erase a just-raised prompt while the agent is still
// blocked reintroducing the silence behind #9592.
return telemetry("PreToolUse")
case .nativeApprovalPrompt:
// Same telemetry wire mapping as .toolStart (no blocking, no
// actionable card), plus the permission-prompt notification.
return FeedEventClassification(
hookEventName: "PreToolUse",
isActionable: false,
notifiesNativeApprovalPrompt: true,
clearsNativeApprovalPrompt: false
)
case .toolEnd:
return ("PostToolUse", false)
// A completed tool ran, and execution strictly follows any
// approval so this is the earliest progress signal that can
// safely clear a resolved native approval prompt (approved by
// the user or by the agent's own auto-reviewer). Scoped to
// sources that raise those prompts so other agents' tool
// telemetry never touches the notification queue.
return FeedEventClassification(
hookEventName: "PostToolUse",
isActionable: false,
notifiesNativeApprovalPrompt: false,
clearsNativeApprovalPrompt: sourceRaisesNativeApprovalPrompts(source)
)
case .preCompact:
return ("PreCompact", false)
return telemetry("PreCompact")
case .postCompact:
return ("PostCompact", false)
return telemetry("PostCompact")
case .promptSubmit:
return ("UserPromptSubmit", false)
return telemetry("UserPromptSubmit")
case .subagentStart:
return ("SubagentStart", false)
return telemetry("SubagentStart")
case .response:
return ("Stop", false)
return telemetry("Stop")
case .subagentResponse:
return ("SubagentStop", false)
return telemetry("SubagentStop")
case .sessionStart:
return ("SessionStart", false)
return telemetry("SessionStart")
case .sessionEnd:
return ("SessionEnd", false)
return telemetry("SessionEnd")
case .statusNotification:
return ("Notification", false)
return telemetry("Notification")
case .unknown:
// Safe default: telemetry, no approval, no notification.
return ("PreToolUse", false)
return telemetry("PreToolUse")
}
}
private static func actionable(_ hookEventName: String) -> FeedEventClassification {
FeedEventClassification(
hookEventName: hookEventName,
isActionable: true,
notifiesNativeApprovalPrompt: false,
clearsNativeApprovalPrompt: false
)
}
private static func telemetry(_ hookEventName: String) -> FeedEventClassification {
FeedEventClassification(
hookEventName: hookEventName,
isActionable: false,
notifiesNativeApprovalPrompt: false,
clearsNativeApprovalPrompt: false
)
}
/// Per-agent event-semantic tables. Each entry is the source of truth
/// for that agent's `(event) -> semantic` mapping; events absent here
/// resolve to ``FeedEventSemantic/unknown``.
@@ -181,10 +274,12 @@ struct FeedEventClassifier {
],
"codex": [
// Codex runs PermissionRequest hooks before its own approval
// reviewer. Treat this as telemetry so "Approve for me" can still
// use Codex's auto-review path instead of blocking on cmux Feed.
"PermissionRequest": .toolStart,
"permission_request": .toolStart,
// reviewer. Keep the feed side telemetry so "Approve for me" can
// still use Codex's auto-review path instead of blocking on cmux
// Feed, but raise the permission-prompt notification: this is the
// only hook Codex fires while blocked on the user (#9592).
"PermissionRequest": .nativeApprovalPrompt,
"permission_request": .nativeApprovalPrompt,
"PreToolUse": .toolStart,
"pre_tool_use": .toolStart,
"beforeShellExecution": .toolStart,
@@ -338,6 +433,75 @@ struct FeedEventClassifier {
"generate_image",
]
/// Builds the pane-attention V1 socket command a classified feed event
/// carries the `needs-permission`-gated `notify_target_async` for a
/// native approval prompt, or the pane-scoped `clear_notifications` for
/// a resolved one. Pure so the exact wire command (UUID gating, payload
/// shape, gate meta) is unit-testable; the CLI feed hook sends the
/// returned line request/response and awaits the app's acknowledgement.
///
/// Returns `nil` when the classification carries no attention side
/// effect or when either identity is missing/not a UUID: the command is
/// advisory and must never fail the hook.
///
/// The notification body deliberately names only the TOOL mirroring
/// the in-app Feed approval banner (`feed.notification.permission.body`)
/// and never the tool input: commands can embed credentials, and
/// notification banners reach lock screens, paired phones, and the
/// recorded notification history.
static func nativeApprovalPromptAttentionCommand(
classification: FeedEventClassification,
displayName: String,
toolName: String,
workspaceId: String?,
surfaceId: String?
) -> String? {
guard classification.notifiesNativeApprovalPrompt
|| classification.clearsNativeApprovalPrompt else { return nil }
guard let workspaceRaw = workspaceId?.trimmingCharacters(in: .whitespacesAndNewlines),
let workspaceUUID = UUID(uuidString: workspaceRaw),
let surfaceRaw = surfaceId?.trimmingCharacters(in: .whitespacesAndNewlines),
let surfaceUUID = UUID(uuidString: surfaceRaw)
else { return nil }
if classification.clearsNativeApprovalPrompt {
return "clear_notifications --tab=\(workspaceUUID.uuidString) --panel=\(surfaceUUID.uuidString)"
}
let subtitle = String(
localized: "agent.generic.notification.subtitle.permission",
defaultValue: "Permission"
)
let sanitizedToolName = attentionNotificationField(toolName)
let body: String
if sanitizedToolName.isEmpty {
body = String(
localized: "agent.generic.notification.body.approvalNeeded",
defaultValue: "Approval needed"
)
} else {
body = String(
localized: "feed.notification.permission.body",
defaultValue: "\(sanitizedToolName) needs approval"
)
}
guard let meta = AgentHookNotifyCategory.needsPermission.metaSegment(pending: false) else {
return nil
}
let payload = [attentionNotificationField(displayName), attentionNotificationField(subtitle), attentionNotificationField(body)]
.joined(separator: "|") + "|" + meta
return "notify_target_async \(workspaceUUID.uuidString) \(surfaceUUID.uuidString) \(payload)"
}
/// Notification payload fields are pipe-delimited single lines; agent
/// tool names are payload-controlled input, so normalize them the same
/// way `notificationPayload` sanitizes its fields.
private static func attentionNotificationField(_ value: String) -> String {
value
.components(separatedBy: .newlines)
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "|", with: "¦")
}
/// Whether a tool mutates state and deserves an approval prompt. Exact
/// match against ``sideEffectingTools`` for every source; the `kiro`
/// source additionally matches its case-insensitive internal aliases.
+22 -209
View File
@@ -1,22 +1,13 @@
import CmuxFoundation
import Darwin
import Foundation
final class SSHPTYAttachReconnectInputFilter {
private static let escape: UInt8 = 0x1B
private static let bell: UInt8 = 0x07
private static let leftBracket: UInt8 = 0x5B
private static let rightBracket: UInt8 = 0x5D
private static let backslash: UInt8 = 0x5C
private static let semicolon: UInt8 = 0x3B
private static let questionMark: UInt8 = 0x3F
private static let dollar: UInt8 = 0x24
private static let maxPendingProbeBytes = 512
// Terminal ESC disambiguation: bounded so a literal Escape key is not held indefinitely.
private static let pendingProbeContinuationTimeoutMilliseconds: Int32 = 25
private static let reconnectProbeDeadlineMilliseconds: Int64 = 2_000
private var isFiltering: Bool
private var pending = [UInt8]()
private var byteFilter: SSHPTYReconnectInputByteFilter
private let deadlineReached: (@Sendable () -> Bool)?
private let remainingDeadline: (@Sendable () -> Int64?)?
@@ -25,14 +16,13 @@ final class SSHPTYAttachReconnectInputFilter {
deadlineReached: (@Sendable () -> Bool)? = nil,
remainingDeadlineMilliseconds: (@Sendable () -> Int64?)? = nil
) {
isFiltering = enabled
byteFilter = SSHPTYReconnectInputByteFilter(enabled: enabled)
self.deadlineReached = deadlineReached
remainingDeadline = remainingDeadlineMilliseconds
}
private init(state: SSHPTYAttachReconnectInputFilterState) {
isFiltering = state.isFiltering
pending = state.pending
byteFilter = SSHPTYReconnectInputByteFilter(enabled: state.isFiltering)
deadlineReached = state.deadlineReached
remainingDeadline = state.remainingDeadlineMilliseconds
}
@@ -52,7 +42,6 @@ final class SSHPTYAttachReconnectInputFilter {
let filterState = filterEnabled
? SSHPTYAttachReconnectInputFilterState(
isFiltering: true,
pending: [],
deadlineReached: { deadline.map { now() >= $0 } ?? false },
remainingDeadlineMilliseconds: {
guard let deadline else { return nil }
@@ -180,10 +169,10 @@ final class SSHPTYAttachReconnectInputFilter {
return true
}
func flushPendingThenShutdown() async {
if let filter = reconnectInputFilter, filter.hasPendingInput {
_ = await writeOrShutdown(filter.flushPendingInput())
}
func finishStdin() {
// Reconnect input can disappear with the old bridge during wake.
// It is not an intentional EOF for the newly attached remote PTY.
guard reconnectInputFilter == nil else { return }
_ = shutdown(fd, SHUT_WR)
}
@@ -210,7 +199,7 @@ final class SSHPTYAttachReconnectInputFilter {
stopSignalFD: stopSignalFD,
timeoutMilliseconds: timeoutMilliseconds
) else {
await flushPendingThenShutdown()
finishStdin()
return
}
@@ -222,13 +211,13 @@ final class SSHPTYAttachReconnectInputFilter {
stopSignalFD: nil,
timeoutMilliseconds: pendingProbeContinuationTimeoutMilliseconds
) else {
await flushPendingThenShutdown()
finishStdin()
return
}
if pendingReadiness.inputReady {
readiness = (inputReady: true, stopRequested: true)
} else if let filter = reconnectInputFilter {
guard await writeOrShutdown(filter.flushPendingInput()) else { return }
guard await writeOrShutdown(filter.stopFiltering()) else { return }
guard stopReconnectFiltering() else { return }
continue
}
@@ -244,7 +233,7 @@ final class SSHPTYAttachReconnectInputFilter {
if !readiness.inputReady {
if let filter = reconnectInputFilter,
filter.hasPendingInput {
guard await writeOrShutdown(filter.flushPendingInput()) else { return }
guard await writeOrShutdown(filter.stopFiltering()) else { return }
}
continue
}
@@ -273,227 +262,51 @@ final class SSHPTYAttachReconnectInputFilter {
}
}
} else if count == 0 {
await flushPendingThenShutdown()
finishStdin()
return
} else if errno != EINTR {
await flushPendingThenShutdown()
finishStdin()
return
}
}
}
func filter(_ data: Data) -> Data {
guard isFiltering, !data.isEmpty else {
return data
}
if isDeadlineReached {
var output = stopFiltering()
output.append(data)
return output
}
var bytes = pending
pending.removeAll(keepingCapacity: true)
bytes.append(contentsOf: data)
var output = Data()
var index = 0
while index < bytes.count {
guard bytes[index] == Self.escape else {
isFiltering = false
output.append(contentsOf: bytes[index...])
return output
}
switch Self.reconnectProbeReplySequence(in: bytes, at: index) {
case .strip(let length):
index += length
case .incomplete:
let suffix = bytes[index...]
guard suffix.count <= Self.maxPendingProbeBytes else {
isFiltering = false
output.append(contentsOf: suffix)
return output
}
pending.append(contentsOf: suffix)
return output
case .passThrough:
isFiltering = false
output.append(contentsOf: bytes[index...])
return output
}
}
return output
return byteFilter.filter(data)
}
func finish() -> Data {
guard !pending.isEmpty else {
return Data()
}
let data = Data(pending)
pending.removeAll(keepingCapacity: false)
return data
byteFilter.finish()
}
func stopFiltering() -> Data {
let input = finish()
isFiltering = false
return input
byteFilter.stopFiltering()
}
var hasPendingInput: Bool {
isFiltering && !pending.isEmpty
byteFilter.hasPendingInput
}
var isFilteringAtProbeBoundary: Bool {
isFiltering && pending.isEmpty
byteFilter.isFilteringAtProbeBoundary
}
var isFilteringActive: Bool {
isFiltering
byteFilter.isFilteringActive
}
var isDeadlineReached: Bool {
isFiltering && (deadlineReached?() == true)
byteFilter.isFilteringActive && (deadlineReached?() == true)
}
var remainingDeadlineMilliseconds: Int64? {
guard isFiltering else { return nil }
guard byteFilter.isFilteringActive else { return nil }
return remainingDeadline?()
}
func flushPendingInput() -> Data {
guard hasPendingInput else {
return Data()
}
let data = Data(pending)
pending.removeAll(keepingCapacity: true)
isFiltering = false
return data
}
private static func reconnectProbeReplySequence(
in bytes: [UInt8],
at start: Int
) -> SSHPTYAttachReconnectInputFilterSequenceMatch {
guard start < bytes.count, bytes[start] == escape else {
return .passThrough
}
guard start + 1 < bytes.count else {
// read() can split immediately after ESC; wait for one more byte before deciding.
return .incomplete
}
switch bytes[start + 1] {
case rightBracket:
return oscColorReplySequence(in: bytes, at: start)
case leftBracket:
return csiProbeReplySequence(in: bytes, at: start)
default:
return .passThrough
}
}
private static func oscColorReplySequence(
in bytes: [UInt8],
at start: Int
) -> SSHPTYAttachReconnectInputFilterSequenceMatch {
var cursor = start + 2
var command = [UInt8]()
while cursor < bytes.count {
let byte = bytes[cursor]
if byte == semicolon {
break
}
if byte < 0x30 || byte > 0x39 || command.count >= 2 {
return .passThrough
}
command.append(byte)
cursor += 1
}
guard cursor < bytes.count else {
return isOSCColorReplyCommandPrefix(command) ? .incomplete : .passThrough
}
guard bytes[cursor] == semicolon else {
return .passThrough
}
guard command == [0x31, 0x30] || command == [0x31, 0x31] || command == [0x31, 0x32] else {
return .passThrough
}
cursor += 1
while cursor < bytes.count {
let byte = bytes[cursor]
if byte == bell {
return .strip(length: cursor - start + 1)
}
if byte == escape {
guard cursor + 1 < bytes.count else {
return .incomplete
}
if bytes[cursor + 1] == backslash {
return .strip(length: cursor - start + 2)
}
}
cursor += 1
}
return .incomplete
}
private static func csiProbeReplySequence(
in bytes: [UInt8],
at start: Int
) -> SSHPTYAttachReconnectInputFilterSequenceMatch {
var cursor = start + 2
while cursor < bytes.count {
let byte = bytes[cursor]
if byte >= 0x40, byte <= 0x7E {
return shouldStripCSIReply(bytes: bytes, bodyStart: start + 2, finalIndex: cursor)
? .strip(length: cursor - start + 1)
: .passThrough
}
guard byte >= 0x20, byte <= 0x3F else {
return .passThrough
}
cursor += 1
}
return .incomplete
}
private static func isOSCColorReplyCommandPrefix(_ command: [UInt8]) -> Bool {
command.isEmpty ||
command == [0x31] ||
command == [0x31, 0x30] ||
command == [0x31, 0x31] ||
command == [0x31, 0x32]
}
private static func shouldStripCSIReply(bytes: [UInt8], bodyStart: Int, finalIndex: Int) -> Bool {
var parameterEnd = bodyStart
while parameterEnd < finalIndex, bytes[parameterEnd] >= 0x30, bytes[parameterEnd] <= 0x3F {
parameterEnd += 1
}
guard bytes[parameterEnd..<finalIndex].allSatisfy({ $0 >= 0x20 && $0 <= 0x2F }) else {
return false
}
let parameters = bytes[bodyStart..<parameterEnd]
let intermediates = bytes[parameterEnd..<finalIndex]
let final = bytes[finalIndex]
switch final {
case 0x52, 0x63, 0x6E:
return intermediates.isEmpty
case 0x75:
return intermediates.isEmpty && parameters.first == questionMark
case 0x79:
return intermediates.elementsEqual([dollar])
default:
return false
}
}
}
@@ -1,6 +1,5 @@
struct SSHPTYAttachReconnectInputFilterState: Sendable {
let isFiltering: Bool
let pending: [UInt8]
let deadlineReached: @Sendable () -> Bool
let remainingDeadlineMilliseconds: @Sendable () -> Int64?
}
+65
View File
@@ -0,0 +1,65 @@
import CmuxControlSocket
import Foundation
extension SocketClient {
static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient {
try waitForConnectableSocket(resolvePath: { path }, timeout: timeout)
}
/// Waits for a socket selected by `resolvePath` to become connectable.
///
/// ``SocketStartupWaiter`` owns the shared deadline, path re-resolution,
/// vnode wakeups, and backoff. This adapter owns CLI connection creation and
/// classifies transport failures so permanent path conflicts surface
/// immediately while startup races remain retryable.
static func waitForConnectableSocket(
resolvePath: () -> String,
timeout: TimeInterval
) throws -> SocketClient {
do {
return try SocketStartupWaiter().wait(
timeout: timeout,
resolvePath: resolvePath
) { path, remainingTime in
let client = SocketClient(path: path)
do {
// Use the remaining total budget as the connect deadline so
// a full listen backlog cannot extend the bounded startup wait.
try client.connectWithoutRetry(
responseTimeout: max(remainingTime, 0.001)
)
if client.isRelayBacked {
client.close()
}
return client
} catch {
client.close()
guard shouldRetrySocketStartup(error) else {
throw error
}
return nil
}
}
} catch let startupTimeout as SocketStartupWaitTimeout {
throw startupSocketTimeout(path: startupTimeout.path)
}
}
static func isSocketStartupTimeout(_ error: Error) -> Bool {
(error as? CLIError)?.socketFailureKind == .startupTimeout
}
private static func shouldRetrySocketStartup(_ error: Error) -> Bool {
if shouldRetryConnect(error) {
return true
}
return (error as? CLIError)?.socketFailureKind == .pathMissing
}
private static func startupSocketTimeout(path: String) -> CLIError {
CLIError(
message: "cmux app did not start in time (socket not found at \(path))",
socketFailureKind: .startupTimeout
)
}
}
+1009 -346
View File
File diff suppressed because it is too large Load Diff
+29 -5
View File
@@ -9,10 +9,14 @@ This is a standalone macOS app that embeds a CMUX sidebar ExtensionKit app exten
3. Replace the Manaflow signing team with your own team.
4. Replace the app and extension bundle identifiers with your own reverse-DNS identifiers.
5. Keep the extension point identifier as `com.cmuxterm.app.cmux.sidebar`.
6. Build and launch the containing app once.
7. In CMUX, click the puzzle button next to the sidebar help button, open Sidebar Extensions, and enable the sample.
8. In the same puzzle menu, choose the extension sidebar provider.
9. In the extension sidebar header, choose `CMUX ExtKit Sample Sidebar` if more than one sidebar extension is enabled.
6. Keep App Sandbox enabled for the extension target. An unsandboxed appex can
build, embed, and sign without registering with ExtensionKit.
7. Build and launch the containing app once.
8. In CMUX, click the puzzle button next to the sidebar help button, open Sidebar
Extensions, and enable the sample.
9. In the same puzzle menu, choose the extension sidebar provider.
10. In the extension sidebar header, choose `CMUX ExtKit Sample Sidebar` if more
than one sidebar extension is enabled.
The sample targets macOS 14+, matching CMUX.
@@ -90,9 +94,29 @@ authors do not define `configuration`, bind an extension point in Swift, or touc
The manifest is the permission request CMUX shows to users. Request only the scopes
your sidebar actually needs.
## Running External Tools
CMUX read scopes can supply a workspace path, but they do not grant filesystem
access to that path. A compiled extension that launches `git` or another
external process must also satisfy the macOS App Sandbox, executable resolution,
working-directory, and file-privacy rules. Follow the SDK's
[Running external tools](../../Packages/macOS/CmuxExtensionKit/README.md#running-external-tools)
guide before adding `Process` code.
## Troubleshooting
If the extension does not appear in CMUX, launch the containing app once, then reopen CMUX's Sidebar Extensions browser.
If the extension does not appear in CMUX, confirm that App Sandbox is enabled for
the extension target, launch the containing app once, then reopen CMUX's Sidebar
Extensions browser. You can also check system discovery directly:
```sh
pluginkit -mAvvv
```
The normal CMUX release uses `com.cmuxterm.app.cmux.sidebar`. Tagged development
builds use the point stored in the host's `CMUXSidebarExtensionPointIdentifier`
Info.plist key (currently `<host-bundle-id>.cmux.sidebar`), so do not filter on
the production point when diagnosing a tagged or custom host.
If it appears but cannot be enabled, check signing on both the containing app and the embedded appex.
+17
View File
@@ -0,0 +1,17 @@
import UIKit
@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions
) -> UISceneConfiguration {
let configuration = UISceneConfiguration(
name: "Default Configuration",
sessionRole: connectingSceneSession.role
)
configuration.delegateClass = SceneDelegate.self
return configuration
}
}
@@ -0,0 +1,19 @@
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = WorkspaceDetailViewController()
window.overrideUserInterfaceStyle = .dark
window.makeKeyAndVisible()
self.window = window
}
}
@@ -0,0 +1,354 @@
import OSLog
import UIKit
@MainActor
final class WorkspaceDetailViewController: UIViewController {
private let logger = Logger(subsystem: "ai.manaflow.KeyboardPinningLab", category: "Keyboard")
private let terminalView = TerminalCanvasView()
private let dockView = ComposerDockView()
private let headerView = WorkspaceHeaderView()
private var stressTask: Task<Void, Never>?
override func viewDidLoad() {
super.viewDidLoad()
configureHierarchy()
configureKeyboardPinning()
configureActions()
observeKeyboardFrames()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
dockView.focusComposer()
if UserDefaults.standard.bool(forKey: "stressKeyboard") {
runStressSequence()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
stressTask?.cancel()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let guideTop = view.keyboardLayoutGuide.layoutFrame.minY
let dockBottom = dockView.frame.maxY
let gap = guideTop - dockBottom
headerView.updatePinGap(gap)
}
private func configureHierarchy() {
view.backgroundColor = UIColor(red: 0.075, green: 0.078, blue: 0.082, alpha: 1)
[headerView, terminalView, dockView].forEach {
$0.translatesAutoresizingMaskIntoConstraints = false
view.addSubview($0)
}
NSLayoutConstraint.activate([
headerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
headerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
headerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
headerView.heightAnchor.constraint(equalToConstant: 60),
terminalView.topAnchor.constraint(equalTo: headerView.bottomAnchor),
terminalView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
terminalView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
terminalView.bottomAnchor.constraint(equalTo: dockView.topAnchor),
dockView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
dockView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
}
private func configureKeyboardPinning() {
let keyboardGuide = view.keyboardLayoutGuide
keyboardGuide.followsUndockedKeyboard = true
// The dock and keyboard share one UIKit constraint graph. UIKit owns the
// keyboard's presentation frame and interruptible animation, so no copied
// keyboard height or separately-timed animation can diverge.
dockView.bottomAnchor.constraint(equalTo: keyboardGuide.topAnchor).isActive = true
}
private func configureActions() {
terminalView.onTap = { [weak self] in
self?.dockView.focusComposer()
}
dockView.onKeyboardToggle = { [weak self] in
self?.toggleKeyboard()
}
headerView.onStress = { [weak self] in
self?.runStressSequence()
}
}
private func observeKeyboardFrames() {
NotificationCenter.default.addObserver(
forName: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let self,
let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
self.logger.debug("Keyboard target minY: \(frame.minY, format: .fixed(precision: 1))")
}
}
private func toggleKeyboard() {
if dockView.isComposerFocused {
dockView.dismissComposer()
} else {
dockView.focusComposer()
}
}
private func runStressSequence() {
stressTask?.cancel()
stressTask = Task { [weak self] in
guard let self else { return }
for _ in 0..<20 {
guard !Task.isCancelled else { return }
self.toggleKeyboard()
try? await Task.sleep(for: .milliseconds(135))
}
guard !Task.isCancelled else { return }
self.dockView.focusComposer()
}
}
}
private final class WorkspaceHeaderView: UIView {
var onStress: (() -> Void)?
private let statusLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(red: 0.075, green: 0.078, blue: 0.082, alpha: 0.98)
let backButton = Self.symbolButton("chevron.left")
let titleLabel = UILabel()
titleLabel.text = String(localized: "workspace.title", defaultValue: "cmux DEV lab")
titleLabel.font = .preferredFont(forTextStyle: .headline)
titleLabel.textColor = .white
let terminalButton = Self.symbolButton("rectangle.on.rectangle")
let stressButton = Self.symbolButton("arrow.trianglehead.2.clockwise.rotate.90")
stressButton.accessibilityIdentifier = "stressKeyboard"
stressButton.accessibilityLabel = String(localized: "stress.accessibility", defaultValue: "Rapidly toggle keyboard 20 times")
stressButton.addAction(UIAction { [weak self] _ in self?.onStress?() }, for: .touchUpInside)
statusLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
statusLabel.textColor = UIColor(red: 0.31, green: 0.84, blue: 0.53, alpha: 1)
statusLabel.textAlignment = .center
statusLabel.accessibilityIdentifier = "pinGapStatus"
let row = UIStackView(arrangedSubviews: [backButton, titleLabel, UIView(), statusLabel, stressButton, terminalButton])
row.axis = .horizontal
row.alignment = .center
row.spacing = 10
row.translatesAutoresizingMaskIntoConstraints = false
addSubview(row)
NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
row.centerYAnchor.constraint(equalTo: centerYAnchor),
statusLabel.widthAnchor.constraint(equalToConstant: 105),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { nil }
func updatePinGap(_ gap: CGFloat) {
let clamped = abs(gap) < 0.05 ? 0 : gap
let formattedGap = Double(clamped).formatted(.number.precision(.fractionLength(1)))
statusLabel.text = String(localized: "PIN GAP \(formattedGap) pt")
statusLabel.textColor = abs(clamped) < 0.1 ? UIColor(red: 0.31, green: 0.84, blue: 0.53, alpha: 1) : .systemRed
}
private static func symbolButton(_ symbol: String) -> UIButton {
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(systemName: symbol)
configuration.baseForegroundColor = .white
return UIButton(configuration: configuration)
}
}
private final class TerminalCanvasView: UIView {
var onTap: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(red: 0.105, green: 0.108, blue: 0.112, alpha: 1)
isAccessibilityElement = true
accessibilityIdentifier = "terminalCanvas"
accessibilityLabel = String(localized: "terminal.accessibility", defaultValue: "Terminal. Tap to show keyboard.")
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
addGestureRecognizer(tapGesture)
let terminalText = UILabel()
terminalText.translatesAutoresizingMaskIntoConstraints = false
terminalText.numberOfLines = 0
terminalText.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
terminalText.textColor = UIColor(white: 0.79, alpha: 1)
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")
addSubview(terminalText)
NSLayoutConstraint.activate([
terminalText.topAnchor.constraint(equalTo: topAnchor, constant: 14),
terminalText.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
terminalText.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -12),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { nil }
@objc private func handleTap() {
onTap?()
}
}
private final class ComposerDockView: UIView, UITextFieldDelegate {
var onKeyboardToggle: (() -> Void)?
private let textField = UITextField()
var isComposerFocused: Bool { textField.isFirstResponder }
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(red: 0.075, green: 0.078, blue: 0.082, alpha: 0.99)
let shortcuts = makeShortcutBar()
let composer = makeComposerBar()
let stack = UIStackView(arrangedSubviews: [shortcuts, composer])
stack.axis = .vertical
stack.spacing = 4
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: topAnchor, constant: 6),
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6),
shortcuts.heightAnchor.constraint(equalToConstant: 36),
composer.heightAnchor.constraint(equalToConstant: 42),
])
}
@available(*, unavailable)
required init?(coder: NSCoder) { nil }
func focusComposer() {
textField.becomeFirstResponder()
}
func dismissComposer() {
textField.resignFirstResponder()
}
private func makeShortcutBar() -> UIView {
let specs: [(String, String)] = [
("keyboard", "keyboard.toggle"),
("circle.fill", "shortcut.control"),
("square.and.pencil", "shortcut.command"),
("chevron.up", "shortcut.up"),
("option", "shortcut.option"),
("command", "shortcut.command"),
("doc.on.clipboard", "shortcut.paste"),
]
let buttons = specs.map { symbol, identifier in
var configuration = UIButton.Configuration.plain()
configuration.image = UIImage(systemName: symbol)
configuration.baseForegroundColor = .white
configuration.contentInsets = .zero
let button = UIButton(configuration: configuration)
button.accessibilityIdentifier = identifier
if identifier == "keyboard.toggle" {
button.addAction(UIAction { [weak self] _ in self?.onKeyboardToggle?() }, for: .touchUpInside)
}
return button
}
var tabConfiguration = UIButton.Configuration.filled()
tabConfiguration.title = String(localized: "shortcut.tab", defaultValue: "Tab")
tabConfiguration.baseBackgroundColor = UIColor(white: 0.18, alpha: 1)
tabConfiguration.baseForegroundColor = .white
tabConfiguration.cornerStyle = .capsule
tabConfiguration.contentInsets = .init(top: 0, leading: 2, bottom: 0, trailing: 2)
tabConfiguration.titleLineBreakMode = .byClipping
tabConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in
var attributes = attributes
attributes.font = .systemFont(ofSize: 11, weight: .medium)
return attributes
}
let tabButton = UIButton(configuration: tabConfiguration)
var escapeConfiguration = tabConfiguration
escapeConfiguration.title = String(localized: "shortcut.escape", defaultValue: "Esc")
let escapeButton = UIButton(configuration: escapeConfiguration)
let stack = UIStackView(arrangedSubviews: buttons + [tabButton, escapeButton])
stack.axis = .horizontal
stack.alignment = .fill
stack.distribution = .fillEqually
stack.spacing = 4
return stack
}
private func makeComposerBar() -> UIView {
let attachment = Self.circleButton(symbol: "paperclip")
let microphone = Self.circleButton(symbol: "mic")
let send = Self.circleButton(symbol: "arrow.up", filled: true)
textField.delegate = self
textField.placeholder = String(localized: "composer.placeholder", defaultValue: "Message")
textField.textColor = .white
textField.tintColor = .white
textField.font = .preferredFont(forTextStyle: .body)
textField.returnKeyType = .send
textField.autocorrectionType = .no
textField.accessibilityIdentifier = "composerTextField"
let fieldContainer = UIView()
fieldContainer.backgroundColor = UIColor(white: 0.12, alpha: 1)
fieldContainer.layer.cornerRadius = 17
textField.translatesAutoresizingMaskIntoConstraints = false
fieldContainer.addSubview(textField)
NSLayoutConstraint.activate([
textField.leadingAnchor.constraint(equalTo: fieldContainer.leadingAnchor, constant: 12),
textField.trailingAnchor.constraint(equalTo: fieldContainer.trailingAnchor, constant: -8),
textField.topAnchor.constraint(equalTo: fieldContainer.topAnchor),
textField.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor),
])
let row = UIStackView(arrangedSubviews: [attachment, microphone, fieldContainer, send])
row.axis = .horizontal
row.alignment = .fill
row.spacing = 6
return row
}
private static func circleButton(symbol: String, filled: Bool = false) -> UIButton {
var configuration = filled ? UIButton.Configuration.filled() : UIButton.Configuration.plain()
configuration.image = UIImage(systemName: symbol)
configuration.baseForegroundColor = filled ? .black : .white
configuration.baseBackgroundColor = filled ? UIColor(white: 0.85, alpha: 1) : .clear
configuration.cornerStyle = .capsule
configuration.contentInsets = .zero
return UIButton(configuration: configuration)
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.text = nil
return false
}
}
+11
View File
@@ -0,0 +1,11 @@
# Verification
Source reference: `ScreenRecording_08-07-2026 19-50-56_1.MP4`, 1320×2868, 60 fps, 51.62 seconds.
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.
@@ -0,0 +1,351 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
1E81F0EEF337DE057CCEC892 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = CE0E17A1008C3577E9CD4995 /* InfoPlist.strings */; };
7BCC8A0872603104E124D03E /* WorkspaceDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12AF82959FE7DAAA5C3ADBEE /* WorkspaceDetailViewController.swift */; };
8F1F331AA4DD22756ED437F4 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 949EB7D848CCC51B60D11989 /* Localizable.xcstrings */; };
DE8D30BAEC473E45B80219A5 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2728F872D5FB8D33A41A6E91 /* SceneDelegate.swift */; };
EFCF59C7ED8878A5FAA7DDF5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4444F6FE9025CD71B41223BE /* AppDelegate.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
12AF82959FE7DAAA5C3ADBEE /* WorkspaceDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceDetailViewController.swift; sourceTree = "<group>"; };
2728F872D5FB8D33A41A6E91 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
2C03363774959A68994988EB /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = "<group>"; };
4444F6FE9025CD71B41223BE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
5405DD1047FED983298B8CD3 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
949EB7D848CCC51B60D11989 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
D4959730D56E1DB02C931108 /* KeyboardPinningLab.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KeyboardPinningLab.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0CA61C38419BE31AC5D5455E /* App */ = {
isa = PBXGroup;
children = (
4444F6FE9025CD71B41223BE /* AppDelegate.swift */,
2728F872D5FB8D33A41A6E91 /* SceneDelegate.swift */,
12AF82959FE7DAAA5C3ADBEE /* WorkspaceDetailViewController.swift */,
);
path = App;
sourceTree = "<group>";
};
CD6A0D068D8B8C2D583490C8 = {
isa = PBXGroup;
children = (
0CA61C38419BE31AC5D5455E /* App */,
E0F87851FC78526801069DE5 /* Resources */,
E6422832B55FB3758A111DC2 /* Products */,
);
sourceTree = "<group>";
};
E0F87851FC78526801069DE5 /* Resources */ = {
isa = PBXGroup;
children = (
949EB7D848CCC51B60D11989 /* Localizable.xcstrings */,
CE0E17A1008C3577E9CD4995 /* InfoPlist.strings */,
);
path = Resources;
sourceTree = "<group>";
};
E6422832B55FB3758A111DC2 /* Products */ = {
isa = PBXGroup;
children = (
D4959730D56E1DB02C931108 /* KeyboardPinningLab.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
27AE499E18794152BA4672C0 /* KeyboardPinningLab */ = {
isa = PBXNativeTarget;
buildConfigurationList = 76AA4EB0FA9193B6C03BEA00 /* Build configuration list for PBXNativeTarget "KeyboardPinningLab" */;
buildPhases = (
668F9A8ACF9F66A71F3435F3 /* Sources */,
AF47256CFA451165B9D69F33 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = KeyboardPinningLab;
packageProductDependencies = (
);
productName = KeyboardPinningLab;
productReference = D4959730D56E1DB02C931108 /* KeyboardPinningLab.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
58E3B7797EDEB766152AADC7 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
27AE499E18794152BA4672C0 = {
DevelopmentTeam = 7WLXT3NR37;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 6AE1CD5DF1B492DCA42ECF57 /* Build configuration list for PBXProject "KeyboardPinningLab" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
ja,
);
mainGroup = CD6A0D068D8B8C2D583490C8;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = E6422832B55FB3758A111DC2 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
27AE499E18794152BA4672C0 /* KeyboardPinningLab */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
AF47256CFA451165B9D69F33 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1E81F0EEF337DE057CCEC892 /* InfoPlist.strings in Resources */,
8F1F331AA4DD22756ED437F4 /* Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
668F9A8ACF9F66A71F3435F3 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EFCF59C7ED8878A5FAA7DDF5 /* AppDelegate.swift in Sources */,
DE8D30BAEC473E45B80219A5 /* SceneDelegate.swift in Sources */,
7BCC8A0872603104E124D03E /* WorkspaceDetailViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXVariantGroup section */
CE0E17A1008C3577E9CD4995 /* InfoPlist.strings */ = {
isa = PBXVariantGroup;
children = (
5405DD1047FED983298B8CD3 /* en */,
2C03363774959A68994988EB /* ja */,
);
name = InfoPlist.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
0E30ABD0849A82BD34D9BF72 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 7WLXT3NR37;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Release;
};
C0E137783EBF25A0C576D57F /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pinning Lab";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = ai.manaflow.KeyboardPinningLab;
PRODUCT_NAME = "Keyboard Pinning Lab";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
DF22FCDD84F72C666DC4079C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Pinning Lab";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = ai.manaflow.KeyboardPinningLab;
PRODUCT_NAME = "Keyboard Pinning Lab";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
EE990F81439C9DDADF595351 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 7WLXT3NR37;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
SWIFT_VERSION = 6.0;
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
6AE1CD5DF1B492DCA42ECF57 /* Build configuration list for PBXProject "KeyboardPinningLab" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EE990F81439C9DDADF595351 /* Debug */,
0E30ABD0849A82BD34D9BF72 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
76AA4EB0FA9193B6C03BEA00 /* Build configuration list for PBXNativeTarget "KeyboardPinningLab" */ = {
isa = XCConfigurationList;
buildConfigurations = (
DF22FCDD84F72C666DC4079C /* Debug */,
C0E137783EBF25A0C576D57F /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 58E3B7797EDEB766152AADC7 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "27AE499E18794152BA4672C0"
BuildableName = "KeyboardPinningLab.app"
BlueprintName = "KeyboardPinningLab"
ReferencedContainer = "container:KeyboardPinningLab.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+11
View File
@@ -0,0 +1,11 @@
# Keyboard Pinning Lab
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.
@@ -0,0 +1,54 @@
{
"sourceLanguage" : "en",
"strings" : {
"composer.placeholder" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Message" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "メッセージ" } }
}
},
"PIN GAP %@ pt" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "PIN GAP %1$@ pt" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "固定間隔 %1$@ pt" } }
}
},
"shortcut.escape" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Esc" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "Esc" } }
}
},
"stress.accessibility" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Rapidly toggle keyboard 20 times" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "キーボードを20回すばやく切り替える" } }
}
},
"shortcut.tab" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Tab" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "Tab" } }
}
},
"terminal.sample" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Last login: Fri Aug 7 19:45:12 on ttys006\n\n~/cmux git:(feat/keyboard-pinning-lab)\n" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "最終ログイン: 8月7日 金 19:45:12 ttys006\n\n~/cmux git:(feat/keyboard-pinning-lab)\n" } }
}
},
"terminal.accessibility" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Terminal. Tap to show keyboard." } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "ターミナル。タップしてキーボードを表示します。" } }
}
},
"workspace.title" : {
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "cmux DEV lab" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "cmux DEV ラボ" } }
}
}
},
"version" : "1.0"
}
@@ -0,0 +1 @@
"CFBundleDisplayName" = "Pinning Lab";
@@ -0,0 +1 @@
"CFBundleDisplayName" = "ピン留めラボ";
+32
View File
@@ -0,0 +1,32 @@
name: KeyboardPinningLab
options:
bundleIdPrefix: ai.manaflow
deploymentTarget:
iOS: "17.0"
generateEmptyDirectories: true
settings:
base:
DEVELOPMENT_TEAM: 7WLXT3NR37
SWIFT_VERSION: 6.0
SWIFT_STRICT_CONCURRENCY: complete
targets:
KeyboardPinningLab:
type: application
platform: iOS
sources:
- path: App
- path: Resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ai.manaflow.KeyboardPinningLab
PRODUCT_NAME: Keyboard Pinning Lab
INFOPLIST_KEY_CFBundleDisplayName: Pinning Lab
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: true
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: true
INFOPLIST_KEY_UILaunchScreen_Generation: true
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
TARGETED_DEVICE_FAMILY: 1
GENERATE_INFOPLIST_FILE: true
CODE_SIGN_STYLE: Automatic
scheme:
testTargets: []
@@ -0,0 +1,591 @@
public import Foundation
/// The consolidated on-disk application log: one active file with bounded
/// archives for app-wide events and one equivalent set for network diagnostics.
///
/// ``AppLog`` is the durable half of the diagnostics stack. The in-memory
/// ``DiagnosticLog`` ring stays the single structured spine every subsystem
/// records into; the composition root taps that ring into an ``AppLog``, which
/// renders each event through ``DiagnosticEventPresentation`` and appends it to
/// one of two files:
///
/// - the **app log** (``appLogFileName``): every event that is not
/// network-plane simulator streaming/control, browser streaming, composer,
/// render plus mirrored string debug-log lines, so one file tells the whole
/// in-app story in wall-clock order;
/// - the **network log** (``networkLogFileName``): transport dials, discovery,
/// relay policy, path changes, session lifecycle and close attribution.
///
/// Cross-cutting context (app lifecycle, reachability) is written to both so
/// each file is self-sufficient. Diagnostic events are integer-encoded and
/// privacy-safe by construction, so persistence is always on, including
/// Release; the free-text mirror keeps the string log's own gating (DEBUG
/// always, Release behind the verbose opt-in) because those lines are not
/// structurally scrubbed.
///
/// Ordering: both entry points are non-blocking and feed one buffered stream
/// drained by a single internal task, so lines land on disk in admission
/// order. Consecutive frame-pipeline events for the same panel and stage are
/// coalesced into a `repeated ×N` summary when the run breaks, so a healthy
/// 20 fps stream costs one line plus one summary instead of megabytes.
///
/// The active generation is reopened for appending on the next launch. When
/// the byte budget is reached it is moved to a timestamped archive, then a
/// fresh active generation is opened. Archives are bounded by both count and
/// total bytes, so retention never requires clearing the app container.
///
/// Inject one instance from the app composition root; do not add a `.shared`
/// singleton.
public actor AppLog {
/// Which on-disk file an event belongs to.
public enum Domain: Sendable, Equatable {
case app
case network
case both
}
public static let appLogFileName = "cmux-app.log"
public static let networkLogFileName = "cmux-network.log"
/// The approximate size of one active generation in production.
public static let defaultMaxFileBytes = 5_000_000
/// Number of timestamped generations retained in addition to the active
/// file. A legacy `.1` file is migrated before this limit is applied.
public static let defaultMaxArchiveCount = 3
/// Per-file retention ceiling, including the active generation.
public static let defaultMaxRetainedBytes = 12_000_000
/// Default location of the app-wide log inside Application Support, or
/// `nil` when the directory cannot be resolved. Exists so settings UI can
/// offer the file for sharing without holding the ``AppLog`` instance.
public static var defaultAppLogFileURL: URL? {
defaultFileURL(named: appLogFileName)
}
/// Default location of the network diagnostics log. See
/// ``defaultAppLogFileURL``.
public static var defaultNetworkLogFileURL: URL? {
defaultFileURL(named: networkLogFileName)
}
/// All available generations for the app log, newest first after the
/// active file. Settings passes this collection to the share UI so a
/// diagnostic export includes the bounded history, not only the active
/// generation.
public static var appLogFileURLs: [URL] {
guard let url = defaultAppLogFileURL else { return [] }
return logFileURLs(for: url)
}
/// All available generations for the network log, with the active file
/// first. See ``appLogFileURLs``.
public static var networkLogFileURLs: [URL] {
guard let url = defaultNetworkLogFileURL else { return [] }
return logFileURLs(for: url)
}
/// Returns the active file and any retained archive generations for a
/// caller-supplied location. The legacy `<name>.1` generation is included
/// when a prior build could not migrate it.
public static func logFileURLs(for fileURL: URL) -> [URL] {
let fileManager = FileManager.default
var urls: [URL] = []
if fileManager.fileExists(atPath: fileURL.path) {
urls.append(fileURL)
}
urls.append(contentsOf: archiveURLs(for: fileURL))
let legacyURL = legacyRotationURL(for: fileURL)
if fileManager.fileExists(atPath: legacyURL.path) {
urls.append(legacyURL)
}
return urls
}
private static let archiveMarker = ".archive-"
private static func legacyRotationURL(for fileURL: URL) -> URL {
URL(fileURLWithPath: fileURL.path + ".1")
}
private static func archivePrefix(for fileURL: URL) -> String {
let stem = fileURL.pathExtension.isEmpty
? fileURL.lastPathComponent
: fileURL.deletingPathExtension().lastPathComponent
return "\(stem)\(archiveMarker)"
}
private static func archiveStamp(for url: URL, prefix: String) -> Int64? {
let remainder = url.lastPathComponent.dropFirst(prefix.count)
let stamp = remainder.prefix(13)
guard stamp.count == 13,
stamp.allSatisfy({ $0.isNumber }),
remainder.dropFirst(stamp.count).first == "-"
else {
return nil
}
return Int64(stamp)
}
private static func archiveURLs(for fileURL: URL) -> [URL] {
let fileManager = FileManager.default
let directory = fileURL.deletingLastPathComponent()
let prefix = archivePrefix(for: fileURL)
guard let names = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return names
.filter { candidate in
candidate.lastPathComponent.hasPrefix(prefix)
&& candidate.pathExtension == fileURL.pathExtension
&& fileManager.fileExists(atPath: candidate.path)
}
.sorted { lhs, rhs in
let leftStamp = archiveStamp(for: lhs, prefix: prefix)
let rightStamp = archiveStamp(for: rhs, prefix: prefix)
switch (leftStamp, rightStamp) {
case let (left?, right?):
if left != right { return left > right }
case (_?, nil):
return true
case (nil, _?):
return false
case (nil, nil):
break
}
let leftDate = (try? lhs.resourceValues(
forKeys: [.contentModificationDateKey]
).contentModificationDate) ?? .distantPast
let rightDate = (try? rhs.resourceValues(
forKeys: [.contentModificationDateKey]
).contentModificationDate) ?? .distantPast
if leftDate != rightDate { return leftDate > rightDate }
return lhs.lastPathComponent > rhs.lastPathComponent
}
}
private static func makeArchiveURL(for fileURL: URL, date: Date) -> URL {
let stem = fileURL.pathExtension.isEmpty
? fileURL.lastPathComponent
: fileURL.deletingPathExtension().lastPathComponent
let extensionSuffix = fileURL.pathExtension.isEmpty
? ""
: ".\(fileURL.pathExtension)"
let milliseconds = Int64(date.timeIntervalSince1970 * 1_000)
let stamp = String(format: "%013lld", milliseconds)
let unique = String(UUID().uuidString.prefix(8))
return fileURL.deletingLastPathComponent()
.appendingPathComponent(
"\(stem)\(archiveMarker)\(stamp)-\(unique)\(extensionSuffix)"
)
}
private static func defaultFileURL(named name: String) -> URL? {
let fileManager = FileManager.default
guard let base = fileManager.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else { return nil }
do {
try fileManager.createDirectory(at: base, withIntermediateDirectories: true)
} catch {
return nil
}
return base.appendingPathComponent(name)
}
private enum Entry: Sendable {
case event(DiagnosticEvent, wall: Date)
case appLine(String, wall: Date)
}
private struct LogFile {
let url: URL
let maxBytes: Int
let maxArchiveCount: Int
let maxRetainedBytes: Int
let header: String
let now: @Sendable () -> Date
var handle: FileHandle?
var bytesWritten = 0
/// Byte level at which the next rotation is attempted. Normally
/// `maxBytes`; raised after a failed rotate so a sustained failure
/// (busy file, read-only directory) retries once per additional
/// budget of growth instead of once per appended line.
var rotationThreshold: Int
init(
url: URL,
maxBytes: Int,
maxArchiveCount: Int,
maxRetainedBytes: Int,
header: String,
now: @escaping @Sendable () -> Date
) {
self.url = url
self.maxBytes = max(1, maxBytes)
self.maxArchiveCount = max(1, maxArchiveCount)
self.maxRetainedBytes = max(maxRetainedBytes, self.maxBytes)
self.header = header
self.now = now
self.rotationThreshold = max(1, maxBytes)
migrateLegacyRotation()
if FileManager.default.fileExists(atPath: url.path) {
openExistingForAppending()
if handle != nil, bytesWritten >= self.maxBytes {
_ = rotate()
}
} else {
_ = openFreshGeneration()
}
if handle != nil {
pruneArchives()
}
}
/// Moves a legacy `<name>.1` generation into the timestamped archive
/// namespace. If the move cannot be completed, the legacy file stays
/// untouched and remains shareable.
private mutating func migrateLegacyRotation() {
let fileManager = FileManager.default
let legacyURL = AppLog.legacyRotationURL(for: url)
guard fileManager.fileExists(atPath: legacyURL.path) else { return }
let archiveURL = AppLog.makeArchiveURL(for: url, date: now())
try? fileManager.moveItem(at: legacyURL, to: archiveURL)
}
/// Opens a new active generation. This method never removes or
/// overwrites an existing file. The caller must move an old active
/// generation away first.
@discardableResult
private mutating func openFreshGeneration() -> Bool {
let fileManager = FileManager.default
guard !fileManager.fileExists(atPath: url.path),
fileManager.createFile(atPath: url.path, contents: nil),
let opened = try? FileHandle(forWritingTo: url) else {
handle = nil
return false
}
handle = opened
bytesWritten = 0
rotationThreshold = maxBytes
write(header)
return handle != nil
}
/// Rotates the active generation into a unique archive. A failed move
/// reopens the original file for appending and leaves every existing
/// byte in place. If creating the replacement fails after the move,
/// the archive is restored when possible; otherwise it remains on disk
/// and is still returned by ``AppLog.logFileURLs(for:)``.
@discardableResult
private mutating func rotate() -> Bool {
let fileManager = FileManager.default
guard fileManager.fileExists(atPath: url.path) else {
return openFreshGeneration()
}
close()
let archiveURL = AppLog.makeArchiveURL(for: url, date: now())
do {
try fileManager.moveItem(at: url, to: archiveURL)
} catch {
openExistingForAppending()
rotationThreshold = bytesWritten + maxBytes
return false
}
guard openFreshGeneration() else {
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.moveItem(at: archiveURL, to: url)
}
openExistingForAppending()
rotationThreshold = bytesWritten + maxBytes
return false
}
pruneArchives()
return true
}
/// Keeps writing to the current generation. Existing files are opened
/// at their end, never truncated. An empty pre-existing file receives
/// the generation header once.
private mutating func openExistingForAppending() {
guard let opened = try? FileHandle(forWritingTo: url),
let size = try? opened.seekToEnd() else {
// A generation that cannot be opened or positioned at its end
// is not safely appendable: writing from offset 0 would
// overwrite the content this fallback exists to preserve.
handle = nil
return
}
handle = opened
bytesWritten = Int(clamping: size)
rotationThreshold = maxBytes
if bytesWritten == 0 {
write(header)
}
}
mutating func append(_ line: String) {
guard handle != nil else { return }
let data = Data((line + "\n").utf8)
if bytesWritten + data.count > rotationThreshold {
_ = rotate()
}
write(line)
}
/// Removes only timestamped archives, and only after a new active
/// generation has been opened. The newest archive is always kept even
/// if one unusually large line temporarily exceeds the byte ceiling.
private mutating func pruneArchives() {
let fileManager = FileManager.default
var archives = AppLog.archiveURLs(for: url)
guard !archives.isEmpty else { return }
var totalBytes = fileSize(of: url)
var archiveSizes = archives.map { fileSize(of: $0) }
totalBytes += archiveSizes.reduce(0, +)
while archives.count > maxArchiveCount || totalBytes > maxRetainedBytes {
guard archives.count > 1 else { break }
let oldestIndex = archives.count - 1
let oldest = archives.remove(at: oldestIndex)
let oldestSize = archiveSizes.remove(at: oldestIndex)
do {
try fileManager.removeItem(at: oldest)
totalBytes -= oldestSize
} catch {
// A protection or sharing failure should never cause us
// to remove a different, newer generation.
break
}
}
}
private func fileSize(of fileURL: URL) -> Int {
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
let size = values.fileSize else {
return 0
}
return size
}
private mutating func write(_ line: String) {
guard let handle else { return }
let data = Data((line + "\n").utf8)
do {
try handle.write(contentsOf: data)
bytesWritten += data.count
} catch {
try? handle.close()
self.handle = nil
}
}
mutating func close() {
try? handle?.close()
handle = nil
}
}
/// One in-progress run of coalescible frame events.
private struct FrameRun {
let key: FrameRunKey
var lastEvent: DiagnosticEvent
var count: Int
}
private struct FrameRunKey: Equatable {
let code: DiagnosticEventCode
let surface: UInt32?
let stage: Int?
}
private var appFile: LogFile?
private var networkFile: LogFile?
private var pendingFrameRun: FrameRun?
private var processed = 0
private let presentation = DiagnosticEventPresentation()
private let timestampFormatter: ISO8601DateFormatter
private let ingress: AsyncStream<Entry>.Continuation
/// Create a log writing to the given locations. Passing `nil` for a URL
/// disables that file (used by tests exercising one file at a time).
public init(
appFileURL: URL?,
networkFileURL: URL?,
maxFileBytes: Int = AppLog.defaultMaxFileBytes,
buildStamp: String = "",
maxArchiveCount: Int = AppLog.defaultMaxArchiveCount,
maxRetainedBytes: Int = AppLog.defaultMaxRetainedBytes,
now: @escaping @Sendable () -> Date = { Date() }
) {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
timestampFormatter = formatter
let started = formatter.string(from: now())
if let appFileURL {
appFile = LogFile(
url: appFileURL,
maxBytes: maxFileBytes,
maxArchiveCount: maxArchiveCount,
maxRetainedBytes: maxRetainedBytes,
header: "cmux app log · \(buildStamp) · started \(started)",
now: now
)
}
if let networkFileURL {
networkFile = LogFile(
url: networkFileURL,
maxBytes: maxFileBytes,
maxArchiveCount: maxArchiveCount,
maxRetainedBytes: maxRetainedBytes,
header: "cmux network diagnostics log · \(buildStamp) · started \(started)",
now: now
)
}
let (stream, continuation) = AsyncStream<Entry>.makeStream(
bufferingPolicy: .bufferingNewest(2048)
)
ingress = continuation
// The drain holds self only across one write; when the log deallocs,
// `deinit` finishes the stream and the loop ends on its own.
Task { [weak self] in
for await entry in stream {
guard let self else { return }
await self.write(entry)
}
}
}
deinit {
ingress.finish()
}
/// Record one structured diagnostic event. Non-blocking and safe to call
/// from the ``DiagnosticLog`` event tap (which runs on the ring's drain
/// task and must not block).
public nonisolated func ingest(_ event: DiagnosticEvent) {
ingress.yield(.event(event, wall: Date()))
}
/// Mirror one free-text debug-log line into the app file. The caller owns
/// the privacy gating (the string debug log only produces lines in DEBUG
/// or behind the user's verbose opt-in).
public nonisolated func mirrorAppLine(_ line: String) {
ingress.yield(.appLine(line, wall: Date()))
}
/// The total number of entries the drain task has written. Never
/// decreases, so after admitting `n` entries a test can poll this to `n`
/// to know everything reached the files, without sleeping.
public func processedCount() -> Int {
processed
}
/// Flushes a pending coalesced frame run to disk. Test-only
/// synchronization; in production runs flush when they break.
public func flushForTesting() {
flushPendingFrameRun()
}
private func write(_ entry: Entry) {
processed += 1
switch entry {
case .event(let event, let wall):
writeEvent(event, wall: wall)
case .appLine(let line, let wall):
flushPendingFrameRun()
appFile?.append("\(timestampFormatter.string(from: wall)) \(line)")
}
}
private func writeEvent(_ event: DiagnosticEvent, wall: Date) {
if let key = Self.frameRunKey(for: event) {
if var run = pendingFrameRun, run.key == key {
run.lastEvent = event
run.count += 1
pendingFrameRun = run
return
}
flushPendingFrameRun()
appendRendered(event, wall: wall)
pendingFrameRun = FrameRun(key: key, lastEvent: event, count: 1)
return
}
flushPendingFrameRun()
appendRendered(event, wall: wall)
}
/// Frame-pipeline events repeat at frame cadence with only the sequence
/// and byte count varying; they coalesce per (code, panel, stage).
private static func frameRunKey(for event: DiagnosticEvent) -> FrameRunKey? {
guard event.code == .simulatorFrameLifecycle else { return nil }
return FrameRunKey(code: event.code, surface: event.surface, stage: event.a)
}
private func flushPendingFrameRun() {
guard let run = pendingFrameRun else { return }
pendingFrameRun = nil
guard run.count > 1 else { return }
let summary = presentation.summary(run.lastEvent)
append(
line: "\(timestampFormatter.string(from: Date())) \(summary) (repeated ×\(run.count))",
domain: run.key.code.appLogDomain
)
}
private func appendRendered(_ event: DiagnosticEvent, wall: Date) {
append(
line: "\(timestampFormatter.string(from: wall)) \(presentation.summary(event))",
domain: event.code.appLogDomain
)
}
private func append(line: String, domain: Domain) {
switch domain {
case .app:
appFile?.append(line)
case .network:
networkFile?.append(line)
case .both:
appFile?.append(line)
networkFile?.append(line)
}
}
}
public extension DiagnosticEventCode {
/// Which on-disk log this event belongs to: the app-wide log, the network
/// diagnostics log, or both (cross-cutting context that keeps each file
/// self-sufficient). New codes default to the app log.
var appLogDomain: AppLog.Domain {
switch self {
case .connect, .pairOk, .pairFail, .pairUnreachable,
.livenessResubscribe, .streamEnded, .inputSeqBehind, .byteGap,
.transportDialStarted, .transportDialConnected, .transportDialFailed,
.hostAuthenticated, .hostAuthenticationFailed,
.rpcReady, .rpcFailed,
.recoveryStarted, .recoverySucceeded, .recoveryFailed,
.endpointStarting, .endpointActive, .endpointStopped, .endpointFailed,
.relayPolicyRefreshStarted, .relayPolicyRefreshSucceeded,
.relayPolicyRefreshFailed,
.selectedPathChanged, .sessionClosed, .routeUnavailable,
.retryScheduled,
.discoveryStarted, .discoverySucceeded, .discoveryFailed,
.admissionSucceeded, .admissionFailed,
.transportSessionLifecycle,
.transportCloseAttribution, .transportPathEvent,
.transportDialPlanBuilt, .transportPrivateAddressJoin,
.transportLANDiscovery, .transportDialLegSucceeded,
.transportDialLegFailed, .lanPublicationState,
.transportDialSessionLinked, .transportDialCancelled,
.transportCloseReason:
return .network
case .appLifecycleChanged, .reachabilityChanged:
return .both
default:
return .app
}
}
}
@@ -0,0 +1,161 @@
/// A privacy-safe, staged explanation of Iroh connection readiness.
public struct CmxIrohConnectionCheckReport: Equatable, Sendable {
public enum Role: Equatable, Sendable {
case mobileClient
case macHost
}
public enum StageKind: CaseIterable, Hashable, Sendable {
case encryptedTransport
case relayPolicy
case relayReachability
case macDiscovery
case secureSession
}
public enum StageStatus: Equatable, Sendable {
case passed
case warning
case failed
case notApplicable
}
public enum RelayReachability: Equatable, Sendable {
case notConfigured
case reachable
case unreachable
case unavailable
}
public enum MacDiscovery: Equatable, Sendable {
case found
case missing
case unavailable
}
public enum Recommendation: Equatable, Sendable {
case none
case retry
case checkInternet
case openMacApp
case allowRelayTraffic
case refreshAccount
case reviewRelaySettings
case updateOrRepair
}
public struct Stage: Identifiable, Equatable, Sendable {
public var id: StageKind { kind }
public let kind: StageKind
public let status: StageStatus
public init(kind: StageKind, status: StageStatus) {
self.kind = kind
self.status = status
}
}
public let role: Role
public let stages: [Stage]
public let recommendation: Recommendation
public let failureKind: DiagnosticFailureKind?
public let selectedPath: CmxIrohSelectedTransportPath
public var isReady: Bool {
!stages.contains { $0.status == .failed }
}
public init(
role: Role,
snapshot: CmxIrohSettingsSnapshot,
diagnostics: DiagnosticReport,
relayReachability: RelayReachability,
macDiscovery: MacDiscovery = .unavailable
) {
self.role = role
failureKind = diagnostics.lastFailureKind
selectedPath = snapshot.selectedTransportPath
let transportStatus: StageStatus = switch snapshot.runtimeStatus {
case .inactive, .degraded: .failed
case .starting: .warning
case .active, .direct, .relayed, .privateNetwork: .passed
}
let policyStatus: StageStatus = switch snapshot.policySource {
case .server: .passed
case .cached: .warning
case .unavailable: .failed
}
let relayStatus: StageStatus = switch relayReachability {
case .notConfigured: .notApplicable
case .reachable: .passed
case .unreachable: .failed
case .unavailable: .failed
}
let discoveryStatus: StageStatus
let sessionStatus: StageStatus
switch role {
case .macHost:
discoveryStatus = .notApplicable
sessionStatus = .notApplicable
case .mobileClient:
discoveryStatus = switch macDiscovery {
case .found: .passed
case .missing, .unavailable: .failed
}
sessionStatus = snapshot.selectedTransportPath == .unavailable ? .failed : .passed
}
stages = [
Stage(kind: .encryptedTransport, status: transportStatus),
Stage(kind: .relayPolicy, status: policyStatus),
Stage(kind: .relayReachability, status: relayStatus),
Stage(kind: .macDiscovery, status: discoveryStatus),
Stage(kind: .secureSession, status: sessionStatus),
]
recommendation = Self.recommendation(
role: role,
transportStatus: transportStatus,
policyStatus: policyStatus,
relayReachability: relayReachability,
discoveryStatus: discoveryStatus,
sessionStatus: sessionStatus,
failureKind: diagnostics.lastFailureKind,
hasRelayConfigurationProblem: !snapshot.staleRelayIDs.isEmpty
|| snapshot.failureDescription != nil
)
}
private static func recommendation(
role: Role,
transportStatus: StageStatus,
policyStatus: StageStatus,
relayReachability: RelayReachability,
discoveryStatus: StageStatus,
sessionStatus: StageStatus,
failureKind: DiagnosticFailureKind?,
hasRelayConfigurationProblem: Bool
) -> Recommendation {
if transportStatus == .failed, failureKind == .offline { return .checkInternet }
if policyStatus == .failed || hasRelayConfigurationProblem {
return .reviewRelaySettings
}
// Corporate-allowlist advice requires a relay that was actually probed
// and blocked. An unavailable probe is indeterminate (inactive runtime,
// unreadable path hints), so it must never send users to IT.
if relayReachability == .unreachable { return .allowRelayTraffic }
if transportStatus == .failed { return .refreshAccount }
if role == .mobileClient, discoveryStatus == .failed { return .openMacApp }
if role == .mobileClient, sessionStatus == .failed {
switch failureKind {
case .identityMismatch, .accountMismatch, .authorizationFailed,
.admissionDenied, .secureChannelFailed:
return .updateOrRepair
default:
return .retry
}
}
if relayReachability == .unavailable { return .retry }
return .none
}
}
@@ -1,6 +1,6 @@
public import Foundation
/// Release-safe, device-local Iroh path constraint chosen in Settings.
/// Legacy device-local Iroh path preference retained for version compatibility.
public enum CmxIrohPathPreference: String, CaseIterable, Equatable, Sendable {
/// Allows Iroh to select automatic, direct, private-network, or relay paths.
case automatic = "auto"
@@ -8,14 +8,21 @@ public enum CmxIrohPathPreference: String, CaseIterable, Equatable, Sendable {
/// Keeps Iroh connections on relay paths.
case relayOnly
/// Prevents this device from listening or dialing through Iroh relays.
case neverUseRelays
/// Shared defaults key used independently by the macOS and iOS apps.
public static let defaultsKey = "cmux.iroh.pathPreference"
/// The transport constraint this preference imposes on the runtime.
/// The release transport mode after normalizing retired preferences.
///
/// Relay-only is a DEBUG verification mode. A value persisted by an older
/// release must not constrain a current production connection.
public var transportVerificationMode: CmxIrohTransportVerificationMode {
switch self {
case .automatic: .automatic
case .relayOnly: .relayOnly
case .relayOnly: .automatic
case .neverUseRelays: .directOnly
}
}
@@ -0,0 +1,26 @@
import Foundation
public extension Sequence where Element == String {
/// Returns unique, sorted, credential-free HTTPS origins suitable for an IT allowlist.
func cmxIrohCanonicalRelayOrigins() -> [String] {
Array(Set(compactMap { rawValue in
guard let components = URLComponents(string: rawValue),
components.scheme?.lowercased() == "https",
let host = components.host,
!host.isEmpty,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/" else {
return nil
}
var origin = URLComponents()
origin.scheme = "https"
origin.host = host
origin.port = components.port
return origin.string
})).sorted()
}
}
@@ -27,12 +27,19 @@ public protocol CmxIrohSettingsControlling: AnyObject {
/// Probes one custom relay without changing the active preference.
func testIrohCustomRelay(id: String) async -> CmxIrohRelayTestResult
/// Runs a bounded, privacy-safe check of the active encrypted connection path.
func runIrohConnectionCheck() async -> CmxIrohConnectionCheckReport
/// Persists one device-local custom private-path configuration.
func upsertIrohCustomPrivatePath(_ path: CmxIrohCustomPrivatePathDraft) async throws
/// Removes this device's custom private paths for one Mac.
func removeIrohCustomPrivatePath(macDeviceID: String) async throws
/// Restores the active networking choices to their safe defaults without
/// deleting saved relay definitions or private addresses.
func resetIrohSettingsToDefaults() async throws
/// Fetches the latest signed fleet and account preference.
func refreshIrohSettings() async
@@ -51,6 +58,16 @@ public protocol CmxIrohSettingsControlling: AnyObject {
}
public extension CmxIrohSettingsControlling {
func runIrohConnectionCheck() async -> CmxIrohConnectionCheckReport {
CmxIrohConnectionCheckReport(
role: .mobileClient,
snapshot: await irohSettingsSnapshot(),
diagnostics: await irohDiagnosticReport(),
relayReachability: .unavailable,
macDiscovery: .unavailable
)
}
func setIrohPathPreference(_ preference: CmxIrohPathPreference) async throws {
throw CmxIrohSettingsControlError.unsupported
}
@@ -63,6 +80,35 @@ public extension CmxIrohSettingsControlling {
throw CmxIrohSettingsControlError.unsupported
}
func resetIrohSettingsToDefaults() async throws {
let snapshot = await irohSettingsSnapshot()
var firstError: (any Error)?
do {
try await setIrohRelayPreference(.automatic)
} catch {
firstError = error
}
do {
try await setIrohPathPreference(.automatic)
} catch {
firstError = firstError ?? error
}
for privateNetwork in snapshot.customPrivateNetworks where privateNetwork.isEnabled {
do {
try await upsertIrohCustomPrivatePath(.init(
macDeviceID: privateNetwork.macDeviceID,
macDisplayName: privateNetwork.macDisplayName,
addresses: privateNetwork.addresses,
isEnabled: false
))
} catch {
firstError = firstError ?? error
}
}
if let firstError { throw firstError }
}
func irohDiagnosticReport() async -> DiagnosticReport {
.empty
}
@@ -92,10 +92,16 @@ public struct CmxIrohSettingsSnapshot: Equatable, Sendable {
public struct PrivateNetworkMac: Identifiable, Equatable, Sendable {
public let id: String
public let displayName: String
public let supportsPrivatePaths: Bool
public init(id: String, displayName: String) {
public init(
id: String,
displayName: String,
supportsPrivatePaths: Bool = false
) {
self.id = id
self.displayName = displayName
self.supportsPrivatePaths = supportsPrivatePaths
}
}
@@ -12,9 +12,16 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
/// Returns a tokenless Tailscale-only v1 pairing URL, or `nil` when the
/// ticket has no Tailscale route to disclose.
public func encode(_ ticket: CmxAttachTicket) throws -> URL? {
public func encode(
_ ticket: CmxAttachTicket,
pairingURLScheme: CmxPairingURLScheme? =
CmxPairingURLSchemeResolver().resolved
) throws -> URL? {
let tailscaleRoutes = ticket.routes.filter { $0.kind == .tailscale }
guard !tailscaleRoutes.isEmpty else { return nil }
guard !tailscaleRoutes.isEmpty,
let scheme = pairingURLScheme?.rawValue else {
return nil
}
let legacyTicket = try CmxAttachTicket(
version: ticket.version,
@@ -35,7 +42,7 @@ public struct CmxLegacyPrivateNetworkPairingCode: Sendable {
encoder.dateEncodingStrategy = .iso8601
let payload = base64URLEncode(try encoder.encode(legacyTicket))
return URL(
string: "\(CmxPairingURLScheme.current)://attach?v=\(legacyTicket.version)&payload=\(payload)"
string: "\(scheme)://attach?v=\(legacyTicket.version)&payload=\(payload)"
)
}
@@ -77,8 +77,13 @@ public struct CmxPairingQRCode: Sendable {
/// route is dropped, never written into a scannable code.
public func encode(
_ ticket: CmxAttachTicket,
routeDisclosureMode: CmxPairingRouteDisclosureMode
routeDisclosureMode: CmxPairingRouteDisclosureMode,
pairingURLScheme: CmxPairingURLScheme? =
CmxPairingURLSchemeResolver().resolved
) -> String? {
guard let scheme = pairingURLScheme?.rawValue else {
return nil
}
let items: [String]
switch routeDisclosureMode {
case .irohIdentityOnly:
@@ -119,7 +124,7 @@ public struct CmxPairingQRCode: Sendable {
// Mac's QR opens the dev iOS build, a release Mac's QR opens the
// release build, and the system camera can no longer hand a beta/prod
// code to a dev build that also claimed the scheme.
return "\(CmxPairingURLScheme.current)://attach?" + items.joined(separator: "&")
return "\(scheme)://attach?" + items.joined(separator: "&")
}
/// Whether `ticket` is expressible in the selected minimal grammar.
@@ -224,7 +229,7 @@ public struct CmxPairingQRCode: Sendable {
/// the minimal grammar).
public func isPairingCodeURLString(_ rawValue: String) -> Bool {
guard let url = URL(string: rawValue),
CmxPairingURLScheme.isPairingScheme(url.scheme),
CmxPairingURLScheme(rawValue: url.scheme) != nil,
url.host == "attach",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return false
@@ -316,7 +321,10 @@ private extension CmxPairingQRCode {
terminalID: nil,
macDeviceID: "",
macDisplayName: nil,
macPairingCompatibilityVersion: 0,
// v3 is intentionally endpoint-only. `nil` means the QR did not
// make a compatibility claim, unlike v2's explicit unknown value
// (0), which must continue to trigger its legacy warning.
macPairingCompatibilityVersion: nil,
routes: [route],
expiresAt: nil,
authToken: nil
@@ -1,75 +1,95 @@
import Foundation
/// The channel-specific URL scheme carried by cmux pairing/attach deep links.
/// One validated URL scheme carried by a cmux pairing or attach deep link.
///
/// All builds used to register and emit one scheme (`cmux-ios`), so scanning a
/// beta/prod pairing QR with the iOS Camera app could open a *dev* build that
/// happened to be installed (the OS picks an arbitrary app when several claim
/// a scheme). The scheme is therefore channel-specific, mirroring how
/// `MobileBuildType` splits channels:
///
/// - **Development (DEBUG)** builds local Xcode and `reload.sh` tagged
/// builds on both Mac and iPhone register and emit ``development``.
/// - **Release** builds (TestFlight beta and App Store prod) register and
/// emit ``release``. Beta and prod share a scheme because they are the same
/// compile configuration and a phone realistically has only one of them.
///
/// Emitters (the Mac building a pairing QR or attach URL) use ``current`` so a
/// dev Mac pairs a dev phone and a release Mac pairs a release phone via the
/// system camera. Parsers (the in-app scanner, manual paste, the root scene's
/// deep-link gate) accept *any* pairing scheme via ``isPairingScheme(_:)`` /
/// ``hasPairingScheme(_:)``, so cross-channel pairing still works when the
/// user scans from inside the app.
///
/// The iOS app's registered scheme comes from `CMUX_IOS_URL_SCHEME` in
/// `ios/Config/Shared.xcconfig` (dev) and `ios/Config/Release.xcconfig`
/// (release); keep those values in sync with these constants.
///
/// lint:allow namespace-type the build channel's URL scheme is a pure
/// compile-time constant set with no per-instance state to inject; these
/// scheme strings and the stateless pairing-scheme predicates are a genuine
/// namespace, like the sanctioned FFI/seam holders.
/// Every installed iOS bundle registers exactly one scheme derived from its
/// complete bundle identifier. Parsers also accept the two historical shared
/// schemes so an old QR remains scannable inside an already-open app, but new
/// apps never register those shared schemes with iOS.
public struct CmxPairingURLScheme {
private init() {}
/// The validated, lowercase URL scheme.
public let rawValue: String
/// The scheme Release (TestFlight beta + App Store) builds register and emit.
/// Creates the exact scheme registered by one installed iOS bundle.
public init?(iOSBundleIdentifier: String?) {
guard let namespace = MobileIOSAppNamespace(
bundleIdentifier: iOSBundleIdentifier
) else {
return nil
}
let scheme = namespace.pairingURLScheme.lowercased()
guard Self.releaseSchemes.contains(scheme)
|| scheme == Self.untaggedDevelopmentScheme
|| scheme.hasPrefix(Self.developmentPrefix) else {
return nil
}
rawValue = scheme
}
/// Parses a classifiable bundle-specific or historical shared pairing
/// scheme. Unknown release-like namespaces fail closed so account preflight
/// cannot be bypassed by a syntactically valid but unclassified scheme.
public init?(rawValue: String?) {
guard let rawValue else { return nil }
let normalized = rawValue.lowercased()
if Self.all.contains(normalized) {
self.rawValue = normalized
return
}
let prefix = "cmux-ios-"
guard normalized.hasPrefix(prefix),
MobileIOSAppNamespace(
bundleIdentifier: String(normalized.dropFirst(prefix.count))
) != nil,
Self.releaseSchemes.contains(normalized)
|| normalized == Self.untaggedDevelopmentScheme
|| normalized.hasPrefix(Self.developmentPrefix) else {
return nil
}
self.rawValue = normalized
}
/// Parses the scheme from a complete pairing URL.
public init?(urlString: String) {
guard urlString.contains("://"),
let components = URLComponents(string: urlString),
let scheme = CmxPairingURLScheme(rawValue: components.scheme) else {
return nil
}
self = scheme
}
/// Whether this scheme identifies a tagged iOS development build.
public var isDevelopment: Bool {
rawValue == Self.development
|| rawValue == Self.untaggedDevelopmentScheme
|| rawValue.hasPrefix(Self.developmentPrefix)
}
/// Whether this scheme identifies an App Store or TestFlight build.
public var isRelease: Bool {
Self.releaseSchemes.contains(rawValue)
}
/// Historical shared Release scheme. Parse-only in new iOS builds.
public static let release = "cmux-ios"
/// The scheme development (DEBUG/tagged) builds register and emit.
/// Historical shared development scheme. Parse-only in new iOS builds.
public static let development = "cmux-ios-dev"
/// Every scheme any cmux build may emit; parsers accept all of them.
/// Historical schemes retained for source compatibility and old QR tests.
public static let all: [String] = [release, development]
/// The scheme this build emits in pairing QRs and attach URLs.
public static var current: String {
scheme(isDevelopmentBuild: isDevelopmentBuild)
}
private static let untaggedDevelopmentScheme = "cmux-ios-dev.cmux.ios"
private static let developmentPrefix = "cmux-ios-dev.cmux.ios."
/// Pure channel-to-scheme mapping, injected with the compile flag so the
/// derivation is testable from a single build configuration.
public static func scheme(isDevelopmentBuild: Bool) -> String {
isDevelopmentBuild ? development : release
}
/// Whether `scheme` is a pairing scheme from any cmux channel.
public static func isPairingScheme(_ scheme: String?) -> Bool {
guard let scheme else { return false }
return all.contains { $0.caseInsensitiveCompare(scheme) == .orderedSame }
}
/// Whether `rawValue` starts with any channel's pairing scheme (the
/// scanner/paste-side prefix check, before URL parsing).
public static func hasPairingScheme(_ rawValue: String) -> Bool {
let lowercased = rawValue.lowercased()
return all.contains { lowercased.hasPrefix($0 + "://") }
}
private static var isDevelopmentBuild: Bool {
#if DEBUG
true
#else
false
#endif
}
private static let releaseSchemes: Set<String> = [
release,
"cmux-ios-com.cmux.app",
"cmux-ios-dev.cmux.app.beta",
"cmux-ios-dev.cmux.app.internal",
"cmux-ios-dev.cmux.app.demo",
]
}
extension CmxPairingURLScheme: Equatable, Sendable {}
@@ -0,0 +1,71 @@
import Foundation
/// Resolves the pairing target for the current process without global state.
public struct CmxPairingURLSchemeResolver: Sendable {
private let currentIOSBundleIdentifier: String?
private let targetIOSBundleIdentifier: String?
private let macInstanceTag: String?
private let isDevelopmentBuild: Bool
/// Captures the current app identity and any explicit Mac pairing target.
///
/// A Mac may set `CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER` to any authoritative
/// release-lane bundle id. Tagged Mac builds otherwise target their exact
/// same-tag iOS bundle.
public init(
bundle: Bundle = .main,
environment: [String: String] = ProcessInfo.processInfo.environment
) {
currentIOSBundleIdentifier = bundle.bundleIdentifier
targetIOSBundleIdentifier =
environment["CMUX_IOS_PAIRING_BUNDLE_IDENTIFIER"]
macInstanceTag = environment["CMUX_TAG"]
#if DEBUG
isDevelopmentBuild = true
#else
isDevelopmentBuild = false
#endif
}
init(
currentIOSBundleIdentifier: String?,
targetIOSBundleIdentifier: String?,
macInstanceTag: String?,
isDevelopmentBuild: Bool
) {
self.currentIOSBundleIdentifier = currentIOSBundleIdentifier
self.targetIOSBundleIdentifier = targetIOSBundleIdentifier
self.macInstanceTag = macInstanceTag
self.isDevelopmentBuild = isDevelopmentBuild
}
/// The exact scheme this process should emit, or `nil` on invalid identity.
public var resolved: CmxPairingURLScheme? {
#if os(iOS)
return CmxPairingURLScheme(
iOSBundleIdentifier: currentIOSBundleIdentifier
)
#else
if let targetIOSBundleIdentifier {
return CmxPairingURLScheme(
iOSBundleIdentifier: targetIOSBundleIdentifier
)
}
if macInstanceTag == nil || macInstanceTag?.isEmpty == true {
return CmxPairingURLScheme(
iOSBundleIdentifier: isDevelopmentBuild
? "dev.cmux.ios"
: "com.cmux.app"
)
}
guard let namespace = MobileIOSAppNamespace(
pairedMacInstanceTag: macInstanceTag
) else {
return nil
}
return CmxPairingURLScheme(
iOSBundleIdentifier: namespace.bundleIdentifier
)
#endif
}
}
@@ -422,6 +422,15 @@ public protocol CmxByteTransportContinuityIdentifying: CmxByteTransport {
func transportContinuityID() async -> UInt64?
}
/// Optional privacy-safe link from a byte dial to the admitted transport
/// session that backs it. The value is process-local and never leaves the
/// diagnostic ring.
public protocol CmxByteTransportDiagnosticSessionIdentifying: CmxByteTransport {
/// Returns the current admitted session ID, or `nil` before connection or
/// after the session has been released.
func transportDiagnosticSessionID() async -> Int?
}
/// A privacy-safe handle that waits for one exact native transport to close.
///
/// The handle captures the transport generation at creation time, so callers
@@ -0,0 +1,164 @@
import Foundation
/// Fixed terminal-toolbar actions stored in the value payload of
/// ``DiagnosticAppEventKind/terminalToolbarActionUsed``.
///
/// Values 0...31 mirror the persisted `TerminalInputAccessoryAction` raw
/// values. Values starting at 100 are fixed controls outside that configurable
/// action list. Append only, because these values ship in diagnostic reports.
public enum DiagnosticTerminalToolbarAction: Int, Sendable, Codable, CaseIterable {
case control = 0
case alternate = 1
case command = 2
case shift = 3
case zoomOut = 4
case zoomIn = 5
case escape = 6
case tab = 7
case upArrow = 8
case downArrow = 9
case leftArrow = 10
case rightArrow = 11
case claude = 12
case codex = 13
case tilde = 14
case pipe = 15
case dollar = 16
case slash = 17
case atSign = 18
case ctrlC = 19
case ctrlD = 20
case ctrlZ = 21
case ctrlL = 22
case home = 23
case end = 24
case pageUp = 25
case pageDown = 26
case paste = 27
case composer = 28
case returnKey = 29
case ollama = 30
case files = 31
case keyboardToggle = 100
case hideChrome = 101
case customize = 102
case zoomResetToDefault = 103
case zoomSaveAsDefault = 104
case zoomRestoreBuiltIn = 105
}
/// Fixed reason stored in the value payload of
/// ``DiagnosticAppEventKind/terminalZoomChanged``.
public enum DiagnosticTerminalZoomAction: Int, Sendable, Codable, CaseIterable {
case stepDecrease = 1
case stepIncrease = 2
case resetToDefault = 3
case restoreBuiltIn = 4
case hostSet = 5
}
/// Fixed primary navigation destination stored in the value payload of
/// ``DiagnosticAppEventKind/primaryTabSelected``.
public enum DiagnosticPrimaryTab: Int, Sendable, Codable, CaseIterable {
case workspaces = 1
case notifications = 2
case search = 3
}
/// Fixed search owner stored in the value payload of search lifecycle events.
public enum DiagnosticSearchScope: Int, Sendable, Codable, CaseIterable {
case workspaces = 1
case notifications = 2
}
/// Fixed mutations stored in the value payload of terminal toolbar settings
/// events. The custom-action cases report only the operation, never the action's
/// user-authored label or inserted text.
public enum DiagnosticToolbarConfigurationAction: Int, Sendable, Codable, CaseIterable {
case shortcutShown = 1
case shortcutHidden = 2
case shortcutReordered = 3
case shortcutsReset = 4
case customActionAdded = 10
case customActionUpdated = 11
case customActionRemoved = 12
}
/// Privacy-safe delivery route stored in feedback outcome events.
public enum DiagnosticFeedbackRoute: Int, Sendable, Codable, CaseIterable {
case privilegedAgent = 1
case email = 2
case privilegedAgentFallbackToEmail = 3
}
/// Privacy-safe visual style stored for toast admission events. Toast title,
/// message, action label, and coalescing key are never retained.
public enum DiagnosticToastStyle: Int, Sendable, Codable, CaseIterable {
case info = 1
case success = 2
case warning = 3
case failure = 4
}
/// Fixed reason stored in ``DiagnosticAppEventKind/toastDismissed``.
public enum DiagnosticToastDismissReason: Int, Sendable, Codable, CaseIterable {
case caller = 1
case automatic = 2
case featureDisabled = 3
case dismissAll = 4
case removedFromQueue = 5
}
/// A typed, privacy-safe value carried by an app diagnostic event.
///
/// The durable event schema stores this discriminator in `DiagnosticEvent.c`,
/// but producers use this enum instead of the generic `count` parameter. That
/// keeps item counts, byte counts, and categorical values distinct at the API
/// boundary and lets report presentation assign a stable semantic field name.
public enum DiagnosticAppEventDetail: Sendable, Equatable {
case terminalToolbarAction(DiagnosticTerminalToolbarAction)
case terminalZoomAction(DiagnosticTerminalZoomAction)
case primaryTab(DiagnosticPrimaryTab)
case searchScope(DiagnosticSearchScope)
case toolbarConfigurationAction(DiagnosticToolbarConfigurationAction)
case feedbackRoute(DiagnosticFeedbackRoute)
case toastStyle(DiagnosticToastStyle)
case toastDismissReason(DiagnosticToastDismissReason)
var rawValue: Int {
switch self {
case .terminalToolbarAction(let value): value.rawValue
case .terminalZoomAction(let value): value.rawValue
case .primaryTab(let value): value.rawValue
case .searchScope(let value): value.rawValue
case .toolbarConfigurationAction(let value): value.rawValue
case .feedbackRoute(let value): value.rawValue
case .toastStyle(let value): value.rawValue
case .toastDismissReason(let value): value.rawValue
}
}
func supports(_ kind: DiagnosticAppEventKind) -> Bool {
switch (self, kind) {
case (.terminalToolbarAction(_), .terminalToolbarActionUsed),
(.terminalZoomAction(_), .terminalZoomChanged),
(.primaryTab(_), .primaryTabSelected),
(.searchScope(_), .searchPresented),
(.searchScope(_), .searchDismissed),
(.searchScope(_), .searchResultSelected),
(.toolbarConfigurationAction(_), .customToolbarChanged),
(.toolbarConfigurationAction(_), .terminalShortcutChanged),
(.feedbackRoute(_), .feedbackSubmitStarted),
(.feedbackRoute(_), .feedbackSubmitSucceeded),
(.feedbackRoute(_), .feedbackSubmitFailed),
(.toastStyle(_), .toastPresented),
(.toastStyle(_), .toastCoalesced),
(.toastStyle(_), .toastQueued),
(.toastStyle(_), .toastDropped),
(.toastDismissReason(_), .toastDismissed):
true
default:
false
}
}
}
@@ -0,0 +1,38 @@
import Foundation
/// Builds the short identity printed in every exported network report.
///
/// The git SHA and dev tag are signed bundle metadata, not runtime input. The
/// helper keeps iOS and macOS reports comparable and applies the same bounded
/// sanitization at their shared boundary.
public enum DiagnosticBuildStamp {
/// Returns a bounded `name version (build) tag sha` stamp from bundle data.
public static func make(
infoDictionary: [String: Any]?,
fallbackName: String = "cmux"
) -> String {
let info = infoDictionary ?? [:]
let name = nonEmptyString(info["CFBundleName"]) ?? fallbackName
let version = nonEmptyString(info["CFBundleShortVersionString"]) ?? "?"
let build = nonEmptyString(info["CFBundleVersion"]) ?? "?"
let tag = nonEmptyString(info["CMUXDevTag"])
let sha = nonEmptyString(info["CMUXGitSHA"]).map {
String($0.prefix(12))
}
var result = "\(name) \(version) (\(build))"
if let tag {
result += " tag \(tag)"
}
if let sha {
result += " sha \(sha)"
}
return DiagnosticReport.sanitizeBuildStamp(result)
}
private static func nonEmptyString(_ value: Any?) -> String? {
guard let value = value as? String else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
@@ -0,0 +1,20 @@
import Foundation
/// Produces privacy-safe handles that correlate related events within one app run.
///
/// Swift's `Hasher` is randomly seeded for each process. The same opaque model
/// identifier therefore maps to the same integer inside one exported report,
/// while a later launch produces a different value. This keeps workspace,
/// surface, panel, and computer operations debuggable without persisting their
/// raw identifiers or creating a cross-launch tracking key.
public struct DiagnosticCorrelation: Sendable {
public init() {}
/// Returns a process-local handle for a non-empty opaque identifier.
public func handle(for rawValue: String?) -> UInt32? {
guard let rawValue, !rawValue.isEmpty else { return nil }
var hasher = Hasher()
hasher.combine(rawValue)
return UInt32(truncatingIfNeeded: hasher.finalize())
}
}
@@ -151,12 +151,16 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable {
case routeUnavailable = 42
/// A bounded retry was scheduled. `ms` is the delay before retry.
case retryScheduled = 43
/// Same-account or local-route discovery started.
/// Same-account or local-route discovery started. `a` is
/// ``DiagnosticTransportKind``.
case discoveryStarted = 44
/// Discovery produced at least one authenticated candidate.
/// Discovery produced an authoritative snapshot. `a` is
/// ``DiagnosticTransportKind``, `b` is its binding count, `c` is its
/// managed relay-fleet count, and `ms` is the fetch duration.
case discoverySucceeded = 45
/// Discovery failed to produce an authenticated candidate. `b`, when
/// present, is ``DiagnosticFailureKind``.
/// Discovery failed to produce an authoritative snapshot. `a` is
/// ``DiagnosticTransportKind``, `b`, when present, is
/// ``DiagnosticFailureKind``, and `ms` is the fetch duration.
case discoveryFailed = 46
/// The host admitted the authenticated client to an RPC session.
case admissionSucceeded = 47
@@ -193,6 +197,8 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable {
/// `b` is ``DiagnosticPathKind`` for the affected path, and `c` is the
/// matching positive, process-local session correlation ID.
case transportPathEvent = 55
// MARK: Browser streaming and control
/// A phone-driven browser stream session changed lifecycle state on the
/// Mac. `a` is the stage (1 started, 2 replaced an existing session,
/// 3 stopped, 4 first frame emitted), and `c` is the positive browser
@@ -213,6 +219,91 @@ public enum DiagnosticEventCode: UInt16, Sendable, Codable, CaseIterable {
/// `a` is 1 on success else 0, and `c` is the panel correlation ID of the
/// created panel (absent on failure).
case browserPanelCreateResolved = 59
// MARK: Simulator streaming and control
/// A phone-controlled Simulator stream lifecycle edge. `surface` is a
/// process-local panel handle, `a` is
/// ``DiagnosticSimulatorStreamLifecycle``, `b` is
/// ``DiagnosticSimulatorOwnershipState``, and `c`, when present, is a
/// bounded count such as the active session count.
case simulatorStreamLifecycle = 60
/// One frame-pipeline edge. `surface` is a process-local panel handle,
/// `a` is ``DiagnosticSimulatorFrameLifecycle``, `b`, when present, is a
/// clamped frame sequence number, and `c`, when present, is a byte count.
case simulatorFrameLifecycle = 61
/// One phone-originated Simulator input edge. `surface` is a process-local
/// panel handle, `a` is ``DiagnosticSimulatorInputLifecycle``, `b` is
/// ``DiagnosticSimulatorInputKind``, and `c`, when present, is a
/// phase/button/text-size detail.
case simulatorInputLifecycle = 62
/// A phone touch point was mapped before dispatch. `surface` is a
/// process-local panel handle, `a` and `b` are normalized x/y in
/// ten-thousandths, and `c` is ``DiagnosticSimulatorCoordinateState``.
case simulatorCoordinateMapped = 63
/// A Simulator stream ownership descriptor changed. `surface` is a
/// process-local panel handle, `a` is the new
/// ``DiagnosticSimulatorOwnershipState``, and `b` is the previous state
/// when known.
case simulatorOwnershipChanged = 64
// MARK: App-wide feature observability
/// One privacy-safe iOS feature boundary event. `a` is
/// ``DiagnosticAppEventKind``; `b`, when present, is a
/// ``DiagnosticFailureKind``; `c`, when present, is a bounded count or
/// magnitude documented by that event kind; `ms`, when present, is elapsed
/// time; and `surface`, when present, is a process-local correlation handle.
///
/// This is the app-wide vocabulary for user actions and feature outcomes
/// that do not belong to the transport, browser-frame, or Simulator hot
/// paths. Event kinds are fixed enums, never caller-provided strings, so the
/// durable Release log cannot capture terminal contents, credentials,
/// account identifiers, file paths, URLs, workspace titles, or error text.
case appFeatureAction = 65
// Raw values 66-70 are reserved for app-wide diagnostic expansion.
// MARK: Iroh bootstrap diagnostics
/// One direct dial plan was assembled before any connect attempt. `a` is
/// the public path hint count, `b` is the private fallback path hint
/// count, and `c` is the public relay-URL hint count. A plan with both
/// counts zero proves no dial packet was sent.
case transportDialPlanBuilt = 71
/// Configured private addresses were joined with the target Mac's
/// broker-registered UDP port for one dial. `a` is the join state
/// (``DiagnosticPrivateAddressJoinState``), `b` is the configured
/// address count, and `c` is the resulting dialable hint count.
case transportPrivateAddressJoin = 72
/// Account-private LAN discovery resolved for one dial. `a` is the
/// outcome (``DiagnosticLANDiscoveryOutcome``) and `b` is the resolved
/// hint count.
case transportLANDiscovery = 73
/// One direct dial leg connected. `a` is the leg
/// (``DiagnosticDirectDialLeg``).
case transportDialLegSucceeded = 74
/// One direct dial leg failed before a connection existed. `a` is the
/// leg (``DiagnosticDirectDialLeg``) and `b` is the classified
/// ``DiagnosticFailureKind``.
case transportDialLegFailed = 75
/// The Mac's account-private LAN advertisement changed publication
/// state. `a` is the state (``DiagnosticLANPublicationState``) and `b`
/// is the synchronization reason (0 applied, 1 listener setting
/// disabled, 2 runtime context unavailable).
case lanPublicationState = 76
/// The transport dial was associated with the admitted session it opened.
/// `surface` is the process-local peer alias, `a` is the dial attempt ID,
/// and `c` is the matching session ID.
case transportDialSessionLinked = 77
/// A pending dial was cancelled by a lifecycle owner. `surface` is the
/// peer alias, `a` is ``DiagnosticCancellationReason``, `ms` is elapsed
/// dial time, and `c` is the dial attempt ID.
case transportDialCancelled = 78
/// A close carried a bounded remote reason token. `surface` is the peer
/// alias, `a` is ``DiagnosticRemoteCloseReason``, and `c` is the session ID.
case transportCloseReason = 79
}
/// Scene phase carried by ``DiagnosticEventCode/appLifecycleChanged``.
@@ -221,3 +312,25 @@ public enum DiagnosticAppLifecyclePhase: Int, Sendable, Codable, CaseIterable {
case active = 1
case inactive = 2
}
public extension DiagnosticEventCode {
/// Whether this event belongs to the Simulator streaming/control feature.
var isSimulatorDiagnosticEvent: Bool {
switch self {
case .simulatorStreamLifecycle,
.simulatorFrameLifecycle,
.simulatorInputLifecycle,
.simulatorCoordinateMapped,
.simulatorOwnershipChanged:
true
default:
false
}
}
/// Whether this event belongs to app-wide iOS feature observability rather
/// than the network or frame-stream planes.
var isAppFeatureDiagnosticEvent: Bool {
self == .appFeatureAction
}
}
@@ -74,11 +74,96 @@ public struct DiagnosticEventPresentation: Sendable {
String(describing: phase)
}
/// The stable machine name of an app-wide iOS feature event.
public func name(_ kind: DiagnosticAppEventKind) -> String {
String(describing: kind)
}
/// The stable machine name of a terminal toolbar action.
public func name(_ action: DiagnosticTerminalToolbarAction) -> String {
String(describing: action)
}
/// The stable machine name of a terminal zoom action.
public func name(_ action: DiagnosticTerminalZoomAction) -> String {
String(describing: action)
}
/// The stable machine name of a primary navigation destination.
public func name(_ tab: DiagnosticPrimaryTab) -> String {
String(describing: tab)
}
/// The stable machine name of a primary search owner.
public func name(_ scope: DiagnosticSearchScope) -> String {
String(describing: scope)
}
/// The stable machine name of a terminal toolbar configuration mutation.
public func name(_ action: DiagnosticToolbarConfigurationAction) -> String {
String(describing: action)
}
/// The stable machine name of a feedback delivery route.
public func name(_ route: DiagnosticFeedbackRoute) -> String {
String(describing: route)
}
/// The stable machine name of a toast style.
public func name(_ style: DiagnosticToastStyle) -> String {
String(describing: style)
}
/// The stable machine name of a toast dismissal reason.
public func name(_ reason: DiagnosticToastDismissReason) -> String {
String(describing: reason)
}
/// The stable machine name of a runtime role.
public func name(_ role: DiagnosticRuntimeRole) -> String {
String(describing: role)
}
/// The stable machine name of a Simulator stream lifecycle edge.
public func name(_ kind: DiagnosticSimulatorStreamLifecycle) -> String {
String(describing: kind)
}
/// The stable machine name of a Simulator frame lifecycle edge.
public func name(_ kind: DiagnosticSimulatorFrameLifecycle) -> String {
String(describing: kind)
}
/// The stable machine name of a Simulator input lifecycle edge.
public func name(_ kind: DiagnosticSimulatorInputLifecycle) -> String {
String(describing: kind)
}
/// The stable machine name of a Simulator input kind.
public func name(_ kind: DiagnosticSimulatorInputKind) -> String {
String(describing: kind)
}
/// The stable machine name of a Simulator hardware button kind.
public func name(_ kind: DiagnosticSimulatorHardwareButtonKind) -> String {
String(describing: kind)
}
/// The stable machine name of a Simulator pointer phase.
public func name(_ phase: DiagnosticSimulatorPointerPhase) -> String {
String(describing: phase)
}
/// The stable machine name of a Simulator ownership state.
public func name(_ state: DiagnosticSimulatorOwnershipState) -> String {
String(describing: state)
}
/// The stable machine name of a Simulator coordinate mapping state.
public func name(_ state: DiagnosticSimulatorCoordinateState) -> String {
String(describing: state)
}
/// Human-readable name of a diagnostic failure category.
public func displayName(_ kind: DiagnosticFailureKind) -> String {
switch kind {
@@ -108,6 +193,11 @@ public struct DiagnosticEventPresentation: Sendable {
case .admissionRevalidationFailed: localized("diagnostics.failure.admissionRevalidationFailed", defaultValue: "Admission revalidation failed")
case .sendQueueOverflow: localized("diagnostics.failure.sendQueueOverflow", defaultValue: "Send queue overflow")
case .routeGated: localized("diagnostics.failure.routeGated", defaultValue: "Route already connecting")
case .payloadTooLarge: localized("diagnostics.failure.payloadTooLarge", defaultValue: "Payload too large")
case .resourceLimitReached: localized("diagnostics.failure.resourceLimitReached", defaultValue: "Resource limit reached")
case .attachmentCountLimitReached: localized("diagnostics.failure.attachmentCountLimitReached", defaultValue: "Attachment count limit reached")
case .attachmentAggregateSizeLimitReached: localized("diagnostics.failure.attachmentAggregateSizeLimitReached", defaultValue: "Attachment size limit reached")
case .localStateUnavailable: localized("diagnostics.failure.localStateUnavailable", defaultValue: "Local state unavailable")
case .unknown: localized("diagnostics.failure.unknown", defaultValue: "Unknown failure")
}
}
@@ -123,6 +213,14 @@ public struct DiagnosticEventPresentation: Sendable {
}
}
/// Human-readable name of a configured connection method.
public func displayName(_ method: DiagnosticConnectionMethod) -> String {
switch method {
case .automatic: localized("diagnostics.connectionMethod.automatic", defaultValue: "Auto-Connect (Iroh)")
case .tailscale: localized("diagnostics.connectionMethod.tailscale", defaultValue: "Tailscale Only")
}
}
/// Human-readable name of a selected network path.
public func displayName(_ kind: DiagnosticPathKind) -> String {
switch kind {
@@ -177,7 +275,24 @@ public struct DiagnosticEventPresentation: Sendable {
public func describe(_ event: DiagnosticEvent) -> DescribedEvent {
var fields: [Field] = []
if let surface = event.surface {
fields.append(Field(key: "surface", value: String(surface)))
let key: String
switch event.code {
case .recoveryStarted, .recoverySucceeded, .recoveryFailed:
key = "recovery"
case .transportDialStarted, .transportDialConnected,
.transportDialFailed, .transportDialSessionLinked,
.transportDialCancelled, .transportSessionLifecycle,
.sessionClosed, .transportCloseAttribution,
.transportCloseReason, .transportPathEvent,
.transportDialPlanBuilt, .transportPrivateAddressJoin,
.transportLANDiscovery, .transportDialLegSucceeded,
.transportDialLegFailed, .discoveryStarted,
.discoverySucceeded, .discoveryFailed:
key = "peer"
default:
key = "surface"
}
fields.append(Field(key: key, value: String(surface)))
}
if let a = event.a {
fields.append(decodeA(a, code: event.code))
@@ -189,7 +304,7 @@ public struct DiagnosticEventPresentation: Sendable {
fields.append(decodeMilliseconds(ms, code: event.code))
}
if let c = event.c {
fields.append(decodeC(c, code: event.code))
fields.append(decodeC(c, event: event))
}
return DescribedEvent(name: title(for: event.code), fields: fields)
}
@@ -233,10 +348,10 @@ public struct DiagnosticEventPresentation: Sendable {
/// Event codes whose `b` slot carries a ``DiagnosticFailureKind``.
private static let codesWithFailureB: Set<DiagnosticEventCode> = [
.pairFail, .transportDialFailed, .recoveryFailed, .endpointFailed,
.pairFail, .transportDialFailed, .transportDialLegFailed, .recoveryFailed, .endpointFailed,
.relayPolicyRefreshFailed, .sessionClosed, .routeUnavailable,
.discoveryFailed, .admissionFailed, .hostAuthenticationFailed,
.rpcFailed, .transportCloseAttribution,
.rpcFailed, .transportCloseAttribution, .appFeatureAction,
]
/// Event codes whose `a` slot carries a ``DiagnosticTransportKind``.
@@ -297,6 +412,10 @@ public struct DiagnosticEventPresentation: Sendable {
localized("diagnostics.event.transportDialConnected", defaultValue: "Transport connected")
case .transportDialFailed:
localized("diagnostics.event.transportDialFailed", defaultValue: "Transport dial failed")
case .transportDialSessionLinked:
localized("diagnostics.event.transportDialSessionLinked", defaultValue: "Transport dial linked to session")
case .transportDialCancelled:
localized("diagnostics.event.transportDialCancelled", defaultValue: "Transport dial cancelled")
case .hostAuthenticated:
localized("diagnostics.event.hostAuthenticated", defaultValue: "Host authenticated")
case .rpcReady:
@@ -351,6 +470,8 @@ public struct DiagnosticEventPresentation: Sendable {
localized("diagnostics.event.reachabilityChanged", defaultValue: "Network reachability changed")
case .transportCloseAttribution:
localized("diagnostics.event.transportCloseAttribution", defaultValue: "Transport close attributed")
case .transportCloseReason:
localized("diagnostics.event.transportCloseReason", defaultValue: "Remote close reason")
case .transportPathEvent:
localized("diagnostics.event.transportPathEvent", defaultValue: "Transport path changed")
case .browserStreamLifecycle:
@@ -361,6 +482,30 @@ public struct DiagnosticEventPresentation: Sendable {
localized("diagnostics.event.browserEditableFocus", defaultValue: "Browser editable focus")
case .browserPanelCreateResolved:
localized("diagnostics.event.browserPanelCreateResolved", defaultValue: "Browser panel create resolved")
case .simulatorStreamLifecycle:
localized("diagnostics.event.simulatorStreamLifecycle", defaultValue: "Simulator stream state changed")
case .simulatorFrameLifecycle:
localized("diagnostics.event.simulatorFrameLifecycle", defaultValue: "Simulator frame pipeline changed")
case .simulatorInputLifecycle:
localized("diagnostics.event.simulatorInputLifecycle", defaultValue: "Simulator input state changed")
case .simulatorCoordinateMapped:
localized("diagnostics.event.simulatorCoordinateMapped", defaultValue: "Simulator touch coordinate mapped")
case .simulatorOwnershipChanged:
localized("diagnostics.event.simulatorOwnershipChanged", defaultValue: "Simulator control ownership changed")
case .appFeatureAction:
localized("diagnostics.event.appFeatureAction", defaultValue: "App feature event")
case .transportDialPlanBuilt:
localized("diagnostics.event.transportDialPlanBuilt", defaultValue: "Direct dial plan assembled")
case .transportPrivateAddressJoin:
localized("diagnostics.event.transportPrivateAddressJoin", defaultValue: "Private addresses joined broker port")
case .transportLANDiscovery:
localized("diagnostics.event.transportLANDiscovery", defaultValue: "LAN discovery resolved")
case .transportDialLegSucceeded:
localized("diagnostics.event.transportDialLegSucceeded", defaultValue: "Direct dial leg connected")
case .transportDialLegFailed:
localized("diagnostics.event.transportDialLegFailed", defaultValue: "Direct dial leg failed")
case .lanPublicationState:
localized("diagnostics.event.lanPublicationState", defaultValue: "LAN advertisement state changed")
}
}
@@ -403,6 +548,28 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "editable_focused", value: booleanName(raw))
case .browserPanelCreateResolved:
return Field(key: "created", value: booleanName(raw))
case .simulatorStreamLifecycle:
return Field(key: "state", value: simulatorStreamLifecycleName(raw))
case .simulatorFrameLifecycle:
return Field(key: "state", value: simulatorFrameLifecycleName(raw))
case .simulatorInputLifecycle:
return Field(key: "state", value: simulatorInputLifecycleName(raw))
case .simulatorCoordinateMapped:
return Field(key: "x", value: normalizedCoordinate(raw))
case .simulatorOwnershipChanged:
return Field(key: "owner", value: simulatorOwnershipName(raw))
case .appFeatureAction:
return Field(key: "operation", value: appEventName(raw))
case .transportDialPlanBuilt:
return Field(key: "public_paths", value: String(raw))
case .transportPrivateAddressJoin:
return Field(key: "join", value: privateAddressJoinName(raw))
case .transportLANDiscovery:
return Field(key: "outcome", value: lanDiscoveryOutcomeName(raw))
case .transportDialLegSucceeded, .transportDialLegFailed:
return Field(key: "leg", value: dialLegName(raw))
case .lanPublicationState:
return Field(key: "state", value: lanPublicationStateName(raw))
default:
return Field(key: "detail_1", value: String(raw))
}
@@ -431,6 +598,26 @@ public struct DiagnosticEventPresentation: Sendable {
return Field(key: "count", value: String(raw))
case .browserEditableFocus:
return Field(key: "outcome", value: browserFocusOutcomeName(raw))
case .transportDialPlanBuilt:
return Field(key: "private_fallback_paths", value: String(raw))
case .discoverySucceeded:
return Field(key: "bindings", value: String(raw))
case .transportPrivateAddressJoin:
return Field(key: "configured_addresses", value: String(raw))
case .transportLANDiscovery:
return Field(key: "hints", value: String(raw))
case .lanPublicationState:
return Field(key: "reason", value: lanPublicationReasonName(raw))
case .simulatorStreamLifecycle:
return Field(key: "owner", value: simulatorOwnershipName(raw))
case .simulatorFrameLifecycle:
return Field(key: "frame_sequence", value: String(raw))
case .simulatorInputLifecycle:
return Field(key: "input", value: simulatorInputKindName(raw))
case .simulatorCoordinateMapped:
return Field(key: "y", value: normalizedCoordinate(raw))
case .simulatorOwnershipChanged:
return Field(key: "previous_owner", value: simulatorOwnershipName(raw))
default:
return Field(key: "detail_2", value: String(raw))
}
@@ -456,8 +643,12 @@ public struct DiagnosticEventPresentation: Sendable {
}
}
private func decodeC(_ raw: Int, code: DiagnosticEventCode) -> Field {
switch code {
private func decodeC(_ raw: Int, event: DiagnosticEvent) -> Field {
switch event.code {
case .transportDialPlanBuilt:
return Field(key: "public_relay_urls", value: String(raw))
case .discoverySucceeded:
return Field(key: "relay_fleet", value: String(raw))
case .transportDialStarted, .transportDialConnected, .transportDialFailed:
return Field(key: "attempt", value: String(raw))
case .sessionClosed, .transportSessionLifecycle,
@@ -468,11 +659,130 @@ public struct DiagnosticEventPresentation: Sendable {
case .browserStreamLifecycle, .browserInputReplayed,
.browserEditableFocus, .browserPanelCreateResolved:
return Field(key: "panel", value: String(raw))
case .simulatorStreamLifecycle:
return Field(key: "active_sessions", value: String(raw))
case .simulatorFrameLifecycle:
return Field(key: "payload_size", value: byteCount(raw))
case .simulatorInputLifecycle:
return Field(
key: "input_detail",
value: simulatorInputDetailName(raw, inputKindRaw: event.b)
)
case .simulatorCoordinateMapped:
return Field(key: "mapping", value: simulatorCoordinateStateName(raw))
case .appFeatureAction:
if let kind = event.a.flatMap(DiagnosticAppEventKind.init(rawValue:)) {
switch kind {
case .terminalToolbarActionUsed:
return Field(key: "action", value: terminalToolbarActionName(raw))
case .terminalZoomChanged:
return Field(key: "action", value: terminalZoomActionName(raw))
case .primaryTabSelected:
return Field(key: "tab", value: primaryTabName(raw))
case .searchPresented, .searchDismissed, .searchResultSelected:
return Field(key: "scope", value: searchScopeName(raw))
case .customToolbarChanged, .terminalShortcutChanged:
return Field(key: "change", value: toolbarConfigurationActionName(raw))
case .feedbackSubmitStarted, .feedbackSubmitSucceeded, .feedbackSubmitFailed:
return Field(key: "route", value: feedbackRouteName(raw))
case .toastPresented, .toastCoalesced, .toastQueued, .toastDropped:
return Field(key: "style", value: toastStyleName(raw))
case .toastDismissed:
return Field(key: "reason", value: toastDismissReasonName(raw))
case .connectionMethodPreferenceChanged, .connectionMethodConfigured:
return Field(key: "method", value: connectionMethodName(raw))
case .foregroundTransportSelected:
return Field(key: "transport", value: transportName(raw))
default:
if Self.appEventKindsWithValuePayload.contains(kind) {
return Field(key: "value", value: String(raw))
}
}
}
return Field(key: "count", value: String(raw))
default:
return Field(key: "detail_3", value: String(raw))
}
}
private func appEventName(_ raw: Int) -> String {
guard let kind = DiagnosticAppEventKind(rawValue: raw) else {
return localized(
"diagnostics.unknown.appEvent",
defaultValue: "Unknown app event (\(raw))"
)
}
return name(kind)
}
private func terminalToolbarActionName(_ raw: Int) -> String {
DiagnosticTerminalToolbarAction(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func terminalZoomActionName(_ raw: Int) -> String {
DiagnosticTerminalZoomAction(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func primaryTabName(_ raw: Int) -> String {
DiagnosticPrimaryTab(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func searchScopeName(_ raw: Int) -> String {
DiagnosticSearchScope(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func toolbarConfigurationActionName(_ raw: Int) -> String {
DiagnosticToolbarConfigurationAction(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func feedbackRouteName(_ raw: Int) -> String {
DiagnosticFeedbackRoute(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func toastStyleName(_ raw: Int) -> String {
DiagnosticToastStyle(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func toastDismissReasonName(_ raw: Int) -> String {
DiagnosticToastDismissReason(rawValue: raw).map(name)
?? unknownPayloadName(raw)
}
private func connectionMethodName(_ raw: Int) -> String {
DiagnosticConnectionMethod(rawValue: raw).map(displayName)
?? unknownPayloadName(raw)
}
private func unknownPayloadName(_ raw: Int) -> String {
localized(
"diagnostics.unknown.payload",
defaultValue: "Unknown value (\(raw))"
)
}
private static let appEventKindsWithValuePayload: Set<DiagnosticAppEventKind> = [
.displayAltScreenNoticeChanged,
.displayFolderTapChanged,
.displayHapticsChanged,
.taskComposerFeatureChanged,
.terminalFilesFeatureChanged,
.toastFeatureChanged,
.displayMissingFilesChanged,
.displayWorkspaceTitleWrappingChanged,
.displayWorkspacePreviewLinesChanged,
.terminalScrollbackRowsChanged,
.telemetrySharingChanged,
.notificationPreferenceChanged,
.terminalDraftStateChanged,
]
private func failureName(_ raw: Int) -> String {
guard let value = DiagnosticFailureKind(rawValue: raw) else {
return localized(
@@ -523,6 +833,100 @@ public struct DiagnosticEventPresentation: Sendable {
return displayName(value)
}
private func dialLegName(_ raw: Int) -> String {
switch raw {
case DiagnosticDirectDialLeg.publicPaths.rawValue:
localized("diagnostics.dialLeg.public", defaultValue: "Public paths")
case DiagnosticDirectDialLeg.privateFallback.rawValue:
localized("diagnostics.dialLeg.privateFallback", defaultValue: "Private fallback")
default:
localized("diagnostics.unknown.dialLeg", defaultValue: "Unknown leg (\(raw))")
}
}
private func privateAddressJoinName(_ raw: Int) -> String {
switch raw {
case DiagnosticPrivateAddressJoinState.notConfigured.rawValue:
localized("diagnostics.privateJoin.notConfigured", defaultValue: "None configured")
case DiagnosticPrivateAddressJoinState.joined.rawValue:
localized("diagnostics.privateJoin.joined", defaultValue: "Joined broker port")
case DiagnosticPrivateAddressJoinState.brokerPortsStale.rawValue:
localized(
"diagnostics.privateJoin.stalePorts",
defaultValue: "Broker ports missing or stale"
)
default:
localized(
"diagnostics.unknown.privateJoin",
defaultValue: "Unknown join state (\(raw))"
)
}
}
private func lanDiscoveryOutcomeName(_ raw: Int) -> String {
switch raw {
case DiagnosticLANDiscoveryOutcome.noAuthority.rawValue:
localized("diagnostics.lanDiscovery.noAuthority", defaultValue: "No broker LAN authority")
case DiagnosticLANDiscoveryOutcome.found.rawValue:
localized("diagnostics.lanDiscovery.found", defaultValue: "Advertisement found")
case DiagnosticLANDiscoveryOutcome.notFound.rawValue:
localized("diagnostics.lanDiscovery.notFound", defaultValue: "Advertisement not found")
case DiagnosticLANDiscoveryOutcome.policyDenied.rawValue:
localized(
"diagnostics.lanDiscovery.policyDenied",
defaultValue: "Local Network permission denied"
)
default:
localized(
"diagnostics.unknown.lanDiscovery",
defaultValue: "Unknown discovery outcome (\(raw))"
)
}
}
private func lanPublicationStateName(_ raw: Int) -> String {
switch raw {
case DiagnosticLANPublicationState.inactive.rawValue:
localized("diagnostics.lanPublication.inactive", defaultValue: "Stopped")
case DiagnosticLANPublicationState.active.rawValue:
localized("diagnostics.lanPublication.active", defaultValue: "Advertising")
case DiagnosticLANPublicationState.unavailable.rawValue:
localized("diagnostics.lanPublication.unavailable", defaultValue: "Registration failing")
case DiagnosticLANPublicationState.policyDenied.rawValue:
localized(
"diagnostics.lanPublication.policyDenied",
defaultValue: "Local Network permission denied"
)
default:
localized(
"diagnostics.unknown.lanPublication",
defaultValue: "Unknown publication state (\(raw))"
)
}
}
private func lanPublicationReasonName(_ raw: Int) -> String {
switch raw {
case 0:
localized("diagnostics.lanPublicationReason.applied", defaultValue: "Settings applied")
case 1:
localized(
"diagnostics.lanPublicationReason.listenerDisabled",
defaultValue: "Listener setting disabled"
)
case 2:
localized(
"diagnostics.lanPublicationReason.noContext",
defaultValue: "Runtime context unavailable"
)
default:
localized(
"diagnostics.unknown.lanPublicationReason",
defaultValue: "Unknown reason (\(raw))"
)
}
}
private func sessionPurposeName(_ raw: Int) -> String {
guard let byte = UInt8(exactly: raw),
let purpose = CmxTransportSessionPurpose(rawValue: byte)
@@ -677,6 +1081,241 @@ public struct DiagnosticEventPresentation: Sendable {
}
}
private func simulatorStreamLifecycleName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorStreamLifecycle(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorStreamState",
defaultValue: "Unknown stream state (\(raw))"
)
}
switch value {
case .startRequested:
return localized("diagnostics.simulator.stream.startRequested", defaultValue: "Start requested")
case .started:
return localized("diagnostics.simulator.stream.started", defaultValue: "Started")
case .locked:
return localized("diagnostics.simulator.stream.locked", defaultValue: "Locked by another controller")
case .startFailed:
return localized("diagnostics.simulator.stream.startFailed", defaultValue: "Start failed")
case .stopRequested:
return localized("diagnostics.simulator.stream.stopRequested", defaultValue: "Stop requested")
case .stopped:
return localized("diagnostics.simulator.stream.stopped", defaultValue: "Stopped")
case .closed:
return localized("diagnostics.simulator.stream.closed", defaultValue: "Closed")
case .restartRequested:
return localized("diagnostics.simulator.stream.restartRequested", defaultValue: "Restart requested")
case .pausedForBackground:
return localized("diagnostics.simulator.stream.pausedForBackground", defaultValue: "Paused for background")
case .descriptorApplied:
return localized("diagnostics.simulator.stream.descriptorApplied", defaultValue: "Descriptor applied")
case .stalled:
return localized("diagnostics.simulator.stream.stalled", defaultValue: "Stalled (no frames or keepalives)")
case .stopFailed:
return localized("diagnostics.simulator.stream.stopFailed", defaultValue: "Stop failed")
}
}
private func simulatorFrameLifecycleName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorFrameLifecycle(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorFrameState",
defaultValue: "Unknown frame state (\(raw))"
)
}
switch value {
case .readerAttached:
return localized("diagnostics.simulator.frame.readerAttached", defaultValue: "Reader attached")
case .readerMissing:
return localized("diagnostics.simulator.frame.readerMissing", defaultValue: "Reader missing")
case .copied:
return localized("diagnostics.simulator.frame.copied", defaultValue: "Frame copied")
case .encodeFailed:
return localized("diagnostics.simulator.frame.encodeFailed", defaultValue: "Frame encode failed")
case .sent:
return localized("diagnostics.simulator.frame.sent", defaultValue: "Frame sent")
case .refused:
return localized("diagnostics.simulator.frame.refused", defaultValue: "Frame refused by queue")
case .cachedSent:
return localized("diagnostics.simulator.frame.cachedSent", defaultValue: "Cached frame sent")
case .subscriptionReasserted:
return localized("diagnostics.simulator.frame.subscriptionReasserted", defaultValue: "Subscription reasserted")
case .received:
return localized("diagnostics.simulator.frame.received", defaultValue: "Frame received")
case .staleIgnored:
return localized("diagnostics.simulator.frame.staleIgnored", defaultValue: "Stale frame ignored")
case .decodeFailed:
return localized("diagnostics.simulator.frame.decodeFailed", defaultValue: "Frame decode failed")
case .imageDecoded:
return localized("diagnostics.simulator.frame.imageDecoded", defaultValue: "Image decoded")
case .imageDecodeFailed:
return localized("diagnostics.simulator.frame.imageDecodeFailed", defaultValue: "Image decode failed")
case .unknownPanel:
return localized("diagnostics.simulator.frame.unknownPanel", defaultValue: "Unknown panel")
}
}
private func simulatorInputLifecycleName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorInputLifecycle(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorInputState",
defaultValue: "Unknown input state (\(raw))"
)
}
switch value {
case .queued:
return localized("diagnostics.simulator.input.queued", defaultValue: "Queued")
case .sent:
return localized("diagnostics.simulator.input.sent", defaultValue: "Sent")
case .accepted:
return localized("diagnostics.simulator.input.accepted", defaultValue: "Accepted")
case .failed:
return localized("diagnostics.simulator.input.failed", defaultValue: "Failed")
case .rejectedLocked:
return localized("diagnostics.simulator.input.rejectedLocked", defaultValue: "Rejected because locked")
case .unavailable:
return localized("diagnostics.simulator.input.unavailable", defaultValue: "Unavailable")
case .invalidParameters:
return localized("diagnostics.simulator.input.invalidParameters", defaultValue: "Invalid parameters")
case .panelMissing:
return localized("diagnostics.simulator.input.panelMissing", defaultValue: "Panel missing")
case .featureDisabled:
return localized("diagnostics.simulator.input.featureDisabled", defaultValue: "Feature disabled")
case .blockedViewOnly:
return localized("diagnostics.simulator.input.blockedViewOnly", defaultValue: "Blocked in view-only mode")
}
}
private func simulatorInputKindName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorInputKind(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorInputKind",
defaultValue: "Unknown input kind (\(raw))"
)
}
switch value {
case .pointer:
return localized("diagnostics.simulator.inputKind.pointer", defaultValue: "Pointer")
case .text:
return localized("diagnostics.simulator.inputKind.text", defaultValue: "Text")
case .hardwareButton:
return localized("diagnostics.simulator.inputKind.hardwareButton", defaultValue: "Hardware button")
}
}
private func simulatorInputDetailName(_ raw: Int, inputKindRaw: Int?) -> String {
guard let inputKindRaw,
let inputKind = DiagnosticSimulatorInputKind(rawValue: inputKindRaw) else {
return String(raw)
}
switch inputKind {
case .pointer:
return simulatorPointerPhaseName(raw)
case .text:
return byteCount(raw)
case .hardwareButton:
return simulatorHardwareButtonName(raw)
}
}
private func simulatorPointerPhaseName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorPointerPhase(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorPointerPhase",
defaultValue: "Unknown pointer phase (\(raw))"
)
}
switch value {
case .began:
return localized("diagnostics.simulator.pointer.began", defaultValue: "Began")
case .moved:
return localized("diagnostics.simulator.pointer.moved", defaultValue: "Moved")
case .ended:
return localized("diagnostics.simulator.pointer.ended", defaultValue: "Ended")
case .tap:
return localized("diagnostics.simulator.pointer.tap", defaultValue: "Tap")
}
}
private func simulatorHardwareButtonName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorHardwareButtonKind(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorHardwareButton",
defaultValue: "Unknown hardware button (\(raw))"
)
}
switch value {
case .unknown:
return localized("diagnostics.simulator.button.unknown", defaultValue: "Unknown button")
case .home:
return localized("diagnostics.simulator.button.home", defaultValue: "Home")
case .swipeHome:
return localized("diagnostics.simulator.button.swipeHome", defaultValue: "Swipe Home")
case .appSwitcher:
return localized("diagnostics.simulator.button.appSwitcher", defaultValue: "App Switcher")
case .lock:
return localized("diagnostics.simulator.button.lock", defaultValue: "Lock")
case .siri:
return localized("diagnostics.simulator.button.siri", defaultValue: "Siri")
case .sideButton:
return localized("diagnostics.simulator.button.sideButton", defaultValue: "Side button")
case .power:
return localized("diagnostics.simulator.button.power", defaultValue: "Power")
case .volumeUp:
return localized("diagnostics.simulator.button.volumeUp", defaultValue: "Volume up")
case .volumeDown:
return localized("diagnostics.simulator.button.volumeDown", defaultValue: "Volume down")
case .action:
return localized("diagnostics.simulator.button.action", defaultValue: "Action")
case .watchSideButton:
return localized("diagnostics.simulator.button.watchSideButton", defaultValue: "Watch side button")
}
}
private func simulatorOwnershipName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorOwnershipState(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorOwner",
defaultValue: "Unknown owner state (\(raw))"
)
}
switch value {
case .unowned:
return localized("diagnostics.simulator.owner.unowned", defaultValue: "Unowned")
case .currentConnection:
return localized("diagnostics.simulator.owner.currentConnection", defaultValue: "Current connection")
case .otherConnection:
return localized("diagnostics.simulator.owner.otherConnection", defaultValue: "Other connection")
case .pendingHandshake:
return localized("diagnostics.simulator.owner.pendingHandshake", defaultValue: "Pending handshake")
case .unknown:
return localized("diagnostics.simulator.owner.unknown", defaultValue: "Unknown")
}
}
private func simulatorCoordinateStateName(_ raw: Int) -> String {
guard let value = DiagnosticSimulatorCoordinateState(rawValue: raw) else {
return localized(
"diagnostics.unknown.simulatorCoordinateState",
defaultValue: "Unknown coordinate state (\(raw))"
)
}
switch value {
case .mapped:
return localized("diagnostics.simulator.coordinate.mapped", defaultValue: "Mapped")
case .outsideImage:
return localized("diagnostics.simulator.coordinate.outsideImage", defaultValue: "Outside image")
case .viewOnlyBlocked:
return localized("diagnostics.simulator.coordinate.viewOnlyBlocked", defaultValue: "View-only blocked")
case .zeroImage:
return localized("diagnostics.simulator.coordinate.zeroImage", defaultValue: "Missing image geometry")
}
}
private func normalizedCoordinate(_ raw: Int) -> String {
String(format: "%.4f", Double(raw) / 10_000.0)
}
private func duration(_ milliseconds: UInt32) -> String {
guard milliseconds >= 1_000 else {
return localized(
@@ -756,12 +1395,44 @@ public struct DiagnosticEventPresentation: Sendable {
case "delivered_sequence": localized("diagnostics.field.deliveredSequence", defaultValue: "Delivered sequence")
case "next_sequence": localized("diagnostics.field.nextSequence", defaultValue: "Next sequence")
case "stage": localized("diagnostics.field.stage", defaultValue: "Stage")
case "owner": localized("diagnostics.field.owner", defaultValue: "Owner")
case "previous_owner": localized("diagnostics.field.previousOwner", defaultValue: "Previous owner")
case "frame_sequence": localized("diagnostics.field.frameSequence", defaultValue: "Frame sequence")
case "payload_size": localized("diagnostics.field.payloadSize", defaultValue: "Payload size")
case "input": localized("diagnostics.field.input", defaultValue: "Input")
case "input_detail": localized("diagnostics.field.inputDetail", defaultValue: "Input detail")
case "active_sessions": localized("diagnostics.field.activeSessions", defaultValue: "Active sessions")
case "count": localized("diagnostics.field.count", defaultValue: "Count")
case "value": localized("diagnostics.field.value", defaultValue: "Value")
case "method": localized("diagnostics.field.method", defaultValue: "Method")
case "action": localized("diagnostics.field.action", defaultValue: "Action")
case "tab": localized("diagnostics.field.tab", defaultValue: "Tab")
case "scope": localized("diagnostics.field.scope", defaultValue: "Scope")
case "change": localized("diagnostics.field.change", defaultValue: "Change")
case "route": localized("diagnostics.field.route", defaultValue: "Route")
case "style": localized("diagnostics.field.style", defaultValue: "Style")
case "reason": localized("diagnostics.field.reason", defaultValue: "Reason")
case "outcome": localized("diagnostics.field.outcome", defaultValue: "Outcome")
case "editable_focused": localized("diagnostics.field.editableFocused", defaultValue: "Editable focused")
case "created": localized("diagnostics.field.created", defaultValue: "Created")
case "public_paths": localized("diagnostics.field.publicPaths", defaultValue: "Public paths")
case "private_fallback_paths":
localized(
"diagnostics.field.privateFallbackPaths",
defaultValue: "Private fallback paths"
)
case "join": localized("diagnostics.field.join", defaultValue: "Join")
case "configured_addresses":
localized(
"diagnostics.field.configuredAddresses",
defaultValue: "Configured addresses"
)
case "hints": localized("diagnostics.field.hints", defaultValue: "Hints")
case "leg": localized("diagnostics.field.leg", defaultValue: "Leg")
case "panel": localized("diagnostics.field.panel", defaultValue: "Panel")
case "x": localized("diagnostics.field.x", defaultValue: "X")
case "y": localized("diagnostics.field.y", defaultValue: "Y")
case "mapping": localized("diagnostics.field.mapping", defaultValue: "Mapping")
case "detail_1": localized("diagnostics.field.detail1", defaultValue: "Detail 1")
case "detail_2": localized("diagnostics.field.detail2", defaultValue: "Detail 2")
case "detail_3": localized("diagnostics.field.detail3", defaultValue: "Detail 3")
@@ -47,6 +47,10 @@ public final class DiagnosticLog: Sendable {
/// The optional live observer, delivered retained events on the drain task.
private let tap: TapBox
/// Stateless hashing seam used to reduce opaque model identifiers before
/// they enter the event ring. Swift supplies its process-randomized seed.
private let correlation = DiagnosticCorrelation()
/// The drain task. Its closure captures only local stream/store values, so
/// deinitialization can finish ingress and let accepted clear commands drain
/// to their acknowledgements without retaining this log.
@@ -167,6 +171,96 @@ public final class DiagnosticLog: Sendable {
ingress.record(event)
}
/// Records one privacy-safe iOS product event into the same ordered ring
/// and durable app-log tap as transport diagnostics.
///
/// Callers select only fixed enum values and bounded integers. Never add a
/// free-text overload: the absence of strings is what makes this API safe
/// to leave enabled in Release builds for every user.
///
/// - Parameters:
/// - kind: Stable feature action or outcome.
/// - surface: Optional process-local correlation handle.
/// - elapsedMilliseconds: Optional elapsed time for the operation.
/// - failure: Optional privacy-safe failure category.
/// - count: Optional bounded item/byte/attempt count documented by `kind`.
public nonisolated func recordAppEvent(
_ kind: DiagnosticAppEventKind,
surface: UInt32? = nil,
elapsedMilliseconds: UInt32? = nil,
failure: DiagnosticFailureKind? = nil,
count: Int? = nil
) {
let boundedCount = count.map { min(max(0, $0), Int(UInt32.max)) }
record(DiagnosticEvent(
.appFeatureAction,
surface: surface,
ms: elapsedMilliseconds,
a: kind.rawValue,
b: failure?.rawValue,
c: boundedCount
))
}
/// Records one app event correlated to an opaque model identifier.
///
/// The identifier is immediately reduced to a process-local handle by
/// ``DiagnosticCorrelation`` and is never retained or written to disk.
public nonisolated func recordAppEvent(
_ kind: DiagnosticAppEventKind,
correlationID: String?,
elapsedMilliseconds: UInt32? = nil,
failure: DiagnosticFailureKind? = nil,
count: Int? = nil
) {
recordAppEvent(
kind,
surface: correlation.handle(for: correlationID),
elapsedMilliseconds: elapsedMilliseconds,
failure: failure,
count: count
)
}
/// Records a categorical app-event value through a typed payload instead
/// of conflating its raw discriminator with an item or byte count.
public nonisolated func recordAppEvent(
_ kind: DiagnosticAppEventKind,
surface: UInt32? = nil,
elapsedMilliseconds: UInt32? = nil,
failure: DiagnosticFailureKind? = nil,
detail: DiagnosticAppEventDetail
) {
guard detail.supports(kind) else {
assertionFailure("Unsupported diagnostic detail for \(kind)")
return
}
recordAppEvent(
kind,
surface: surface,
elapsedMilliseconds: elapsedMilliseconds,
failure: failure,
count: detail.rawValue
)
}
/// Records a typed categorical value correlated to an opaque model ID.
public nonisolated func recordAppEvent(
_ kind: DiagnosticAppEventKind,
correlationID: String?,
elapsedMilliseconds: UInt32? = nil,
failure: DiagnosticFailureKind? = nil,
detail: DiagnosticAppEventDetail
) {
recordAppEvent(
kind,
surface: correlation.handle(for: correlationID),
elapsedMilliseconds: elapsedMilliseconds,
failure: failure,
detail: detail
)
}
/// Snapshot the currently-drained ring and format a plain-language report.
///
/// Reads whatever the drain task has already moved into the ring; it does not
@@ -354,7 +354,19 @@ public extension DiagnosticEvent {
/// Positive process-local correlation ID shared by a dial attempt and its
/// outcome. It is intentionally not stable across launches or devices.
var diagnosticAttemptID: Int? {
guard code.isTransportDialEvent, let c, c > 0 else { return nil }
guard code.isTransportDialEvent || code == .transportDialSessionLinked
|| code == .transportDialCancelled,
let c,
c > 0 else { return nil }
return c
}
/// Positive process-local session correlation ID carried by a dial/session
/// link or close-reason event.
var diagnosticLinkedSessionID: Int? {
guard code == .transportDialSessionLinked || code == .transportCloseReason,
let c,
c > 0 else { return nil }
return c
}
@@ -388,6 +400,7 @@ public extension DiagnosticEvent {
guard code == .transportSessionLifecycle
|| code == .sessionClosed
|| code == .transportCloseAttribution
|| code == .transportCloseReason
|| code == .transportPathEvent,
let c,
c > 0 else { return nil }
@@ -429,6 +442,7 @@ public extension DiagnosticEventCode {
.streamEnded,
.error,
.transportDialFailed,
.transportDialLegFailed,
.recoveryFailed,
.endpointFailed,
.relayPolicyRefreshFailed,
@@ -446,6 +460,7 @@ public extension DiagnosticEventCode {
var carriesDiagnosticFailureKind: Bool {
switch self {
case .transportDialFailed,
.transportDialLegFailed,
.recoveryFailed,
.endpointFailed,
.relayPolicyRefreshFailed,
@@ -473,6 +488,7 @@ public extension DiagnosticEventCode {
case .pairFail,
.error,
.transportDialFailed,
.transportDialLegFailed,
.recoveryFailed,
.endpointFailed,
.relayPolicyRefreshFailed,
@@ -76,6 +76,18 @@ public enum DiagnosticFailureKind: Int, Sendable, Codable, CaseIterable {
/// genuine dial timeouts in exports; a gated attempt never reached the
/// network.
case routeGated = 25
/// A bounded operation rejected one input because its payload exceeded the
/// supported per-item size. The payload itself is never retained.
case payloadTooLarge = 26
/// A bounded operation could not admit more work because its item-count or
/// aggregate byte budget was already exhausted.
case resourceLimitReached = 27
/// An attachment picker or composer reached its fixed item-count cap.
case attachmentCountLimitReached = 28
/// An attachment picker or composer reached its aggregate byte budget.
case attachmentAggregateSizeLimitReached = 29
/// Required device-local persisted state was absent or unavailable.
case localStateUnavailable = 30
case unknown = 255
/// Reduces a typed or system error to the bounded diagnostic vocabulary.
@@ -151,6 +163,36 @@ public enum DiagnosticFailureKind: Int, Sendable, Codable, CaseIterable {
}
}
/// Why a pending transport dial was cancelled by its owner.
///
/// This is deliberately separate from ``DiagnosticFailureKind/cancelled``:
/// the failure says what the transport observed, while this value says which
/// lifecycle boundary asked it to stop.
public enum DiagnosticCancellationReason: Int, Sendable, Codable, CaseIterable {
case unknown = 0
case requestCancelled = 1
case requestTimedOut = 2
case sessionTeardown = 3
case sessionDeinitialized = 4
}
/// The bounded reason token sent by an admitted Iroh peer when it closes.
///
/// Raw Iroh close text is never exported. The server and client agree on these
/// tokens so a report can distinguish an expected replacement from a network
/// failure without retaining a peer-chosen string.
public enum DiagnosticRemoteCloseReason: Int, Sendable, Codable, CaseIterable {
case unknown = 0
case clientClosed = 1
case serverClosed = 2
case superseded = 3
case admissionLeaseExpired = 4
case admissionRevalidationFailed = 5
case sendQueueOverflow = 6
case serverFailure = 7
case serverCancelled = 8
}
/// Adopted by transport and policy errors that can provide a safe failure
/// category without exporting their raw associated values or description.
public protocol DiagnosticFailureProviding: Error, Sendable {
@@ -181,6 +223,60 @@ public enum DiagnosticPathKind: Int, Sendable, Codable, CaseIterable {
}
}
/// The dial leg attempted while establishing a direct Iroh connection.
///
/// Raw values are stable export vocabulary; never renumber.
public enum DiagnosticDirectDialLeg: Int, Sendable, Codable, CaseIterable {
/// Broker-published public path hints.
case publicPaths = 0
/// Profile-gated private fallback hints (manual, LAN, or VPN sourced).
case privateFallback = 1
}
/// Whether configured private addresses became dialable hints for one dial.
///
/// Raw values are stable export vocabulary. The cases carry only the join
/// outcome, never an address, port, or identity.
public enum DiagnosticPrivateAddressJoinState: Int, Sendable, Codable, CaseIterable {
/// No enabled private address is configured for the target Mac.
case notConfigured = 0
/// At least one configured address joined a fresh broker UDP port.
case joined = 1
/// Addresses are configured but the target's broker-registered ports
/// were missing or older than the private-hint TTL, so none joined.
case brokerPortsStale = 2
}
/// The outcome of account-private LAN discovery for one dial.
///
/// Raw values are stable export vocabulary; the cases carry no peer,
/// address, or service identity.
public enum DiagnosticLANDiscoveryOutcome: Int, Sendable, Codable, CaseIterable {
/// No broker-issued LAN authority exists for the target, so no browse ran.
case noAuthority = 0
/// The target Mac's advertisement resolved to dialable hints.
case found = 1
/// The browse completed without resolving the target's advertisement.
case notFound = 2
/// The system denied Bonjour browsing (Local Network permission).
case policyDenied = 3
}
/// The Mac-side account-private Bonjour publication state.
///
/// Raw values are stable export vocabulary mirroring the publisher's
/// lifecycle without exposing any advertised name, address, or port.
public enum DiagnosticLANPublicationState: Int, Sendable, Codable, CaseIterable {
/// Publication is stopped.
case inactive = 0
/// Advertisements are registered on at least one interface.
case active = 1
/// Registration is wanted but currently failing.
case unavailable = 2
/// The system denied Bonjour publication (Local Network policy).
case policyDenied = 3
}
/// Why an admitted transport session entered or left its local pool.
///
/// Raw values are stable export vocabulary. The cases identify only local
@@ -221,3 +317,642 @@ public enum DiagnosticRuntimeRole: Int, Sendable, Codable, CaseIterable {
/// Source-level spelling used by the current Apple mobile composition.
public static let iosClient = DiagnosticRuntimeRole.mobileClient
}
/// Stable, privacy-safe iOS product events written to the durable app log.
///
/// Each case names a feature boundary and outcome without carrying the user's
/// content or identity. Raw values are shipped diagnostic vocabulary: append
/// new cases physically at the end and never renumber existing ones. Stable
/// declaration order also prevents stale incremental clients from constructing
/// a case with a different enum discriminator. Gaps reserve room for each
/// product area so future events remain easy to audit in exported logs.
///
/// `DiagnosticEvent.c` has one stable contract per event. Categorical values
/// use ``DiagnosticAppEventDetail``. Numeric producers use `count` only for a
/// documented item count, byte count, ordinal, boolean, or bounded setting.
public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable {
// MARK: App runtime (1-19)
case appLaunched = 1
case appForegrounded = 2
case appBecameInactive = 3
case appBackgrounded = 4
case appOpenURLReceived = 5
case appOpenURLDeferredForAuthentication = 6
case appOpenURLHandled = 7
case appOpenURLRejected = 8
case appMemoryWarningReceived = 9
case appProtectedDataUnavailable = 10
case appProtectedDataAvailable = 11
case appScreenshotCaptured = 12
// MARK: Authentication and account (20-39)
case authRestoreStarted = 20
case authRestoreSucceeded = 21
case authRestoreFailed = 22
case authSignInStarted = 23
case authCodeRequested = 24
case authCodeRequestFailed = 25
case authVerificationStarted = 26
case authSignInSucceeded = 27
case authSignInFailed = 28
case authSignInCancelled = 29
case authSignOutStarted = 30
case authSignOutSucceeded = 31
case authSignOutFailed = 32
case authTeamChanged = 33
case authAccountDeletionStarted = 34
case authAccountDeletionSucceeded = 35
case authAccountDeletionFailed = 36
case authRevalidationStarted = 37
case authRevalidationSucceeded = 38
case authRevalidationFailed = 39
// MARK: Onboarding and migration (40-59)
case onboardingStarted = 40
case onboardingStageViewed = 41
case onboardingConnectionMethodChanged = 42
case onboardingPairingStarted = 43
case onboardingConnectionRetried = 44
case onboardingSkipped = 45
case onboardingCompleted = 46
case autoConnectMigrationPresented = 47
case autoConnectMigrationAccepted = 48
case autoConnectMigrationDismissed = 49
// MARK: Push notifications (60-99)
case pushConfigured = 60
case pushAuthorizationPrompted = 61
case pushAuthorizationGranted = 62
case pushAuthorizationDenied = 63
case pushRemoteRegistrationRequested = 64
case pushDeviceTokenReceived = 65
case pushDeviceTokenRegistrationFailed = 66
case pushBackendSyncStarted = 67
case pushBackendSyncSucceeded = 68
case pushBackendSyncFailed = 69
case pushReceivedInForeground = 70
case pushPresentedInForeground = 71
case pushSuppressedInForeground = 72
case pushTapped = 73
case pushReplyStarted = 74
case pushReplySucceeded = 75
case pushReplyFailed = 76
case pushDismissStarted = 77
case pushDismissSucceeded = 78
case pushDismissFailed = 79
case pushRemoteDismissReceived = 80
case pushRemoteDismissApplied = 81
case pushDeeplinkParked = 82
case pushDeeplinkResolved = 83
case pushDeeplinkExpired = 84
case pushDeeplinkFailed = 85
case pushDisabled = 86
// MARK: Computers and pairing (100-129)
case pairingStarted = 100
case pairingSucceeded = 101
case pairingFailed = 102
case pairingCancelled = 103
case computerListRefreshStarted = 104
case computerListRefreshSucceeded = 105
case computerListRefreshFailed = 106
case computerSelected = 107
case computerHidden = 108
case computerUnhidden = 109
case computerForgetStarted = 110
case computerForgetSucceeded = 111
case computerForgetFailed = 112
case computerAliasChanged = 113
case computerRoutesUpdated = 114
case tailscaleStatusChanged = 116
case computerSwitchStarted = 117
case computerSwitchSucceeded = 118
case computerSwitchFailed = 119
case reconnectStarted = 120
case reconnectSucceeded = 121
case reconnectFailed = 122
case presenceStreamStarted = 123
case presenceStreamUpdated = 124
case presenceStreamFailed = 125
case deviceRegistryLoadStarted = 126
case deviceRegistryLoadSucceeded = 127
case deviceRegistryLoadFailed = 128
case connectionStateChanged = 129
// MARK: Workspaces and groups (130-179)
case workspaceListRefreshStarted = 130
case workspaceListRefreshSucceeded = 131
case workspaceListRefreshFailed = 132
case workspaceStateSyncStarted = 133
case workspaceStateSyncSucceeded = 134
case workspaceStateSyncFailed = 135
case workspaceStateSyncFellBack = 136
case workspaceOpenStarted = 137
case workspaceOpenSucceeded = 138
case workspaceOpenFailed = 139
case workspaceCreateStarted = 140
case workspaceCreateSucceeded = 141
case workspaceCreateFailed = 142
case workspaceRenameStarted = 143
case workspaceRenameSucceeded = 144
case workspaceRenameFailed = 145
case workspaceCloseStarted = 146
case workspaceCloseSucceeded = 147
case workspaceCloseFailed = 148
case workspaceMoveStarted = 149
case workspaceMoveSucceeded = 150
case workspaceMoveFailed = 151
case workspaceReorderStarted = 152
case workspaceReorderSucceeded = 153
case workspaceReorderFailed = 154
case workspaceReadStateChanged = 155
case workspaceReadStateChangeFailed = 156
case workspaceGroupCreateStarted = 157
case workspaceGroupCreateSucceeded = 158
case workspaceGroupCreateFailed = 159
case workspaceGroupRenameStarted = 160
case workspaceGroupRenameSucceeded = 161
case workspaceGroupRenameFailed = 162
case workspaceGroupDeleteStarted = 163
case workspaceGroupDeleteSucceeded = 164
case workspaceGroupDeleteFailed = 165
case workspaceCustomizationChanged = 166
case workspaceCustomizationChangeFailed = 167
case workspaceSortChanged = 168
case workspaceComputerOrderChanged = 169
case workspaceDragDropStarted = 170
case workspaceDragDropSucceeded = 171
case workspaceDragDropFailed = 172
case workspaceListRecoveryStarted = 173
case workspaceListRecoverySucceeded = 174
case workspaceListRecoveryFailed = 175
case workspaceMutationUnavailable = 176
case workspaceMutationCancelled = 177
case workspaceGroupCollapsedChanged = 178
case workspaceListFilterChanged = 179
// MARK: Surfaces and navigation (180-199)
case surfaceSelected = 180
case surfaceListUpdated = 181
case surfaceFocused = 182
case surfaceCloseStarted = 183
case surfaceCloseSucceeded = 184
case surfaceCloseFailed = 185
case surfaceTitleChanged = 186
case primaryTabSelected = 187
case searchPresented = 188
case searchDismissed = 189
case searchResultSelected = 190
// MARK: Terminal (200-239)
case terminalMounted = 200
case terminalUnmounted = 201
case terminalStreamSubscribed = 202
case terminalStreamResubscribed = 203
case terminalStreamEnded = 204
case terminalReplayStarted = 205
case terminalReplaySucceeded = 206
case terminalReplayFailed = 207
case terminalReplayRetried = 208
case terminalInputSubmitted = 209
case terminalInputSent = 210
case terminalInputAcknowledged = 211
case terminalInputDropped = 212
case terminalOutputReceived = 213
case terminalOutputGapDetected = 214
case terminalRenderLagDetected = 215
case terminalViewportChanged = 216
case terminalViewportReportSucceeded = 217
case terminalViewportReportFailed = 218
case terminalScrollSent = 219
case terminalScrollFailed = 220
case terminalThemeChanged = 221
/// Detail: ``DiagnosticAppEventDetail/terminalZoomAction(_:)``.
case terminalZoomChanged = 222
/// Detail: ``DiagnosticAppEventDetail/terminalToolbarAction(_:)``.
case terminalToolbarActionUsed = 223
case terminalCreateStarted = 224
case terminalCreateSucceeded = 225
case terminalCreateFailed = 226
case terminalClosed = 227
case terminalAlternateScreenChanged = 228
case terminalTextViewOpened = 229
case terminalArtifactGalleryOpened = 230
case terminalArtifactListLoaded = 231
case terminalArtifactLoadFailed = 232
// MARK: Task composer and agent launch (240-279)
case taskComposerOpened = 240
case taskComposerClosed = 241
case taskDraftChanged = 242
case taskProviderSelected = 243
case taskModelListLoadStarted = 244
case taskModelListLoadSucceeded = 245
case taskModelListLoadFailed = 246
case taskModelSelected = 247
case taskDirectorySearchStarted = 248
case taskDirectorySearchSucceeded = 249
case taskDirectorySearchFailed = 250
case taskAttachmentPickerOpened = 251
case taskAttachmentPrepared = 252
case taskAttachmentPreparationFailed = 253
case taskAttachmentRemoved = 254
case taskSubmitStarted = 255
case taskSubmitSucceeded = 256
case taskSubmitFailed = 257
case taskSubmitCancelled = 258
case taskWorkspaceCreated = 259
case taskAgentLaunched = 260
case taskTemplateListLoaded = 261
case taskTemplateCreated = 262
case taskTemplateUpdated = 263
case taskTemplateDeleted = 264
case taskMachineSelected = 265
case taskRouteSelected = 266
case taskComposerRecoveryStarted = 267
case taskComposerRecovered = 268
case taskComposerRecoveryFailed = 269
/// Count is the admitted attachment count for
/// ``DiagnosticFailureKind/attachmentCountLimitReached`` and aggregate
/// bytes for ``DiagnosticFailureKind/attachmentAggregateSizeLimitReached``.
case taskAttachmentLimitReached = 270
// MARK: Agent chat (280-309)
case chatOpened = 280
case chatClosed = 281
case chatSessionListLoadStarted = 282
case chatSessionListLoadSucceeded = 283
case chatSessionListLoadFailed = 284
case chatSessionSelected = 285
case chatEventStreamStarted = 286
case chatEventStreamEnded = 287
case chatMessageSubmitStarted = 289
case chatMessageSubmitSucceeded = 290
case chatMessageSubmitFailed = 291
case chatPermissionAnswered = 292
case chatQuestionAnswered = 293
case chatArtifactDiscovered = 294
case chatArtifactOpened = 295
case chatMessageRetried = 296
case chatComposerAttachmentAdded = 297
case chatComposerAttachmentRemoved = 298
case chatBlockDetailOpened = 299
case chatPermissionAnswerFailed = 300
case chatQuestionAnswerFailed = 301
case chatMessageQueued = 560
case chatInterruptSucceeded = 561
case chatInterruptFailed = 562
case chatHistoryLoadStarted = 563
case chatHistoryLoadSucceeded = 564
case chatHistoryLoadFailed = 565
case chatOlderHistoryLoadStarted = 566
case chatOlderHistoryLoadSucceeded = 567
case chatOlderHistoryLoadFailed = 568
// MARK: Notification feed (310-339)
case notificationFeedOpened = 310
case notificationFeedClosed = 311
case notificationFeedLoadStarted = 312
case notificationFeedLoadSucceeded = 313
case notificationFeedLoadFailed = 314
case notificationFeedLoadMoreStarted = 315
case notificationFeedLoadMoreSucceeded = 316
case notificationFeedItemOpened = 318
case notificationFeedItemMarkedRead = 319
case notificationFeedItemDismissed = 320
case notificationBadgeReconciled = 324
case notificationBadgeReconcileFailed = 325
case phonePushTestStarted = 327
case phonePushTestSucceeded = 328
case phonePushTestFailed = 329
case notificationFeedFilterChanged = 330
// MARK: Changes and diffs (340-369)
case changesOpened = 340
case changesClosed = 341
case changesSummaryLoadStarted = 342
case changesSummaryLoadSucceeded = 343
case changesSummaryLoadFailed = 344
case changedFilesLoadStarted = 345
case changedFilesLoadSucceeded = 346
case changedFilesLoadFailed = 347
case fileDiffLoadStarted = 348
case fileDiffLoadSucceeded = 349
case fileDiffLoadFailed = 350
/// Count is the zero-based file ordinal in the already-redacted list.
case fileDiffExpanded = 351
case fileDiffCacheHit = 352
case changedFileSelected = 353
case diffCopied = 354
// MARK: Artifacts and files (370-399)
case artifactListLoadStarted = 370
case artifactListLoadSucceeded = 371
case artifactListLoadFailed = 372
case artifactOpened = 373
case artifactDownloadStarted = 374
case artifactDownloadSucceeded = 375
case artifactDownloadFailed = 376
case artifactShareStarted = 377
case artifactShareSucceeded = 378
case artifactShareFailed = 379
case artifactPreviewFailed = 380
case artifactSearchChanged = 381
case artifactFolderOpened = 382
case artifactCopied = 383
case artifactQuickLookOpened = 385
case artifactCacheHit = 386
case artifactStreamInterrupted = 387
case artifactSaveStarted = 388
case artifactSaveSucceeded = 389
case artifactSaveFailed = 390
// MARK: Browser (400-429)
case browserListRefreshStarted = 400
case browserListRefreshSucceeded = 401
case browserListRefreshFailed = 402
case browserCreateStarted = 403
case browserCreateSucceeded = 404
case browserCreateFailed = 405
case browserStreamStartRequested = 406
case browserStreamStarted = 407
case browserStreamStartFailed = 408
case browserStreamStopped = 409
case browserStreamRestarted = 410
case browserNavigateStarted = 411
case browserNavigateSucceeded = 412
case browserNavigateFailed = 413
case browserBackRequested = 414
case browserForwardRequested = 415
case browserReloadRequested = 416
case browserDialogPresented = 417
case browserDialogResponded = 418
case browserFrameReceived = 419
case browserFrameDecodeFailed = 420
case browserStateReceived = 421
case browserViewportChanged = 422
case browserInputFailed = 423
case browserClosed = 424
case browserDialogResponseFailed = 425
case browserStopRequested = 426
case browserStateDecodeFailed = 427
case browserDialogDecodeFailed = 428
case browserClosedDecodeFailed = 429
// MARK: Settings, feedback, and diagnostics (430-449)
case settingsOpened = 430
case settingsClosed = 431
case feedbackSubmitStarted = 435
case feedbackSubmitSucceeded = 436
case feedbackSubmitFailed = 437
case crashReportingConsentChanged = 438
case analyticsUploadStarted = 441
case analyticsUploadSucceeded = 442
case analyticsUploadFailed = 443
case analyticsUploadDropped = 444
case analyticsConsentChanged = 445
case crashReportingStarted = 446
case crashReportingDisabled = 447
// MARK: Camera and media attachments (450-479)
case cameraAuthorizationRequested = 450
case cameraAuthorizationGranted = 451
case cameraAuthorizationDenied = 452
case qrScanStarted = 453
case qrScanSucceeded = 454
case qrScanFailed = 455
case qrScanCancelled = 456
case photoPickerOpened = 457
/// Count is the number of picker results returned.
case photoPickerSelected = 458
case photoPickerDismissed = 459
case attachmentPreparationStarted = 460
/// Count is the prepared attachment's byte size.
case attachmentPreparationSucceeded = 461
case attachmentPreparationFailed = 462
// MARK: Persistence and capability negotiation (480-519)
case pairedMacStoreOpened = 480
case pairedMacStoreOpenFailed = 481
case pairedMacStoreReadFailed = 482
case pairedMacStoreWriteFailed = 483
case draftRestored = 484
case draftSaved = 485
case draftPersistenceFailed = 486
case pairedMacStoreReadSucceeded = 487
case pairedMacStoreWriteSucceeded = 488
case pairedMacBackupRefreshStarted = 489
case pairedMacBackupRefreshSucceeded = 490
case pairedMacBackupRefreshFailed = 491
case pairedMacRestoreStarted = 492
case pairedMacRestoreSucceeded = 493
case pairedMacRestoreFailed = 494
case settingPersistenceFailed = 495
case draftDeleted = 496
case templatePersistenceFailed = 497
case pairedMacBackupWriteStarted = 498
case pairedMacBackupWriteSucceeded = 499
case capabilitySnapshotReceived = 500
case pairedMacBackupWriteFailed = 502
// MARK: Detailed setting mutations (520-559)
case displayAltScreenNoticeChanged = 520
case displayFolderTapChanged = 521
case displayHapticsChanged = 522
case taskComposerFeatureChanged = 523
case terminalFilesFeatureChanged = 524
case toastFeatureChanged = 525
case displayMissingFilesChanged = 526
case displayWorkspaceTitleWrappingChanged = 527
case displayWorkspacePreviewLinesChanged = 528
case terminalScrollbackRowsChanged = 529
case telemetrySharingChanged = 530
/// `c`: ``DiagnosticConnectionMethod`` the user switched to.
case connectionMethodPreferenceChanged = 531
/// Detail: ``DiagnosticAppEventDetail/toolbarConfigurationAction(_:)``.
case customToolbarChanged = 532
/// Detail: ``DiagnosticAppEventDetail/toolbarConfigurationAction(_:)``.
case terminalShortcutChanged = 533
case notificationPreferenceChanged = 534
case appDiagnosticsShared = 535
case networkDiagnosticsShared = 536
case toastPresented = 537
case toastCoalesced = 538
case toastQueued = 539
case toastDropped = 540
case toastDismissed = 541
case toastInteractionStarted = 542
case toastInteractionEnded = 543
// MARK: Iroh settings (610-639)
case irohSettingsOpened = 610
case irohSettingsClosed = 611
case irohRelayPreferenceChangeStarted = 612
case irohRelayPreferenceChangeSucceeded = 613
case irohRelayPreferenceChangeFailed = 614
case irohPathPreferenceChangeStarted = 615
case irohPathPreferenceChangeSucceeded = 616
case irohPathPreferenceChangeFailed = 617
case irohCustomRelayUpsertStarted = 618
case irohCustomRelayUpsertSucceeded = 619
case irohCustomRelayUpsertFailed = 620
case irohCustomRelayRemoveStarted = 621
case irohCustomRelayRemoveSucceeded = 622
case irohCustomRelayRemoveFailed = 623
case irohCustomRelayTestStarted = 624
case irohCustomRelayTestSucceeded = 625
case irohCustomRelayTestFailed = 626
case irohPrivatePathUpsertStarted = 627
case irohPrivatePathUpsertSucceeded = 628
case irohPrivatePathUpsertFailed = 629
case irohPrivatePathRemoveStarted = 630
case irohPrivatePathRemoveSucceeded = 631
case irohPrivatePathRemoveFailed = 632
case irohDiagnosticsCleared = 633
case verboseDiagnosticLoggingChanged = 634
case irohDiagnosticsShared = 635
case verboseDiagnosticsShared = 636
// MARK: Terminal composer, media, and feature delivery (640-669)
case terminalDraftStateChanged = 640
case terminalAttachmentStaged = 641
case terminalAttachmentRemoved = 642
case terminalAttachmentRejected = 643
case terminalImagePasteStarted = 644
case terminalImagePasteSucceeded = 645
case terminalImagePasteFailed = 646
case terminalViewportClearStarted = 647
case terminalViewportClearSucceeded = 648
case terminalViewportClearFailed = 649
case browserFrameAcknowledgementFailed = 650
case dictationStartRequested = 651
case dictationStarted = 652
case dictationStopRequested = 653
case dictationStopped = 654
case dictationCancelled = 655
case dictationUnavailable = 656
case dictationFirstResultReceived = 657
case dictationRecognitionFailed = 658
case dictationStopTimedOut = 659
// MARK: Appended persistence events
case pairedMacStoreWriteStarted = 660
// MARK: Appended connection reporting events
/// The configured connection method, recorded at composition and on every
/// foreground so any shared report window states it even after the ring
/// rolls past app launch. `c`: ``DiagnosticConnectionMethod``.
case connectionMethodConfigured = 661
/// The transport that actually carries the foreground connection, recorded
/// on connect and on every active-route change. `c`: ``DiagnosticTransportKind``.
case foregroundTransportSelected = 662
}
/// The user's configured connection method, mirrored from the settings picker
/// without account, address, or grant details.
public enum DiagnosticConnectionMethod: Int, Sendable, Codable, CaseIterable {
case automatic = 0
case tailscale = 1
}
/// High-level lifecycle state for one phone-controlled Simulator stream.
///
/// Values intentionally omit panel UUIDs, device names, workspace titles, and
/// frame contents. The associated ``DiagnosticEvent`` carries only a
/// process-local surface handle and bounded counters.
public enum DiagnosticSimulatorStreamLifecycle: Int, Sendable, Codable, CaseIterable {
case startRequested = 1
case started = 2
case locked = 3
case startFailed = 4
case stopRequested = 5
case stopped = 6
case closed = 7
case restartRequested = 8
case pausedForBackground = 9
case descriptorApplied = 10
/// The client's staleness watchdog saw a full silent interval (no frame
/// or keepalive) for an active stream and is re-requesting it.
case stalled = 11
case stopFailed = 12
}
/// Frame-pipeline state for the Simulator video stream.
public enum DiagnosticSimulatorFrameLifecycle: Int, Sendable, Codable, CaseIterable {
case readerAttached = 1
case readerMissing = 2
case copied = 3
case encodeFailed = 4
case sent = 5
case refused = 6
case cachedSent = 7
case subscriptionReasserted = 8
case received = 9
case staleIgnored = 10
case decodeFailed = 11
case imageDecoded = 12
case imageDecodeFailed = 13
case unknownPanel = 14
}
/// Input delivery state for phone-originated Simulator actions.
public enum DiagnosticSimulatorInputLifecycle: Int, Sendable, Codable, CaseIterable {
case queued = 1
case sent = 2
case accepted = 3
case failed = 4
case rejectedLocked = 5
case unavailable = 6
case invalidParameters = 7
case panelMissing = 8
case featureDisabled = 9
case blockedViewOnly = 10
}
/// Phone-originated Simulator input category.
public enum DiagnosticSimulatorInputKind: Int, Sendable, Codable, CaseIterable {
case pointer = 1
case text = 2
case hardwareButton = 3
}
/// Hardware button category for phone-originated Simulator actions.
public enum DiagnosticSimulatorHardwareButtonKind: Int, Sendable, Codable, CaseIterable {
case unknown = 0
case home = 1
case swipeHome = 2
case appSwitcher = 3
case lock = 4
case siri = 5
case sideButton = 6
case power = 7
case volumeUp = 8
case volumeDown = 9
case action = 10
case watchSideButton = 11
}
/// Pointer phase for phone-originated Simulator touch events.
public enum DiagnosticSimulatorPointerPhase: Int, Sendable, Codable, CaseIterable {
case began = 1
case moved = 2
case ended = 3
case tap = 4
}
/// Privacy-safe ownership state for a Simulator pane's active controller.
public enum DiagnosticSimulatorOwnershipState: Int, Sendable, Codable, CaseIterable {
case unowned = 0
case currentConnection = 1
case otherConnection = 2
case pendingHandshake = 3
case unknown = 4
}
/// Coordinate mapping state for a phone gesture before it leaves the device.
public enum DiagnosticSimulatorCoordinateState: Int, Sendable, Codable, CaseIterable {
case mapped = 1
case outsideImage = 2
case viewOnlyBlocked = 3
case zeroImage = 4
}
@@ -0,0 +1,93 @@
import Foundation
/// Immutable isolation boundary for one installed cmux iOS application.
///
/// The complete bundle identifier is the namespace. Distribution labels and
/// short development tags are deliberately not accepted here because either
/// can alias another installed app.
public struct MobileIOSAppNamespace: Equatable, Hashable, Sendable {
/// Exact bundle identifier that owns this namespace.
public let bundleIdentifier: String
/// Creates a namespace from one complete, validated iOS bundle identifier.
public init?(bundleIdentifier: String?) {
guard let bundleIdentifier else { return nil }
let trimmed = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty,
trimmed == bundleIdentifier,
trimmed == trimmed.lowercased(),
trimmed.count <= 255,
trimmed.contains("."),
trimmed.range(
of: #"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$"#,
options: .regularExpression
) != nil
else {
return nil
}
self.bundleIdentifier = trimmed
}
/// Resolves the exact iOS bundle paired with one Mac app instance.
///
/// Tagged Mac builds pair with the same tagged iOS development bundle.
/// The stable Mac instance pairs with the public App Store bundle. Invalid
/// tags fail closed instead of aliasing another installed iOS app.
public init?(pairedMacInstanceTag instanceTag: String?) {
let tag = instanceTag?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if let instanceTag, instanceTag != tag {
return nil
}
let bundleIdentifier = if tag.isEmpty || tag == "default" {
"com.cmux.app"
} else {
"dev.cmux.ios.\(tag)"
}
self.init(bundleIdentifier: bundleIdentifier)
}
/// The exact Keychain access group this app must claim after signing.
public func keychainAccessGroup(teamIdentifier: String) -> String {
"\(teamIdentifier).\(bundleIdentifier)"
}
/// A Keychain service that cannot collide with another installed bundle.
public func keychainService(base: String) -> String {
"\(base).\(bundleIdentifier)"
}
/// The only pairing URL scheme this bundle registers with iOS.
public var pairingURLScheme: String {
"cmux-ios-\(bundleIdentifier)"
}
/// Opaque server partition for data restored to this exact app bundle.
public var serverScope: String {
let encoded = Data(bundleIdentifier.utf8)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return "ios:v3:\(encoded)"
}
/// The only legacy backup collection that can be attributed to this bundle.
///
/// The App Store app owns the former unscoped release collection. Tagged
/// development bundles own their same-tag v2 collection. Beta, Internal,
/// and Demo intentionally adopt nothing because their old unscoped records
/// cannot be attributed without risking cross-build restore.
public var legacyBackupScope: MobileIOSLegacyBackupScope? {
if bundleIdentifier == "com.cmux.app" {
return .unscoped
}
let prefix = "dev.cmux.ios."
guard bundleIdentifier.hasPrefix(prefix),
let buildScope = MobileIOSBuildScope(
String(bundleIdentifier.dropFirst(prefix.count))
) else {
return nil
}
return .scoped(buildScope.serializedScope)
}
}
@@ -0,0 +1,16 @@
/// One unambiguous pre-v3 backup collection eligible for one-time adoption.
public enum MobileIOSLegacyBackupScope: Equatable, Sendable {
/// The former App Store collection that did not carry a scope header.
case unscoped
/// A former development collection identified by its exact v2 scope.
case scoped(String)
/// The legacy request header value, or `nil` for the unscoped collection.
public var headerValue: String? {
switch self {
case .unscoped: nil
case .scoped(let value): value
}
}
}
@@ -0,0 +1,42 @@
public enum MobileSimulatorFrameFormat: String, Codable, Equatable, Sendable {
case jpeg
case png
}
public struct MobileSimulatorFrameEvent: Codable, Equatable, Sendable {
public let panelID: String
public let sequence: UInt64
public let format: MobileSimulatorFrameFormat
public let pixelWidth: Int
public let pixelHeight: Int
public let displayScale: Double
public let dataBase64: String
public init(
panelID: String,
sequence: UInt64,
format: MobileSimulatorFrameFormat,
pixelWidth: Int,
pixelHeight: Int,
displayScale: Double,
dataBase64: String
) {
self.panelID = panelID
self.sequence = sequence
self.format = format
self.pixelWidth = pixelWidth
self.pixelHeight = pixelHeight
self.displayScale = displayScale
self.dataBase64 = dataBase64
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case sequence = "seq"
case format
case pixelWidth = "pixel_width"
case pixelHeight = "pixel_height"
case displayScale = "display_scale"
case dataBase64 = "data_base64"
}
}
@@ -0,0 +1,69 @@
public struct MobileSimulatorPanelDescriptor: Codable, Equatable, Identifiable, Sendable {
public var id: String { panelID }
public let panelID: String
public let workspaceID: String
public let title: String
public let selectedDeviceName: String?
public let selectedDeviceState: String?
public let status: String
public let isReady: Bool
public let supportsTouch: Bool
public let supportsKeyboard: Bool
public let supportsHardwareButtons: Bool
public let supportsRotation: Bool
public let ownerConnectionID: String?
/// Whether the receiving connection owns the pane's control lock.
/// `nil` means "not personalized": the descriptor was built for a shared
/// payload (state-sync rows, workspace lists) that fans out to every
/// phone, so it cannot say anything about *this* connection. Receivers
/// keep their last per-connection answer (stream start response,
/// `simulator.state` events, `mobile.simulator.list`) when this is `nil`.
public let isOwnedByCurrentConnection: Bool?
public init(
panelID: String,
workspaceID: String,
title: String,
selectedDeviceName: String?,
selectedDeviceState: String?,
status: String,
isReady: Bool,
supportsTouch: Bool,
supportsKeyboard: Bool,
supportsHardwareButtons: Bool,
supportsRotation: Bool,
ownerConnectionID: String? = nil,
isOwnedByCurrentConnection: Bool? = nil
) {
self.panelID = panelID
self.workspaceID = workspaceID
self.title = title
self.selectedDeviceName = selectedDeviceName
self.selectedDeviceState = selectedDeviceState
self.status = status
self.isReady = isReady
self.supportsTouch = supportsTouch
self.supportsKeyboard = supportsKeyboard
self.supportsHardwareButtons = supportsHardwareButtons
self.supportsRotation = supportsRotation
self.ownerConnectionID = ownerConnectionID
self.isOwnedByCurrentConnection = isOwnedByCurrentConnection
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case workspaceID = "workspace_id"
case title
case selectedDeviceName = "selected_device_name"
case selectedDeviceState = "selected_device_state"
case status
case isReady = "is_ready"
case supportsTouch = "supports_touch"
case supportsKeyboard = "supports_keyboard"
case supportsHardwareButtons = "supports_hardware_buttons"
case supportsRotation = "supports_rotation"
case ownerConnectionID = "owner_connection_id"
case isOwnedByCurrentConnection = "is_owned_by_current_connection"
}
}
@@ -0,0 +1,140 @@
public struct MobileSimulatorListParameters: Codable, Equatable, Sendable {
public let workspaceID: String?
public init(workspaceID: String? = nil) {
self.workspaceID = workspaceID
}
private enum CodingKeys: String, CodingKey {
case workspaceID = "workspace_id"
}
}
public struct MobileSimulatorStreamStartParameters: Codable, Equatable, Sendable {
public let panelID: String
public let workspaceID: String
public init(panelID: String, workspaceID: String) {
self.panelID = panelID
self.workspaceID = workspaceID
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case workspaceID = "workspace_id"
}
}
public struct MobileSimulatorPanelParameters: Codable, Equatable, Sendable {
public let panelID: String
public let workspaceID: String
public init(panelID: String, workspaceID: String) {
self.panelID = panelID
self.workspaceID = workspaceID
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case workspaceID = "workspace_id"
}
}
public enum MobileSimulatorPointerPhase: String, Codable, Equatable, Sendable {
case began
case moved
case ended
case tap
}
public struct MobileSimulatorPointerInput: Codable, Equatable, Sendable {
public let panelID: String
public let workspaceID: String
public let phase: MobileSimulatorPointerPhase
public let x: Double
public let y: Double
public init(
panelID: String,
workspaceID: String,
phase: MobileSimulatorPointerPhase,
x: Double,
y: Double
) {
self.panelID = panelID
self.workspaceID = workspaceID
self.phase = phase
self.x = x
self.y = y
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case workspaceID = "workspace_id"
case phase
case x
case y
}
}
public struct MobileSimulatorTextInput: Codable, Equatable, Sendable {
public let panelID: String
public let workspaceID: String
public let text: String
public init(panelID: String, workspaceID: String, text: String) {
self.panelID = panelID
self.workspaceID = workspaceID
self.text = text
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case workspaceID = "workspace_id"
case text
}
}
public enum MobileSimulatorHardwareButton: String, Codable, CaseIterable, Equatable, Sendable {
case home
case swipeHome
case appSwitcher
case lock
case siri
case sideButton
case power
case volumeUp
case volumeDown
case action
case watchSideButton
}
public struct MobileSimulatorButtonInput: Codable, Equatable, Sendable {
public let panelID: String
public let workspaceID: String
public let button: MobileSimulatorHardwareButton
public init(panelID: String, workspaceID: String, button: MobileSimulatorHardwareButton) {
self.panelID = panelID
self.workspaceID = workspaceID
self.button = button
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
case workspaceID = "workspace_id"
case button
}
}
public struct MobileSimulatorClosedEvent: Codable, Equatable, Sendable {
public let panelID: String
public init(panelID: String) {
self.panelID = panelID
}
private enum CodingKeys: String, CodingKey {
case panelID = "panel_id"
}
}
@@ -0,0 +1,23 @@
public struct MobileSimulatorStreamCapability: Sendable {
public static let current = MobileSimulatorStreamCapability()
public let identifier: String
public let inputIdentifier: String
public let ownershipIdentifier: String
/// The Mac re-emits `simulator.state` on a fixed cadence while a stream
/// session is active, so clients can treat event silence as staleness
/// without misreading a static Simulator screen as a dead stream.
public let keepaliveIdentifier: String
public init(
identifier: String = "simulator.stream.v1",
inputIdentifier: String = "simulator.input.v1",
ownershipIdentifier: String = "simulator.ownership.v1",
keepaliveIdentifier: String = "simulator.keepalive.v1"
) {
self.identifier = identifier
self.inputIdentifier = inputIdentifier
self.ownershipIdentifier = ownershipIdentifier
self.keepaliveIdentifier = keepaliveIdentifier
}
}
@@ -35,6 +35,43 @@ public struct MobileSyncCollectionID: RawRepresentable, Codable, Hashable, Senda
/// `mobile.workspace.list` payload (same snake_case wire names) plus an
/// explicit `sort_index` so list order syncs without positional inference.
public struct WorkspaceSyncRecord: MobileSyncRecord {
/// One surface row within a workspace.
public struct Surface: Codable, Equatable, Sendable {
/// Stable surface identifier.
public let surfaceID: String
/// Open surface-kind wire string.
public let kind: String
/// User-facing surface title.
public let title: String
/// Backing file path for file-based surfaces, when reported.
public let filePath: String?
/// Bounded checklist/status payload for todo surfaces.
public let todo: MobileTodoSnapshot?
/// Creates a surface row from its wire fields.
public init(
surfaceID: String,
kind: String,
title: String,
filePath: String?,
todo: MobileTodoSnapshot? = nil
) {
self.surfaceID = surfaceID
self.kind = kind
self.title = title
self.filePath = filePath
self.todo = todo
}
private enum CodingKeys: String, CodingKey {
case surfaceID = "surface_id"
case kind
case title
case filePath = "file_path"
case todo
}
}
/// One terminal row within a workspace.
public struct Terminal: Codable, Equatable, Sendable {
/// Stable terminal identifier.
@@ -104,6 +141,11 @@ public struct WorkspaceSyncRecord: MobileSyncRecord {
public let sortIndex: Int
/// Terminal rows belonging to this workspace, in spatial order.
public let terminals: [Terminal]
/// All surface rows belonging to this workspace, in spatial order.
/// `nil` when decoded from a Mac that predates surface inventory support.
public let surfaces: [Surface]?
/// Simulator panes belonging to this workspace, in spatial order.
public let simulators: [MobileSimulatorPanelDescriptor]
/// ``MobileSyncRecord`` identity: the workspace id.
public var syncID: String { id }
@@ -127,7 +169,9 @@ public struct WorkspaceSyncRecord: MobileSyncRecord {
lastActivityAt: Double,
hasUnread: Bool,
sortIndex: Int,
terminals: [Terminal]
terminals: [Terminal],
surfaces: [Surface]? = nil,
simulators: [MobileSimulatorPanelDescriptor] = []
) {
self.id = id
self.windowID = windowID
@@ -145,6 +189,8 @@ public struct WorkspaceSyncRecord: MobileSyncRecord {
self.hasUnread = hasUnread
self.sortIndex = sortIndex
self.terminals = terminals
self.surfaces = surfaces
self.simulators = simulators
}
public init(from decoder: any Decoder) throws {
@@ -168,6 +214,11 @@ public struct WorkspaceSyncRecord: MobileSyncRecord {
hasUnread = try container.decode(Bool.self, forKey: .hasUnread)
sortIndex = try container.decode(Int.self, forKey: .sortIndex)
terminals = try container.decode([Terminal].self, forKey: .terminals)
surfaces = try container.decodeIfPresent([Surface].self, forKey: .surfaces)
simulators = try container.decodeIfPresent(
[MobileSimulatorPanelDescriptor].self,
forKey: .simulators
) ?? []
}
private enum CodingKeys: String, CodingKey {
@@ -187,6 +238,8 @@ public struct WorkspaceSyncRecord: MobileSyncRecord {
case hasUnread = "has_unread"
case sortIndex = "sort_index"
case terminals
case surfaces
case simulators
}
}
@@ -0,0 +1,48 @@
/// A mobile surface kind identified by its open wire string.
///
/// Known kinds have static constants, while unknown raw values remain valid so
/// older clients can preserve and route surface kinds introduced by newer Macs.
public struct MobileSurfaceKind: RawRepresentable, Codable, Hashable, Sendable {
/// The surface kind's wire identifier.
public let rawValue: String
/// Creates a surface kind from its wire identifier.
/// - Parameter rawValue: The open surface-kind string.
public init(rawValue: String) {
self.rawValue = rawValue
}
/// Decodes the kind directly from its open wire string.
public init(from decoder: any Decoder) throws {
rawValue = try decoder.singleValueContainer().decode(String.self)
}
/// Encodes the kind directly as its open wire string.
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(rawValue)
}
/// A Ghostty terminal surface.
public static let terminal = MobileSurfaceKind(rawValue: "terminal")
/// A browser surface.
public static let browser = MobileSurfaceKind(rawValue: "browser")
/// A markdown preview surface.
public static let markdown = MobileSurfaceKind(rawValue: "markdown")
/// A file preview surface.
public static let filePreview = MobileSurfaceKind(rawValue: "filePreview")
/// A right-sidebar tool hosted as a surface.
public static let rightSidebarTool = MobileSurfaceKind(rawValue: "rightSidebarTool")
/// A custom sidebar hosted as a surface.
public static let customSidebar = MobileSurfaceKind(rawValue: "customSidebar")
/// An agent-session surface.
public static let agentSession = MobileSurfaceKind(rawValue: "agentSession")
/// A project surface.
public static let project = MobileSurfaceKind(rawValue: "project")
/// A browser surface owned by an extension.
public static let extensionBrowser = MobileSurfaceKind(rawValue: "extensionBrowser")
/// A workspace todo surface.
public static let todo = MobileSurfaceKind(rawValue: "todo")
/// A transient Cloud VM loading surface.
public static let cloudVMLoading = MobileSurfaceKind(rawValue: "cloudVMLoading")
}
@@ -116,14 +116,15 @@ public struct MobileSyncPairingPayload: Equatable, Sendable, Codable {
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(self)
let payload = Self.base64URLEncode(data)
guard let url = URL(string: "\(CmxPairingURLScheme.current)://pair?v=\(version)&payload=\(payload)") else {
guard let scheme = CmxPairingURLSchemeResolver().resolved?.rawValue,
let url = URL(string: "\(scheme)://pair?v=\(version)&payload=\(payload)") else {
throw MobileSyncPairingPayloadError.invalidURL
}
return url
}
public static func decodeURL(_ url: URL, now: Date = Date()) throws -> MobileSyncPairingPayload {
guard CmxPairingURLScheme.isPairingScheme(url.scheme),
guard CmxPairingURLScheme(rawValue: url.scheme) != nil,
url.host == "pair",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let encodedPayload = components.queryItems?.first(where: { $0.name == "payload" })?.value,
@@ -0,0 +1,32 @@
/// One bounded checklist item synced with a workspace todo surface.
public struct MobileTodoItem: Codable, Equatable, Identifiable, Sendable {
/// The maximum number of characters accepted for one item's normalized text.
public static let maxTextLength = 500
/// The Mac-owned stable item identifier.
public let id: String
/// The normalized item text.
public let text: String
/// The item's progress state.
public let state: MobileTodoItemState
/// Who created the item.
public let origin: MobileTodoItemOrigin
/// Creates a mobile checklist item.
/// - Parameters:
/// - id: The Mac-owned stable item identifier.
/// - text: The normalized item text.
/// - state: The item's progress state.
/// - origin: Who created the item.
public init(
id: String,
text: String,
state: MobileTodoItemState,
origin: MobileTodoItemOrigin
) {
self.id = id
self.text = text
self.state = state
self.origin = origin
}
}
@@ -0,0 +1,7 @@
/// The creator of a mobile checklist item.
public enum MobileTodoItemOrigin: String, Codable, CaseIterable, Sendable {
/// A person created the item.
case user
/// An agent created the item.
case agent
}
@@ -0,0 +1,18 @@
/// A mobile checklist item's progress state.
public enum MobileTodoItemState: String, Codable, CaseIterable, Sendable {
/// Work has not started.
case pending
/// Work is actively progressing.
case inProgress = "in_progress"
/// Work is complete.
case completed
/// The next state in the mobile tap cycle.
public var next: MobileTodoItemState {
switch self {
case .pending: .inProgress
case .inProgress: .completed
case .completed: .pending
}
}
}
@@ -0,0 +1,29 @@
/// The bounded todo payload attached to a synced todo surface.
public struct MobileTodoSnapshot: Codable, Equatable, Sendable {
/// The maximum number of checklist items carried by one mobile snapshot.
public static let maxItems = 50
/// The effective status after applying any valid manual override.
public let status: MobileTodoStatus
/// Whether the workspace opted out of showing its status lane.
public let statusHidden: Bool
/// Checklist items in the Mac's storage order.
public let items: [MobileTodoItem]
/// Creates a todo snapshot.
/// - Parameters:
/// - status: The effective workspace status.
/// - statusHidden: Whether status presentation is hidden.
/// - items: Checklist items in storage order.
public init(status: MobileTodoStatus, statusHidden: Bool, items: [MobileTodoItem]) {
self.status = status
self.statusHidden = statusHidden
self.items = items
}
private enum CodingKeys: String, CodingKey {
case status
case statusHidden = "status_hidden"
case items
}
}
@@ -0,0 +1,20 @@
/// A workspace's effective todo status on the mobile wire.
public enum MobileTodoStatus: String, Codable, CaseIterable, Sendable {
/// Work has not started.
case todo
/// Work is actively progressing.
case working
/// Work is waiting for attention or input.
case needsAttention = "needs-attention"
/// Work is ready for review.
case review
/// Work is complete.
case done
/// The next status in the same cycle used by the Mac todo controls.
public var next: MobileTodoStatus {
let statuses = Self.allCases
guard let index = statuses.firstIndex(of: self) else { return .todo }
return statuses[(index + 1) % statuses.count]
}
}
@@ -151,6 +151,7 @@ public struct TransportIncidentPolicy: Sendable {
/// Event codes that are failure candidates (subject to suppression rules).
public static let failureCodes: Set<DiagnosticEventCode> = [
.pairFail, .pairUnreachable, .error, .transportDialFailed,
.transportDialLegFailed,
.recoveryFailed, .endpointFailed, .relayPolicyRefreshFailed,
.sessionClosed, .routeUnavailable, .discoveryFailed, .admissionFailed,
.hostAuthenticationFailed, .rpcFailed,
@@ -0,0 +1,316 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite struct AppLogTests {
private func makeTempDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("app-log-tests-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
/// Poll the drain barrier until `expected` entries have been written.
private func waitForProcessed(_ log: AppLog, _ expected: Int) async throws {
for _ in 0..<300 {
if await log.processedCount() >= expected { return }
try await Task.sleep(nanoseconds: 10_000_000)
}
#expect(await log.processedCount() >= expected)
}
private func contents(of url: URL) throws -> String {
try String(contentsOf: url, encoding: .utf8)
}
@Test func routesEventsByDomain() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let networkURL = dir.appendingPathComponent("network.log")
let log = AppLog(appFileURL: appURL, networkFileURL: networkURL, buildStamp: "test")
log.ingest(DiagnosticEvent(
.simulatorStreamLifecycle,
surface: 7,
a: DiagnosticSimulatorStreamLifecycle.started.rawValue
))
log.ingest(DiagnosticEvent(.transportDialStarted, a: 1, c: 42))
log.ingest(DiagnosticEvent(
.appLifecycleChanged,
a: DiagnosticAppLifecyclePhase.background.rawValue
))
log.ingest(DiagnosticEvent(
.appFeatureAction,
a: DiagnosticAppEventKind.workspaceOpenSucceeded.rawValue
))
try await waitForProcessed(log, 4)
await log.flushForTesting()
let app = try contents(of: appURL)
let network = try contents(of: networkURL)
#expect(app.contains("simulatorStreamLifecycle") || app.contains("Simulator"))
#expect(!network.contains("Simulator"))
#expect(network.contains("dial") || network.contains("Dial"))
#expect(!app.contains("dial") && !app.contains("Dial"))
#expect(app.contains("workspaceOpenSucceeded"))
#expect(!network.contains("workspaceOpenSucceeded"))
// Cross-cutting context lands in both files.
let appLifecycleInApp = app.contains("lifecycle") || app.contains("Lifecycle")
let appLifecycleInNetwork = network.contains("lifecycle") || network.contains("Lifecycle")
#expect(appLifecycleInApp && appLifecycleInNetwork)
}
@Test func mirroredStringLinesLandInAppFile() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let networkURL = dir.appendingPathComponent("network.log")
let log = AppLog(appFileURL: appURL, networkFileURL: networkURL, buildStamp: "test")
log.mirrorAppLine("sim.stream state=1 panel=7")
try await waitForProcessed(log, 1)
#expect(try contents(of: appURL).contains("sim.stream state=1 panel=7"))
#expect(try !contents(of: networkURL).contains("sim.stream"))
}
/// A steady frame stream costs one line plus one summary, not a line per
/// frame; the summary flushes when the run breaks.
@Test func coalescesConsecutiveFrameEvents() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let log = AppLog(appFileURL: appURL, networkFileURL: nil, buildStamp: "test")
for sequence in 1...24 {
log.ingest(DiagnosticEvent(
.simulatorFrameLifecycle,
surface: 7,
a: DiagnosticSimulatorFrameLifecycle.received.rawValue,
b: sequence
))
}
log.ingest(DiagnosticEvent(
.simulatorStreamLifecycle,
surface: 7,
a: DiagnosticSimulatorStreamLifecycle.stalled.rawValue
))
try await waitForProcessed(log, 25)
let lines = try contents(of: appURL).split(separator: "\n")
let frameLines = lines.filter { $0.contains("frame pipeline") }
// First occurrence + one coalesced summary, flushed by the stalled
// event that broke the run.
#expect(frameLines.count == 2)
#expect(frameLines.last?.contains("×24") == true)
#expect(lines.last?.contains("Stalled") == true)
}
/// Exceeding the byte budget moves the current generation to a unique
/// archive and keeps writing to a fresh active file.
@Test func rotatesWhenExceedingMaxBytes() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let log = AppLog(
appFileURL: appURL,
networkFileURL: nil,
maxFileBytes: 512,
buildStamp: "test"
)
for index in 0..<40 {
log.mirrorAppLine("filler line \(index) 0123456789 0123456789")
}
try await waitForProcessed(log, 40)
let generations = AppLog.logFileURLs(for: appURL)
let archives = generations.filter { $0 != appURL }
#expect(!archives.isEmpty)
let active = try contents(of: appURL)
#expect(active.contains("cmux app log"))
#expect(archives.contains { (try? contents(of: $0).contains("filler line")) == true })
}
@Test func ordersArchivesByEmbeddedGenerationStamp() throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let olderArchive = dir.appendingPathComponent(
"app.archive-0000000001000-AAAAAAAA.log"
)
let newerArchive = dir.appendingPathComponent(
"app.archive-0000000002000-BBBBBBBB.log"
)
let unparseableArchive = dir.appendingPathComponent(
"app.archive-unparseable.log"
)
try Data("active\n".utf8).write(to: appURL)
try Data("older\n".utf8).write(to: olderArchive)
try Data("newer\n".utf8).write(to: newerArchive)
try Data("unparseable\n".utf8).write(to: unparseableArchive)
let distantFuture = Date(timeIntervalSince1970: 9_000)
let distantPast = Date(timeIntervalSince1970: 1_000)
try FileManager.default.setAttributes(
[.modificationDate: distantFuture],
ofItemAtPath: olderArchive.path
)
try FileManager.default.setAttributes(
[.modificationDate: distantPast],
ofItemAtPath: newerArchive.path
)
try FileManager.default.setAttributes(
[.modificationDate: distantFuture],
ofItemAtPath: unparseableArchive.path
)
let generations = AppLog.logFileURLs(for: appURL)
#expect(generations.map(\.lastPathComponent) == [
appURL.lastPathComponent,
newerArchive.lastPathComponent,
olderArchive.lastPathComponent,
unparseableArchive.lastPathComponent,
])
}
@Test func reopensExistingGenerationAcrossLaunches() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let firstLaunch = AppLog(
appFileURL: appURL,
networkFileURL: nil,
buildStamp: "test"
)
firstLaunch.mirrorAppLine("first launch")
try await waitForProcessed(firstLaunch, 1)
let secondLaunch = AppLog(
appFileURL: appURL,
networkFileURL: nil,
buildStamp: "test"
)
secondLaunch.mirrorAppLine("second launch")
try await waitForProcessed(secondLaunch, 1)
let persisted = try contents(of: appURL)
#expect(persisted.contains("first launch"))
#expect(persisted.contains("second launch"))
#expect(persisted.components(separatedBy: "cmux app log").count == 2)
#expect(!FileManager.default.fileExists(
atPath: appURL.appendingPathExtension("1").path
))
}
@Test func migratesLegacyRotationWithoutDeletingIt() throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let legacyURL = appURL.appendingPathExtension("1")
try Data("legacy generation\n".utf8).write(to: legacyURL)
_ = AppLog(appFileURL: appURL, networkFileURL: nil, buildStamp: "test")
#expect(!FileManager.default.fileExists(atPath: legacyURL.path))
let generations = AppLog.logFileURLs(for: appURL)
#expect(generations.contains { (try? contents(of: $0).contains("legacy generation")) == true })
}
@Test func boundsTimestampedArchiveCount() async throws {
let dir = try makeTempDirectory()
defer { try? FileManager.default.removeItem(at: dir) }
let appURL = dir.appendingPathComponent("app.log")
let log = AppLog(
appFileURL: appURL,
networkFileURL: nil,
maxFileBytes: 160,
buildStamp: "test",
maxArchiveCount: 2,
maxRetainedBytes: 480
)
for index in 0..<100 {
log.mirrorAppLine("bounded line \(index) 0123456789")
}
try await waitForProcessed(log, 100)
let archives = AppLog.logFileURLs(for: appURL).filter { $0 != appURL }
#expect(archives.count <= 2)
#expect(!archives.isEmpty)
let totalBytes = AppLog.logFileURLs(for: appURL).reduce(0) { result, url in
let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize
return result + (size ?? 0)
}
#expect(totalBytes <= 480)
}
/// A failed size rotation (busy file, read-only directory) must append to
/// the existing log instead of truncating away diagnostics a user may be
/// about to share.
@Test func failedRotationAppendsInsteadOfTruncating() async throws {
let dir = try makeTempDirectory()
defer {
try? FileManager.default.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: dir.path
)
try? FileManager.default.removeItem(at: dir)
}
let appURL = dir.appendingPathComponent("app.log")
try Data("previous-generation marker\n".utf8).write(to: appURL)
// A read-only parent makes the `.1` move fail while the existing
// file itself stays writable.
try FileManager.default.setAttributes(
[.posixPermissions: 0o555],
ofItemAtPath: dir.path
)
let log = AppLog(
appFileURL: appURL,
networkFileURL: nil,
maxFileBytes: 32,
buildStamp: "test"
)
log.mirrorAppLine("post-failure line 1")
log.mirrorAppLine("post-failure line 2")
log.mirrorAppLine("post-failure line 3")
try await waitForProcessed(log, 3)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: dir.path
)
let contents = try contents(of: appURL)
#expect(contents.contains("previous-generation marker"))
#expect(contents.contains("post-failure line 1"))
#expect(contents.contains("post-failure line 3"))
// The fallback appends no session header: a sustained rotate failure
// must not grow the file by one header per retried line.
#expect(!contents.contains("cmux app log"))
#expect(!FileManager.default.fileExists(
atPath: appURL.appendingPathExtension("1").path
))
}
@Test func classificationCoversNetworkPlane() {
#expect(DiagnosticEventCode.transportDialFailed.appLogDomain == .network)
#expect(DiagnosticEventCode.transportDialPlanBuilt.appLogDomain == .network)
#expect(DiagnosticEventCode.transportPrivateAddressJoin.appLogDomain == .network)
#expect(DiagnosticEventCode.transportLANDiscovery.appLogDomain == .network)
#expect(DiagnosticEventCode.transportDialLegSucceeded.appLogDomain == .network)
#expect(DiagnosticEventCode.transportDialLegFailed.appLogDomain == .network)
#expect(DiagnosticEventCode.lanPublicationState.appLogDomain == .network)
#expect(DiagnosticEventCode.sessionClosed.appLogDomain == .network)
#expect(DiagnosticEventCode.discoveryFailed.appLogDomain == .network)
#expect(DiagnosticEventCode.relayPolicyRefreshFailed.appLogDomain == .network)
#expect(DiagnosticEventCode.simulatorInputLifecycle.appLogDomain == .app)
#expect(DiagnosticEventCode.browserStreamLifecycle.appLogDomain == .app)
#expect(DiagnosticEventCode.composerViewAppear.appLogDomain == .app)
#expect(DiagnosticEventCode.appFeatureAction.appLogDomain == .app)
#expect(DiagnosticEventCode.appLifecycleChanged.appLogDomain == .both)
#expect(DiagnosticEventCode.reachabilityChanged.appLogDomain == .both)
}
}
@@ -4,6 +4,9 @@ import Testing
private let compactIrohQRCoder = CmxAttachTicketCompactCoder()
private let compactIrohQREndpointID = String(repeating: "c", count: 64)
private let compactIrohQRTarget = CmxPairingURLScheme(
iOSBundleIdentifier: "dev.cmux.app.beta"
)!
private func compactIrohQRExpiry() -> Date {
Date(timeIntervalSince1970: 4_000_000_000)
@@ -88,11 +91,12 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
#expect(hints.isEmpty)
let pairingURL = try #require(CmxPairingQRCode().encode(
ticket,
routeDisclosureMode: .irohIdentityOnly
routeDisclosureMode: .irohIdentityOnly,
pairingURLScheme: compactIrohQRTarget
))
#expect(
pairingURL
== "\(CmxPairingURLScheme.current)://attach?v=3&i=\(compactIrohQREndpointID)"
== "\(compactIrohQRTarget.rawValue)://attach?v=3&i=\(compactIrohQREndpointID)"
)
#expect(!pairingURL.contains("payload="))
#expect(!pairingURL.contains("mac-1"))
@@ -105,19 +109,21 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
URLComponents(url: parsedURL, resolvingAgainstBaseURL: false)
)
let pairingDecoded = try CmxPairingQRCode().decode(components)
#expect(pairingDecoded.routes == [
try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(endpointID: compactIrohQREndpointID),
pathHints: []
)
),
])
let expectedPairingRoute = try CmxAttachRoute(
id: "iroh",
kind: .iroh,
endpoint: .peer(
identity: CmxIrohPeerIdentity(endpointID: compactIrohQREndpointID),
pathHints: []
)
)
#expect(pairingDecoded.routes == [expectedPairingRoute])
#expect(pairingDecoded.macDeviceID.isEmpty)
#expect(pairingDecoded.macDisplayName == nil)
#expect(pairingDecoded.macUserID == nil)
// Endpoint-only v3 codes intentionally omit compatibility metadata. Keep
// that absence distinguishable from an explicitly incompatible version.
#expect(pairingDecoded.macPairingCompatibilityVersion == nil)
#expect(pairingDecoded.macAppVersion == nil)
#expect(pairingDecoded.macAppBuild == nil)
#expect(pairingDecoded.expiresAt == nil)
@@ -128,7 +134,7 @@ private func compactIrohQRHostPortRoute() throws -> CmxAttachRoute {
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
let beforeURL =
"\(CmxPairingURLScheme.current)://attach?v=1&payload=\(compactBase64)"
"\(compactIrohQRTarget.rawValue)://attach?v=1&payload=\(compactBase64)"
let beforeImage = try #require(CmxPairingQRBitmap().makeImage(payload: beforeURL))
let afterImage = try #require(CmxPairingQRBitmap().makeImage(payload: pairingURL))
let quietZone = CmxPairingQRBitmap.quietZoneModules * 2
@@ -0,0 +1,197 @@
import Foundation
import Testing
@testable import CMUXMobileCore
@Suite
struct CmxIrohConnectionCheckReportTests {
@Test
func mobileReportsReadyForAnAuthenticatedPrivateVPNPath() {
let report = CmxIrohConnectionCheckReport(
role: .mobileClient,
snapshot: snapshot(
runtimeStatus: .privateNetwork(displayName: ""),
selectedPath: .privateNetwork,
hasMac: true
),
diagnostics: .empty,
relayReachability: .reachable,
macDiscovery: .found
)
#expect(report.isReady)
#expect(report.recommendation == .none)
#expect(report.stages.allSatisfy { $0.status == .passed })
}
@Test
func relayFailureExplainsCorporateNetworkAllowlisting() {
let report = CmxIrohConnectionCheckReport(
role: .mobileClient,
snapshot: snapshot(runtimeStatus: .active, hasMac: true),
diagnostics: diagnosticFailure(.timedOut),
relayReachability: .unreachable,
macDiscovery: .found
)
#expect(!report.isReady)
#expect(report.recommendation == .allowRelayTraffic)
#expect(report.stages.first { $0.kind == .relayReachability }?.status == .failed)
}
@Test
func relayConfigurationFailureTakesPriorityOverGenericAccountAdvice() {
let brokenRelaySnapshot = CmxIrohSettingsSnapshot(
runtimeStatus: .degraded,
preference: .automatic,
managedRelays: [],
customRelays: [],
policySource: .server,
failureDescription: "redacted relay configuration failure"
)
let report = CmxIrohConnectionCheckReport(
role: .macHost,
snapshot: brokenRelaySnapshot,
diagnostics: .empty,
relayReachability: .unavailable
)
#expect(report.recommendation == .reviewRelaySettings)
}
@Test
func missingMacIsDistinguishedFromAReachableRelay() {
let report = CmxIrohConnectionCheckReport(
role: .mobileClient,
snapshot: snapshot(runtimeStatus: .active),
diagnostics: .empty,
relayReachability: .reachable,
macDiscovery: .missing
)
#expect(report.recommendation == .openMacApp)
#expect(report.stages.first { $0.kind == .macDiscovery }?.status == .failed)
}
@Test
func unavailableRelayProbeFailsClosedWhileNoRelayConfigurationIsOptional() {
let unavailable = CmxIrohConnectionCheckReport(
role: .macHost,
snapshot: snapshot(runtimeStatus: .active),
diagnostics: .empty,
relayReachability: .unavailable
)
let notConfigured = CmxIrohConnectionCheckReport(
role: .macHost,
snapshot: snapshot(runtimeStatus: .active),
diagnostics: .empty,
relayReachability: .notConfigured
)
#expect(!unavailable.isReady)
#expect(
unavailable.stages.first { $0.kind == .relayReachability }?.status == .failed
)
#expect(unavailable.recommendation == .retry)
#expect(notConfigured.isReady)
#expect(
notConfigured.stages.first { $0.kind == .relayReachability }?.status
== .notApplicable
)
}
@Test
func administrativelyDisabledRelayFailsSessionWithRetryNotITAdvice() {
// Direct Only transport mode maps to a not-configured relay at the
// call sites: the relay stage reads Not Needed, and a dead direct
// path recommends retrying instead of contacting corporate IT.
let report = CmxIrohConnectionCheckReport(
role: .mobileClient,
snapshot: snapshot(runtimeStatus: .active, hasMac: true),
diagnostics: .empty,
relayReachability: .notConfigured,
macDiscovery: .found
)
#expect(
report.stages.first { $0.kind == .relayReachability }?.status == .notApplicable
)
#expect(report.stages.first { $0.kind == .secureSession }?.status == .failed)
#expect(report.recommendation == .retry)
}
@Test
func unavailableProbeNeverAdvisesCorporateAllowlisting() {
// An unavailable probe means the runtime was inactive or its path
// hints were unreadable, not that a relay was probed and blocked.
let inactiveRuntime = CmxIrohConnectionCheckReport(
role: .macHost,
snapshot: snapshot(runtimeStatus: .inactive),
diagnostics: .empty,
relayReachability: .unavailable
)
#expect(inactiveRuntime.recommendation == .refreshAccount)
}
@Test
func relayAllowlistOriginsRejectCredentialsAndNonRootURLs() {
#expect([
"https://relay.example.test/",
"https://relay.example.test",
"https://relay.example.test:443",
"https://user:[email protected]",
"https://relay.example.test/private",
"https://relay.example.test?token=secret",
"http://relay.example.test",
].cmxIrohCanonicalRelayOrigins() == [
"https://relay.example.test",
"https://relay.example.test:443",
])
}
@Test
func macReadinessDoesNotRequireAnActivePhoneSession() {
let report = CmxIrohConnectionCheckReport(
role: .macHost,
snapshot: snapshot(runtimeStatus: .active),
diagnostics: .empty,
relayReachability: .reachable
)
#expect(report.isReady)
#expect(report.stages.first { $0.kind == .secureSession }?.status == .notApplicable)
}
private func snapshot(
runtimeStatus: CmxIrohSettingsSnapshot.RuntimeStatus,
selectedPath: CmxIrohSelectedTransportPath = .unavailable,
hasMac: Bool = false
) -> CmxIrohSettingsSnapshot {
CmxIrohSettingsSnapshot(
runtimeStatus: runtimeStatus,
selectedTransportPath: selectedPath,
preference: .automatic,
managedRelays: [],
customRelays: [],
privateNetworkMacs: hasMac ? [.init(id: "mac", displayName: "Mac")] : [],
policySource: .server
)
}
private func diagnosticFailure(_ kind: DiagnosticFailureKind) -> DiagnosticReport {
DiagnosticReport(
role: .mobileClient,
generatedAt: Date(timeIntervalSince1970: 1),
anchorWallNanos: 1,
anchorMonotonicNanos: 1,
events: [
DiagnosticEvent(
code: .transportDialFailed,
tNanos: 2,
a: Int(DiagnosticTransportKind.iroh.rawValue),
b: Int(kind.rawValue)
)
]
)
}
}
@@ -18,16 +18,22 @@ struct CmxIrohSettingsSnapshotTests {
defaults.set("relayOnly", forKey: CmxIrohPathPreference.defaultsKey)
#expect(CmxIrohPathPreference.stored(in: defaults) == .relayOnly)
defaults.set("neverUseRelays", forKey: CmxIrohPathPreference.defaultsKey)
#expect(CmxIrohPathPreference.stored(in: defaults) == .neverUseRelays)
defaults.set("unknown", forKey: CmxIrohPathPreference.defaultsKey)
#expect(CmxIrohPathPreference.stored(in: defaults) == .automatic)
}
@Test func pathPreferenceMapsToTransportVerificationMode() {
@Test func retiredPathPreferencesNormalizeToAutomaticTransport() {
#expect(
CmxIrohPathPreference.automatic.transportVerificationMode == .automatic
)
#expect(
CmxIrohPathPreference.relayOnly.transportVerificationMode == .relayOnly
CmxIrohPathPreference.relayOnly.transportVerificationMode == .automatic
)
#expect(
CmxIrohPathPreference.neverUseRelays.transportVerificationMode == .directOnly
)
}
@@ -47,9 +53,18 @@ struct CmxIrohSettingsSnapshotTests {
customRelays: [],
policySource: .server
)
let neverUseRelays = CmxIrohSettingsSnapshot(
runtimeStatus: .active,
preference: .automatic,
pathPreference: .neverUseRelays,
managedRelays: [],
customRelays: [],
policySource: .server
)
#expect(automatic.pathPreference == .automatic)
#expect(relayOnly.pathPreference == .relayOnly)
#expect(neverUseRelays.pathPreference == .neverUseRelays)
}
@Test

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