diff --git a/.github/contributor/welcome.md b/.github/contributor/welcome.md
new file mode 100644
index 000000000000..c34690a0f7dc
--- /dev/null
+++ b/.github/contributor/welcome.md
@@ -0,0 +1,10 @@
+Thanks for opening your first cmux pull request!
+
+We're a small team and the outside-PR queue is long, so a reply can take a while — sometimes longer than we'd like. If this one goes quiet and you'd like eyes on it, comment here and we'll pick it up.
+
+A few things that help:
+
+- The PR template has a "Review Trigger" block of review-bot mentions. Pasting it as a comment after your latest commit is the quickest way to get automated review.
+- If the CLA check asks, reply with the sentence it gives you.
+- The [verification ladder](https://github.com/manaflow-ai/cmux/blob/main/docs/contributor-verification.md) shows which checks fit your change. Say in the description which ones you ran.
+- If we end up fixing the same problem another way, we'll credit you with a `Co-authored-by` trailer and link the fix here.
diff --git a/.github/review-bot-rules/test-determinism.md b/.github/review-bot-rules/test-determinism.md
index 38ee953d6a3a..ad5dca891093 100644
--- a/.github/review-bot-rules/test-determinism.md
+++ b/.github/review-bot-rules/test-determinism.md
@@ -10,6 +10,7 @@ This gate enforces two principles:
Report a failure when the changed test code introduces or materially expands any of these:
- A fixed `sleep`/`usleep`/`Task.sleep`/`setTimeout`/`Thread.sleep`/`time.sleep` used to wait for async readiness before an assertion (the `sleep(0.3); assert` shape that fails on correct code under load).
+- A poll of a condition bounded by an iteration count of `Task.yield()` (or any other reschedule) instead of a deadline, such as `for _ in 0..<100 { if ready { break }; await Task.yield() }`. A yield waits for nothing, so N yields shrinks to microseconds on an idle machine and gives no fixed budget under load: a busy runner turns a slow pass into a failure. Bound the poll by a clock deadline, or await the real signal.
- An assertion on a measured wall-clock duration, or a hard absolute latency ceiling on shared CI.
- Reading `Date()` / `Date.now` / `CACurrentMediaTime()` / `perf_counter` / `performance.now()` in an assertion.
- Binding a fixed non-zero port, or hitting a live network host instead of a local fake or ephemeral server.
diff --git a/.github/test-determinism-allowlist.txt b/.github/test-determinism-allowlist.txt
index 575a707e9d7e..1b76939e4b46 100644
--- a/.github/test-determinism-allowlist.txt
+++ b/.github/test-determinism-allowlist.txt
@@ -17,3 +17,61 @@ Packages/Shared/CmuxIrxTransport/Tests/CmuxIrxTransportTests/IrxRelayCredentialI
cmuxTests/MobileHostConnectionEventLaneTests.swift sleep-then-assert upstream event-lane timing tests; replace with event-driven readiness
cmuxTests/MobileHostConnectionLifecycleTests.swift sleep-then-assert upstream connection lifecycle timing tests; replace with event-driven readiness
web/tests/iroh-dashboard-controller.test.ts sleep-then-assert upstream dashboard render timing tests; replace with event-driven readiness
+Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticLogTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/TerminalWorkIntervalTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorPostSignInTimeoutTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorSignInExchangePreflightTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorSignInPreflightTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTimeoutTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTokenTouchingPhaseCancellationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityEngineTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxConnectivityPeerSessionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohClientRuntimeTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohEndpointServerTests+Capacity.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimeFailedRestartTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohHostRuntimePolicyTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohOnlineAdmissionRegistryOfflineTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohRegistryContextProviderStalenessTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxIrohTransport/Tests/CmuxIrohTransportTests/CmxIrohServerSessionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/Shared/CmuxSentryTelemetry/Tests/CmuxSentryReportingTests/TransportSentryReporterTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/CancellationIgnoringTokenProvider.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCSessionPipelinedTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/iOS/CmuxMobileRPC/Tests/CmuxMobileRPCTests/MobileCoreRPCTokenTimeoutTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileTaskModelCatalogClientTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalLaneReplayBarrierTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalRawInputOrderingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/iOS/CmuxMobileToast/Tests/CmuxMobileToastTests/ToastCenterTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlPlaneConcurrencyTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/Concurrency/MainActorCoalescingDeadlineTimerTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSidebarGit/Tests/CmuxSidebarGitTests/ProbeSchedulingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorCameraCleanupDeadlineTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientCameraCleanupTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientContainmentTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientLifecycleTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientQueueTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientRecoveryTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientStagedReplayTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorWorkerClientWheelRecoveryTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorAccessibilityPresentationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorLiveStatusWatcherTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorCancellationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorLocationLifecycleTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorOverflowTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorPaneCoordinatorTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorProcessSessionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorUITests/SimulatorWebInspectorCoordinatorTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorWorkerTests/SimulatorToolOperationSchedulingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorWorkerTests/SimulatorWebInspectorServiceFailureTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoReviewRegressionTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/CloudWireGuardHubTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/DockShortcutRoutingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/FilePreviewKindResolverTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/MobileHostAuthorizationTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/MobileHostConnectionEventLaneTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/MobileHostOrderedInputTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/RemoteTmuxPaneSeedTransportTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/SidebarScrollViewConfiguratorTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/SidebarWorkspaceTableTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/TextBoxInlineAttachmentRenderingTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
+cmuxTests/WindowAndDragTests.swift yield-count-poll polls bounded by a Task.yield() count; convert to a deadline-bounded poll (#13903)
diff --git a/.github/workflows/ci-artifact-transport.yml b/.github/workflows/ci-artifact-transport.yml
index 7d4e8a76bd5a..b490d6812f35 100644
--- a/.github/workflows/ci-artifact-transport.yml
+++ b/.github/workflows/ci-artifact-transport.yml
@@ -62,7 +62,7 @@ concurrency:
jobs:
transport:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/ci-cache-receipts.yml b/.github/workflows/ci-cache-receipts.yml
index 8ebd3005f985..fe37ece70a81 100644
--- a/.github/workflows/ci-cache-receipts.yml
+++ b/.github/workflows/ci-cache-receipts.yml
@@ -36,7 +36,7 @@ concurrency:
jobs:
receipt-contract:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
diff --git a/.github/workflows/ci-guards.yml b/.github/workflows/ci-guards.yml
index e90310ab6b39..992009985e4c 100644
--- a/.github/workflows/ci-guards.yml
+++ b/.github/workflows/ci-guards.yml
@@ -30,7 +30,7 @@ jobs:
fail-fast: false
matrix:
group: ${{ fromJSON(inputs.linux_guard_test_groups) }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# Its three sibling guard jobs are bounded at 15; 65 recent runs peaked at 0.8 min.
timeout-minutes: 15
# This job executes scripts from the pull request. It needs only read
@@ -159,6 +159,10 @@ jobs:
if: ${{ matrix.group == 'preflight' }}
run: python3 tests/test_write_sidebar_extension_point.py
+ - name: Validate settings rows advertise supported cmux.json paths
+ if: ${{ matrix.group == 'preflight' }}
+ run: python3 tests/test_settings_configuration_review_paths.py
+
- name: Validate Localizable.xcstrings catalog structure
if: ${{ matrix.group == 'preflight' }}
run: python3 tests/test_localizable_xcstrings_structure.py
@@ -335,6 +339,10 @@ jobs:
if: ${{ matrix.group == 'ci' }}
run: python3 tests/test_ci_health_report.py
+ - name: Validate runner label policy
+ if: ${{ matrix.group == 'ci' }}
+ run: python3 tests/test_runner_label_policy.py
+
- name: Validate the trusted web complexity workflow
if: ${{ matrix.group == 'ci' }}
run: python3 tests/test_web_complexity_trusted_workflow.py
@@ -469,6 +477,10 @@ jobs:
if: ${{ matrix.group == 'app-host-process' }}
run: python3 tests/test_ci_app_host_failure_census.py
+ - name: Validate app-host verdict replay
+ if: ${{ matrix.group == 'app-host-process' }}
+ run: python3 tests/test_ci_replay_app_host_verdict.py
+
- name: Validate app-host result ratchet
if: ${{ matrix.group == 'app-host-process' }}
env:
@@ -545,6 +557,10 @@ jobs:
if: ${{ matrix.group == 'release-ios' }}
run: python3 tests/test_ios_appstore_lane_identity.py
+ - name: Validate the cmux.app upload marker follows the receipt
+ if: ${{ matrix.group == 'release-ios' }}
+ run: python3 tests/test_ios_appstore_upload_marker.py
+
- name: Validate TestFlight upload argument expansion
if: ${{ matrix.group == 'release-ios' }}
run: python3 tests/test_ios_upload_array_expansion.py
@@ -703,6 +719,10 @@ jobs:
python3 tests/test_ci_change_areas.py
python3 tests/test_ci_linux_guard_routing.py
+ - name: Validate fork runner routing
+ if: ${{ matrix.group == 'ci' }}
+ run: python3 tests/test_ci_fork_runner_routing.py
+
- name: Validate evidence collection reporting
if: ${{ matrix.group == 'ci' }}
run: python3 tests/test_ci_evidence_outcomes.py
@@ -717,6 +737,7 @@ jobs:
python3 tests/test_ci_guard_workflow_structure.py
python3 tests/test_app_host_test_products.py
python3 tests/test_reuse_app_host_products.py
+ python3 tests/test_e2e_warm_derived_data.py
python3 tests/test_ci_product_publication.py
- name: Validate Python R2 appcast upload guard
@@ -790,7 +811,7 @@ jobs:
# The lockfile policy compares the candidate with the exact base parent of
# GitHub's synthetic PR/merge-group commit. Depth 2 contains HEAD and that
# parent; the policy does not need the rest of repository history.
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
contents: read
@@ -839,7 +860,7 @@ jobs:
group: [tui-resolution, profiling]
# The two slow CLI contracts are independent; run them beside each other
# and let the reusable-workflow job aggregate their result.
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
contents: read
@@ -874,7 +895,7 @@ jobs:
group: [sidebar-layout, dispatch-ownership]
# These source-policy scans are independent and each dominates wall time;
# run them concurrently and aggregate through the reusable-workflow job.
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
contents: read
@@ -918,7 +939,7 @@ jobs:
- workflow-guard-cli-scripts
- workflow-guard-source-lints
if: ${{ always() }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Check routed guard jobs
diff --git a/.github/workflows/ci-health-report.yml b/.github/workflows/ci-health-report.yml
index 6a11599d524a..9eeaabad1376 100644
--- a/.github/workflows/ci-health-report.yml
+++ b/.github/workflows/ci-health-report.yml
@@ -73,4 +73,29 @@ jobs:
# whenever b is falsy, so the skip has to be written as the negative:
# only a dispatch that did not ask to skip passes the issue through.
CI_HEALTH_REPORT_ISSUE: ${{ (inputs.skip_issue != true) && vars.CI_HEALTH_REPORT_ISSUE || '' }}
+ # Every other number here is measured from jobs that already ran, so
+ # it can only show a runner variable drifting after the minutes are
+ # spent. This is the configuration itself. The expression context
+ # serves variable values without any token scope, which is what lets
+ # a report whose token is `actions: read` see them at all.
+ #
+ # Named one by one rather than `toJSON(vars)`: a step's env is printed
+ # at the top of its public log, and only runner labels belong there.
+ # tests/test_runner_label_policy.py fails if a workflow reads a
+ # runner variable this list leaves out. The background lane carries
+ # its fallback because test_ci_self_hosted_guard.sh requires it on
+ # every read; `macos-15` is an approved label either way.
+ CMUX_CI_RUNNER_VARIABLES: |
+ LINUX_ARM64_RUNNER=${{ vars.LINUX_ARM64_RUNNER }}
+ LINUX_RUNNER=${{ vars.LINUX_RUNNER }}
+ MACOS_RUNNER_15=${{ vars.MACOS_RUNNER_15 }}
+ MACOS_RUNNER_26=${{ vars.MACOS_RUNNER_26 }}
+ MACOS_RUNNER_26_LARGE=${{ vars.MACOS_RUNNER_26_LARGE }}
+ MACOS_RUNNER_BACKGROUND=${{ vars.MACOS_RUNNER_BACKGROUND || 'macos-15' }}
+ MACOS_RUNNER_DISPLAY=${{ vars.MACOS_RUNNER_DISPLAY }}
+ MACOS_RUNNER_DUAL_XCODE=${{ vars.MACOS_RUNNER_DUAL_XCODE }}
+ MACOS_RUNNER_IOS=${{ vars.MACOS_RUNNER_IOS }}
+ MACOS_RUNNER_PR=${{ vars.MACOS_RUNNER_PR }}
+ MACOS_RUNNER_TESTS=${{ vars.MACOS_RUNNER_TESTS }}
+ WINDOWS_RUNNER=${{ vars.WINDOWS_RUNNER }}
run: python3 scripts/ci/ci_health_report.py
diff --git a/.github/workflows/ci-macos-compat.yml b/.github/workflows/ci-macos-compat.yml
index f73269fa5632..d1f4a993f945 100644
--- a/.github/workflows/ci-macos-compat.yml
+++ b/.github/workflows/ci-macos-compat.yml
@@ -27,7 +27,7 @@ jobs:
skip_zig: false
expected_arch: x86_64
expected_os_major: "15"
- - os: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ - os: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout: 120
run_unit_tests: false
run_mobile_transport_tests: true
diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml
index a14576c8d34f..4dec6d4ec3ba 100644
--- a/.github/workflows/ci-macos.yml
+++ b/.github/workflows/ci-macos.yml
@@ -18,6 +18,16 @@ on:
required: false
default: ""
type: string
+ # Label-derived selection for `app-host unit tests`. The full suite
+ # already implies it; this lets `unit-ci` ask for compile admission plus
+ # that one job, which reads the product admission already built, without
+ # waking the package, lag, release-admission and Release-build lanes.
+ # Optional and defaulted for the same reason as swift_packages: a caller
+ # that predates this input leaves it empty, which reads as "not routed".
+ unit_suite:
+ required: false
+ default: ""
+ type: string
product_artifacts:
required: false
default: "layered"
@@ -63,7 +73,7 @@ jobs:
# cannot fan out across every macOS worker and a successful build is not
# repeated six times.
if: ${{ inputs.macos == 'true' && inputs.compile_admitted != 'true' }}
- runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 75
permissions:
contents: read
@@ -91,7 +101,7 @@ jobs:
# Part of the compiled product contract, so it has to name the pool this
# job actually ran on: two pools lay the workspace out differently, and a
# product built under one cannot be relocated into the other.
- CMUX_PRODUCT_RUNNER: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ CMUX_PRODUCT_RUNNER: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
steps:
- name: Reject stale pull request rerun
if: ${{ github.event_name == 'pull_request' }}
@@ -596,6 +606,7 @@ jobs:
id: publish-products
env:
PRODUCT_FULL_SUITE: ${{ inputs.full_suite }}
+ PRODUCT_UNIT_SUITE: ${{ inputs.unit_suite }}
PRODUCT_EVENT: ${{ github.event_name }}
PRODUCT_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
PRODUCT_REPOSITORY: ${{ github.repository }}
@@ -604,8 +615,12 @@ jobs:
import os
head = os.environ.get("PRODUCT_HEAD_REPOSITORY", "").strip()
repository = os.environ.get("PRODUCT_REPOSITORY", "").strip()
+ # `unit-ci` makes app-host unit tests a consumer of this product
+ # under the compile-only policy, so the fork product stops being
+ # unused and must still be packaged and uploaded.
unused_fork_product = (
os.environ.get("PRODUCT_FULL_SUITE") == "false"
+ and os.environ.get("PRODUCT_UNIT_SUITE") != "true"
and os.environ.get("PRODUCT_EVENT") == "pull_request"
and bool(head) and bool(repository)
and head.casefold() != repository.casefold()
@@ -950,14 +965,14 @@ jobs:
# jobs that legitimately skip (web/go/agent-session paths), and that
# transitive skip otherwise marks every macOS job skipped even when
# linux-preflight itself succeeds. Require the direct needs explicitly.
- if: ${{ !cancelled() && needs.macos-compile-admission.result == 'success' && inputs.macos == 'true' && inputs.full_suite == 'true' }}
+ if: ${{ !cancelled() && needs.macos-compile-admission.result == 'success' && inputs.macos == 'true' && (inputs.full_suite == 'true' || inputs.unit_suite == 'true') }}
name: app-host unit tests (${{ matrix.shard }}/7)
# App-host XCTest needs a runner that can broker testmanagerd control
# sessions, so route through the shared MACOS_RUNNER_15 var like the other
# macOS jobs. The fallback is what fork pull requests get, because
# repository variables are not exposed to them, so it names the same
# Blacksmith pool main uses and never the paid overflow provider.
- runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 75
strategy:
# A pull request wants every shard's failures in one run. A merge group
@@ -1691,9 +1706,35 @@ jobs:
LOGICAL_SHARDS=("$PHYSICAL_SHARD" "$((PHYSICAL_SHARD + PHYSICAL_SHARD_TOTAL))")
# Each worker runs two balanced batches sequentially. Every invocation
- # writes to a regular file; a separately-owned tail process mirrors
- # that file into the Actions log. Detached test descendants therefore
- # cannot retain the CI capture pipe after xcodebuild exits.
+ # writes to a regular file, and this script mirrors that file into the
+ # Actions log itself. Detached test descendants therefore cannot retain
+ # the CI capture pipe after xcodebuild exits.
+ #
+ # Mirroring in-process rather than from a background `tail -f` is what
+ # makes the last write observable. A tail had to be given some margin
+ # to deliver it before being killed, and a loaded runner could miss
+ # that margin and drop the final lines -- the ones saying why a batch
+ # died. Tracking the byte offset here has no margin to miss.
+ stream_offset=0
+ emit_batch_output() {
+ local file="$1" size
+ size="$(wc -c <"$file" 2>/dev/null || echo 0)"
+ size="${size//[[:space:]]/}"
+ if [ "${size:-0}" -gt "$stream_offset" ]; then
+ # Read exactly the byte range included in the size snapshot.
+ # Reading to EOF here would race a concurrent writer: bytes
+ # appended after wc(1) could be emitted now while stream_offset
+ # advances only to the old size, duplicating them next poll.
+ python3 -c '
+ import sys
+ path, start, end = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
+ with open(path, "rb") as stream:
+ stream.seek(start)
+ sys.stdout.buffer.write(stream.read(end - start))
+ ' "$file" "$stream_offset" "$size"
+ stream_offset="$size"
+ fi
+ }
run_unit_test_batch() {
local logical_shard="$1"
local execution_attempt="$2"
@@ -1744,6 +1785,7 @@ jobs:
echo "Running app-host unit-test batch ${logical_shard}/${LOGICAL_SHARD_TOTAL}, execution ${execution_attempt}"
: >"$batch_output"
+ stream_offset=0
CMUX_TAG="$batch_tag" \
scripts/ci/run-in-console-session.sh \
scripts/ci/run-app-host-xcodebuild.sh \
@@ -1757,8 +1799,6 @@ jobs:
test-without-building >"$batch_output" 2>&1 &
local xcodebuild_pid=$!
- tail -n +1 -f "$batch_output" &
- local stream_pid=$!
local timeout_seconds="${CMUX_UNIT_TEST_TIMEOUT_SECONDS:-900}"
local deadline=$((SECONDS + timeout_seconds))
local timed_out=0
@@ -1793,6 +1833,7 @@ jobs:
timed_out=1
break
fi
+ emit_batch_output "$batch_output"
sleep 5
done
@@ -1805,9 +1846,7 @@ jobs:
batch_status=124
fi
- sleep 0.2
- kill "$stream_pid" 2>/dev/null || true
- wait "$stream_pid" 2>/dev/null || true
+ emit_batch_output "$batch_output"
shopt -s nullglob
local typed_results=("$result_bundle_root"/cmux-app-host-xcodebuild-"$batch_tag"-pid-*.tests.json)
@@ -2193,7 +2232,7 @@ jobs:
ghostty_helper_toolchain_sha256: ${{ steps.ghostty-helper-identity.outputs.toolchain_sha256 }}
ghostty_helper_sdk: ${{ steps.ghostty-helper-identity.outputs.sdk }}
# Build the release helper with SDK 15, then run package tests with SDK 26.
- runs-on: ${{ vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_DUAL_XCODE || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 40
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
@@ -2651,7 +2690,7 @@ jobs:
if: ${{ !cancelled() && needs.macos-compile-admission.result == 'success' && inputs.macos == 'true' && inputs.full_suite == 'true' }}
# Reuse compile admission's app and UI test products; this runner only
# performs display and runtime verification.
- runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 75
env:
CMUX_NODE_PRODUCT_CACHE_ROOT: ${{ vars.CMUX_NODE_PRODUCT_CACHE_ROOT }}
@@ -2664,7 +2703,7 @@ jobs:
steps:
- name: Validate display runner identity
env:
- REQUESTED_RUNNER: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15' }}
+ REQUESTED_RUNNER: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_DISPLAY || 'blacksmith-6vcpu-macos-15') }}
RUNNER_CONTEXT_NAME: ${{ runner.name }}
run: |
set -euo pipefail
@@ -3007,7 +3046,7 @@ jobs:
- swift-package-tests
- macos-compile-admission
if: ${{ !cancelled() && needs.swift-package-tests.result == 'success' && needs.macos-compile-admission.result == 'success' && inputs.release_build == 'true' && inputs.full_suite == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 20
steps:
- name: Wait for routed Linux preflight
@@ -3087,9 +3126,9 @@ jobs:
# Keep the app build on macOS 26 so SDK-gated SwiftUI Liquid Glass code
# compiles into the same artifact shape as nightly and stable releases.
# Release builds need enough disk for a universal Release build plus the
- # restored SwiftPM cache. Default to a clean paid macOS 26 runner instead
- # of the generic persistent macOS 26 pool.
- runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }}
+ # restored SwiftPM cache, which the macOS 26 image already carries. The
+ # variable names that image, not this lane.
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }}
timeout-minutes: 60
permissions:
actions: read
@@ -3099,7 +3138,7 @@ jobs:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_26 }}
CMUX_CI_REQUIRED_MACOS_SDK_MAJOR: "26"
CMUX_SKIP_ZIG_BUILD: "1"
- CMUX_PRODUCT_RUNNER: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }}
+ CMUX_PRODUCT_RUNNER: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }}
CMUX_RELEASE_SOURCE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }}
steps:
- name: Clear stale git locks (self-hosted reused workspace)
@@ -3478,7 +3517,7 @@ jobs:
- release-admission
- release-build
if: ${{ always() }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Check routed macOS jobs
@@ -3494,7 +3533,7 @@ jobs:
inputs = json.loads(os.environ["MACOS_INPUTS"])
needs = json.loads(os.environ["MACOS_NEEDS"])
# The caller leaves compile_admitted unset when its build-input reuse
- # steps are skipped (full suite) or fail; unset means "compile", the
+ # steps are skipped (full suite or unit-ci) or fail; unset means "compile", the
# same reading the macos-compile-admission job condition uses.
if inputs.get("compile_admitted") == "":
inputs["compile_admitted"] = "false"
@@ -3512,9 +3551,14 @@ jobs:
# The package lane is routed from changed paths, so it is required
# whenever the router selected it even under the compile-only suite.
swift_packages = inputs["swift_packages"] == "true"
+ # `unit-ci` asks for app-host unit tests without the full suite, and
+ # clears suite-coverage on the strength of them, so a skip here must
+ # fail rather than read as a job nobody routed. Empty or missing
+ # means a caller that predates the input.
+ unit_suite = inputs.get("unit_suite", "") == "true"
required = {
"macos-compile-admission": macos and inputs["compile_admitted"] != "true",
- "app-host-unit-tests": macos and full_suite,
+ "app-host-unit-tests": macos and (full_suite or unit_suite),
"swift-package-tests": (macos and full_suite) or swift_packages,
"tests-build-and-lag": macos and full_suite,
"release-admission": macos and full_suite and inputs["release_build"] == "true",
diff --git a/.github/workflows/ci-web.yml b/.github/workflows/ci-web.yml
index e10536e3623e..a4ff9e577d4f 100644
--- a/.github/workflows/ci-web.yml
+++ b/.github/workflows/ci-web.yml
@@ -19,7 +19,7 @@ permissions:
jobs:
web-subarea-scope:
if: ${{ inputs.web == 'true' || inputs.macos == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
outputs:
db: ${{ steps.scope.outputs.db }}
@@ -45,7 +45,7 @@ jobs:
web-typecheck:
needs: web-subarea-scope
if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.typecheck == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# Matches the 10-minute siblings in this workflow.
timeout-minutes: 10
defaults:
@@ -90,7 +90,7 @@ jobs:
web-production-build:
needs: web-subarea-scope
if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.production_build == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
defaults:
run:
@@ -129,7 +129,7 @@ jobs:
fail-fast: false
matrix:
shard: ["1/4", "2/4", "3/4", "4/4"]
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
defaults:
run:
@@ -154,7 +154,7 @@ jobs:
web-instant-navigation:
needs: web-subarea-scope
if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.instant == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
defaults:
run:
@@ -195,7 +195,7 @@ jobs:
react-apps-check:
needs: web-subarea-scope
if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.react_apps == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# Matches the 10-minute siblings in this workflow.
timeout-minutes: 10
steps:
@@ -235,7 +235,7 @@ jobs:
# Generated protocol and streaming benchmarks only run for their owned inputs.
needs: web-subarea-scope
if: ${{ (inputs.macos == 'true' || inputs.web == 'true') && needs.web-subarea-scope.outputs.diff_sidecar == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
steps:
- name: Checkout
@@ -274,7 +274,7 @@ jobs:
web-db-migrations:
needs: web-subarea-scope
if: ${{ inputs.web == 'true' && needs.web-subarea-scope.outputs.db == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# Matches web-production-build and diff-sidecar-check, the other 15s here.
timeout-minutes: 15
defaults:
@@ -327,7 +327,7 @@ jobs:
agent-session-web-resources:
if: ${{ inputs.agent_session_web == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
steps:
- name: Checkout
@@ -359,7 +359,7 @@ jobs:
- web-db-migrations
- agent-session-web-resources
if: ${{ always() }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Check routed web jobs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6a294afa87d7..97ccd03bdb37 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,7 +47,7 @@ concurrency:
jobs:
changes:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
outputs:
macos: ${{ steps.detect.outputs.macos }}
@@ -69,6 +69,7 @@ jobs:
linux_guard_source: ${{ steps.linux_guards.outputs.linux_guard_source }}
ghosttykit_release: ${{ steps.unchanged_inputs.outputs.compile_admitted == 'true' && 'false' || steps.linux_guards.outputs.ghosttykit_release }}
full_suite: ${{ steps.suite.outputs.full_suite }}
+ unit_suite: ${{ steps.suite.outputs.unit_suite }}
coverage_gap: ${{ steps.suite.outputs.coverage_gap }}
compile_admitted: ${{ steps.unchanged_inputs.outputs.compile_admitted == 'true' && 'true' || steps.admitted.outputs.compile_admitted }}
source_tree: ${{ steps.source-identity.outputs.tree }}
@@ -572,7 +573,9 @@ jobs:
# compile. Each pull request run publishes its build-input fingerprint as
# an artifact name, and a compile-only run whose fingerprint an earlier run
# of the same in-org branch already compiled skips compile admission. Runs
- # with the full suite always compile, because the shards need the product.
+ # with the full suite or unit-ci always compile, because the app-host
+ # shards need the product and only run behind a compile admission that
+ # succeeded in this run.
# The artifact name carries the run attempt, so a pass only vouches for the
# fingerprint published in its own attempt. These steps are an optimization:
# any of them failing leaves compile_admitted unset, which compiles.
@@ -595,7 +598,7 @@ jobs:
- name: Skip compile when build inputs are unchanged
id: unchanged_inputs
continue-on-error: true
- if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' }}
+ if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' && steps.suite.outputs.unit_suite != 'true' }}
env:
# Both steps are pull-request-only, and the xcode= extra exists so the
# fingerprint moves when the pinned toolchain does. Read the same lane
@@ -634,7 +637,7 @@ jobs:
- name: Look for an earlier run that compiled these inputs
id: admitted
continue-on-error: true
- if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' && steps.unchanged_inputs.outputs.compile_admitted != 'true' }}
+ if: ${{ steps.inputs.outputs.fingerprint != '' && steps.suite.outputs.full_suite == 'false' && steps.suite.outputs.unit_suite != 'true' && steps.unchanged_inputs.outputs.compile_admitted != 'true' }}
env:
GH_TOKEN: ${{ github.token }}
BRANCH: ${{ github.head_ref }}
@@ -698,7 +701,7 @@ jobs:
name: Fast static checks
# No package installs, submodules or app build: reject malformed inputs
# before starting the longer guard, web and macOS stages.
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
permissions:
contents: read
@@ -760,7 +763,7 @@ jobs:
permissions:
contents: read
# Check the pinned framework before using a Mac, and on provenance changes.
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -817,7 +820,7 @@ jobs:
name: Claude wrapper regressions
needs: [changes, static-preflight]
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && (needs.changes.outputs.claude_wrapper == 'true' || (needs.changes.outputs.macos == 'true' && needs.changes.outputs.full_suite == 'true')) }}
- runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 10
steps:
- name: Checkout wrapper and test inputs
@@ -860,16 +863,18 @@ jobs:
# the previous run through the concurrency group, but by then its macOS jobs
# have already been billed. Holding macOS admission briefly on a Linux runner
# lets a quick follow-up push cancel the run before any Mac starts. A stale
- # head SHA after the wait fails this run instead of admitting it. Merge groups
- # and dispatches are not debounced. CI_MACOS_ADMISSION_DEBOUNCE_SECONDS=0
- # turns the wait off.
+ # head SHA after the wait fails this run instead of admitting it, and so does
+ # a run that has already failed a job. Merge groups and dispatches are not
+ # debounced. A re-run attempt and CI_MACOS_ADMISSION_DEBOUNCE_SECONDS=0 both
+ # skip the wait and with it both checks.
macos-debounce:
name: macOS admission debounce
needs: changes
if: ${{ github.event_name == 'pull_request' && needs.changes.outputs.macos != 'false' && (needs.changes.outputs.full_suite == 'true' || needs.changes.outputs.compile_admitted != 'true') }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
permissions:
+ actions: read
pull-requests: read
steps:
- name: Wait for follow-up pushes
@@ -894,6 +899,28 @@ jobs:
echo "::error::PR head moved from $HEAD_SHA to $current; the newer run owns macOS admission."
exit 1
fi
+ # A job that has already failed has settled this run: the pull request
+ # is red on this head whatever a Mac finds, and the fix push opens a
+ # new run. Declining costs only work nobody was going to read.
+ #
+ # Decline the same way a moved head does, by failing. A job output
+ # would read better here, but `macos` is skipped either way and only a
+ # *failed* dependency makes "Re-run failed jobs" re-run it -- an
+ # output would strand the run red until someone re-ran everything.
+ #
+ # Only a concluded `failure` counts: `cancelled` means the run is
+ # already going away. An unreadable reply, an error body, or a first
+ # page that misses a later job all admit, like the check above.
+ if failed="$(gh api \
+ "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/jobs?per_page=100" \
+ --jq '[.jobs[] | select(.conclusion == "failure")] | length')" \
+ && [ -n "$failed" ] && [ "$failed" != "0" ]; then
+ echo "::error::$failed job(s) in this run already failed; not admitting macOS. Re-run failed jobs to collect macOS results anyway."
+ exit 1
+ fi
+ if [ -z "${failed:-}" ]; then
+ echo "Could not read this run's jobs; admitting macOS."
+ fi
echo "Head $HEAD_SHA is still current; admitting macOS."
macos:
@@ -910,7 +937,7 @@ jobs:
# already admitted or the macOS area is neutral (a Packages/iOS package
# outside the desktop closure). Every other job in the called workflow stays
# gated on inputs.macos, so nothing else wakes up with it.
- if: ${{ !cancelled() && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && (needs.macos-debounce.result == 'success' || needs.macos-debounce.result == 'skipped') && ((needs.changes.outputs.macos != 'false' && (needs.changes.outputs.full_suite == 'true' || needs.changes.outputs.compile_admitted != 'true')) || needs.changes.outputs.swift_packages == 'true') }}
+ if: ${{ !cancelled() && needs.changes.result == 'success' && needs.static-preflight.result == 'success' && (needs.macos-debounce.result == 'success' || needs.macos-debounce.result == 'skipped') && ((needs.changes.outputs.macos != 'false' && (needs.changes.outputs.full_suite == 'true' || needs.changes.outputs.unit_suite == 'true' || needs.changes.outputs.compile_admitted != 'true')) || needs.changes.outputs.swift_packages == 'true') }}
permissions:
actions: read
id-token: write
@@ -921,6 +948,7 @@ jobs:
with:
macos: ${{ needs.changes.outputs.macos }}
full_suite: ${{ needs.changes.outputs.full_suite }}
+ unit_suite: ${{ needs.changes.outputs.unit_suite }}
swift_packages: ${{ needs.changes.outputs.swift_packages }}
compile_admitted: ${{ needs.changes.outputs.compile_admitted }}
release_build: ${{ needs.changes.outputs.release_build }}
@@ -938,7 +966,7 @@ jobs:
- macos
- web
if: ${{ always() }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Check platform workflow routing
@@ -978,9 +1006,16 @@ jobs:
# and of the macOS area, so it can require the called workflow on its
# own. Keep this in sync with the `macos` job's own `if:`.
swift_packages = outputs.get("swift_packages") == "true"
+ # `unit-ci` asks for app-host unit tests without the full suite, so a
+ # routed macOS run is required even when compile admission reused a
+ # product and full_suite stayed false.
+ unit_suite = outputs.get("unit_suite") == "true"
macos_work_required = swift_packages or (
macos_route == "true"
- and not (full_suite == "false" and compile_admitted == "true")
+ and (
+ unit_suite
+ or not (full_suite == "false" and compile_admitted == "true")
+ )
)
if macos_work_required:
if macos_result != "success":
@@ -1016,7 +1051,7 @@ jobs:
# router explicitly says macOS is irrelevant, skip the runner allocation.
# Missing/invalid route output fails open by running the preflight.
if: ${{ always() && needs.changes.outputs.macos != 'false' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Check routed Linux results
@@ -1116,20 +1151,25 @@ jobs:
# that edits cmuxTests/ and skips the full suite runs none of the code it
# changed. The policy that allows the skip is written for a merge queue
# that no longer gates main, so refuse the run here instead of reporting
- # green on a diff nothing observed. Add "full-ci" to run the suite, or
- # "no-full-ci" to record that skipping it is deliberate.
+ # green on a diff nothing observed. A cmuxTests/ diff selects
+ # `app-host unit tests` by itself (choose_ci_suite.py), so it never lands
+ # here. What does is cmuxUITests/, which no pull request job executes, and
+ # a diff that could not be read: "full-ci" runs everything, and
+ # "no-full-ci" records that skipping it is deliberate.
needs:
- changes
if: ${{ !cancelled() && needs.changes.result == 'success' && needs.changes.outputs.coverage_gap == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
steps:
- name: Require the suite that judges this diff
run: |
- echo "This pull request changes cmuxTests/ or cmuxUITests/, and this run" >&2
- echo "skipped the macOS suite, so none of the changed tests executed." >&2
+ echo "This pull request changes cmuxUITests/, or its diff could not be" >&2
+ echo "read, and this run skipped the macOS suite, so nothing observed it." >&2
+ echo "(A cmuxTests/ change runs 'app-host unit tests' automatically.)" >&2
echo >&2
- echo "Add the 'full-ci' label to run the suite on a fresh run, or add" >&2
- echo "'no-full-ci' to record that skipping it is deliberate." >&2
+ echo "No pull request job executes cmuxUITests/. Add the 'full-ci' label" >&2
+ echo "to run the suite on a fresh run, or 'no-full-ci' to record that" >&2
+ echo "skipping it is deliberate." >&2
exit 1
ci-status:
@@ -1149,7 +1189,7 @@ jobs:
- macos
- tests
if: ${{ always() }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# One step that reads `needs` and decides. Its siblings are bounded at 5
# to 20 minutes; this one was inheriting the six-hour default, as the
# required check every pull request waits on.
diff --git a/.github/workflows/cli-pipe-regressions.yml b/.github/workflows/cli-pipe-regressions.yml
index 75d2efb6b481..065b454c4cd9 100644
--- a/.github/workflows/cli-pipe-regressions.yml
+++ b/.github/workflows/cli-pipe-regressions.yml
@@ -13,7 +13,7 @@ concurrency:
jobs:
cli-pipe-regressions:
- runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 30
env:
CMUX_CI_XCODE_APP: ${{ github.event_name == 'pull_request' && (vars.CMUX_CI_XCODE_APP_PR || vars.CMUX_CI_XCODE_APP_MACOS_15) || vars.CMUX_CI_XCODE_APP_MACOS_15 }}
diff --git a/.github/workflows/cloud-command-deadlines.yml b/.github/workflows/cloud-command-deadlines.yml
index 788abc0f9d56..18eceda5b803 100644
--- a/.github/workflows/cloud-command-deadlines.yml
+++ b/.github/workflows/cloud-command-deadlines.yml
@@ -38,7 +38,7 @@ concurrency:
cancel-in-progress: true
jobs:
command-regressions:
- runs-on: ${{ inputs.runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ inputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
diff --git a/.github/workflows/cloud-machine-tests.yml b/.github/workflows/cloud-machine-tests.yml
index 683c6b05a60e..e562dc253f12 100644
--- a/.github/workflows/cloud-machine-tests.yml
+++ b/.github/workflows/cloud-machine-tests.yml
@@ -33,7 +33,7 @@ jobs:
ref: ${{ inputs.ref }}
changes:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 2
outputs:
should_run: ${{ steps.route.outputs.should_run }}
@@ -99,7 +99,7 @@ jobs:
lifecycle:
needs: [changes, resolve-ref]
if: ${{ needs.changes.outputs.should_run == 'true' }}
- runs-on: ${{ inputs.runner || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ inputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/cloud-vm-image-contract.yml b/.github/workflows/cloud-vm-image-contract.yml
index 928091bb65bb..87bb1a1c3c08 100644
--- a/.github/workflows/cloud-vm-image-contract.yml
+++ b/.github/workflows/cloud-vm-image-contract.yml
@@ -50,7 +50,7 @@ concurrency:
jobs:
contract:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
defaults:
run:
working-directory: web
diff --git a/.github/workflows/cloud-vm-image-reachability.yml b/.github/workflows/cloud-vm-image-reachability.yml
index 49a28cfc6397..23f079f99d1a 100644
--- a/.github/workflows/cloud-vm-image-reachability.yml
+++ b/.github/workflows/cloud-vm-image-reachability.yml
@@ -45,7 +45,7 @@ concurrency:
jobs:
reachable:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
# The provider key lives in this environment, not in a repo-wide secret,
# so only a job that names it can read it. No reviewers and no branch
# policy, or the daily run and PR runs could not use it; fork PRs get no
diff --git a/.github/workflows/cmux-skill-contract.yml b/.github/workflows/cmux-skill-contract.yml
index ed3e6c9b72a2..dcc352e172ed 100644
--- a/.github/workflows/cmux-skill-contract.yml
+++ b/.github/workflows/cmux-skill-contract.yml
@@ -40,7 +40,7 @@ concurrency:
jobs:
browser-skill:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Checkout
diff --git a/.github/workflows/cmux-tui-build-package.yml b/.github/workflows/cmux-tui-build-package.yml
index 83e480945812..6f5081a9e97d 100644
--- a/.github/workflows/cmux-tui-build-package.yml
+++ b/.github/workflows/cmux-tui-build-package.yml
@@ -62,6 +62,11 @@ on:
required: false
default: false
type: boolean
+ build_agent_plugin:
+ description: "Build and upload the Unix userland agent screen-detection plugin"
+ required: false
+ default: false
+ type: boolean
permissions: {}
@@ -80,7 +85,7 @@ jobs:
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' }}
+ MACOS_RUNNER: ${{ inputs.macos_runner != '' && inputs.macos_runner || vars.CI_PAID_MACOS_OVERFLOW == '1' && 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 }}
@@ -279,7 +284,34 @@ jobs:
cargo build -p cmux-relay --bin cmux-relay --release --locked --target ${{ matrix.build_target }}
cargo build -p chatmux-relay --bin chatmux-relay --release --locked --target ${{ matrix.build_target }}
+ - name: Build agent screen-detection plugin
+ if: inputs.build_agent_plugin && runner.os != 'Windows'
+ env:
+ AGENT_PLUGIN_CROSS: ${{ matrix.cross }}
+ AGENT_PLUGIN_TARGET: ${{ matrix.build_target }}
+ RUNNER_OS: ${{ runner.os }}
+ working-directory: cmux-tui
+ shell: bash
+ run: |
+ set -euo pipefail
+ plugin_manifest="bindings/examples/rust-agent-screen-detection/Cargo.toml"
+ # The reference plugin is its own Cargo workspace. Use the common
+ # target directory so staging has one predictable path and the
+ # plugin can reuse SDK dependencies from the TUI build.
+ export CARGO_TARGET_DIR="$GITHUB_WORKSPACE/cmux-tui/target"
+ if [[ "$AGENT_PLUGIN_CROSS" == "true" && "$RUNNER_OS" == "Linux" ]]; then
+ cargo zigbuild \
+ --manifest-path "$plugin_manifest" \
+ --release --locked --target "$AGENT_PLUGIN_TARGET"
+ else
+ cargo build \
+ --manifest-path "$plugin_manifest" \
+ --release --locked --target "$AGENT_PLUGIN_TARGET"
+ fi
+
- name: Stage binary
+ env:
+ BUILD_AGENT_PLUGIN: ${{ inputs.build_agent_plugin }}
shell: bash
run: |
mkdir -p dist
@@ -290,6 +322,10 @@ jobs:
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"
cp "cmux-tui/target/${{ matrix.target }}/release/chatmux-relay${{ matrix.ext }}" "dist/chatmux-relay-${{ matrix.target }}${{ matrix.ext }}"
+ if [[ "$BUILD_AGENT_PLUGIN" == "true" && "${{ runner.os }}" != "Windows" ]]; then
+ cp "cmux-tui/target/${{ matrix.target }}/release/cmux-agent-screen-detection${{ matrix.ext }}" \
+ "dist/cmux-agent-screen-detection-${{ matrix.target }}${{ matrix.ext }}"
+ fi
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 }}"
@@ -356,6 +392,14 @@ jobs:
path: dist/chatmux-relay-*${{ matrix.ext }}
if-no-files-found: error
+ - name: Upload agent screen-detection plugin artifact
+ if: inputs.build_agent_plugin && runner.os != 'Windows'
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: cmux-agent-screen-detection-${{ matrix.target }}
+ path: dist/cmux-agent-screen-detection-*${{ matrix.ext }}
+ if-no-files-found: error
+
build-windows:
name: build x86_64-pc-windows-gnu
if: inputs.include_windows
diff --git a/.github/workflows/cmux-tui-sdks.yml b/.github/workflows/cmux-tui-sdks.yml
index 66dff216abea..7d87859f1016 100644
--- a/.github/workflows/cmux-tui-sdks.yml
+++ b/.github/workflows/cmux-tui-sdks.yml
@@ -79,7 +79,7 @@ permissions:
jobs:
contract:
name: protocol contract
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 8
outputs:
sdk_inputs: ${{ steps.sdk-inputs.outputs.changed }}
@@ -182,7 +182,7 @@ jobs:
name: ${{ matrix.language }} package
needs: contract
if: ${{ needs.contract.outputs.sdk_inputs == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 25
strategy:
fail-fast: false
@@ -356,7 +356,7 @@ jobs:
name: ${{ matrix.language }} consumer
needs: contract
if: ${{ needs.contract.outputs.sdk_inputs == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 25
strategy:
fail-fast: false
@@ -515,7 +515,7 @@ jobs:
name: Rust SDK MSRV (1.88)
needs: contract
if: ${{ needs.contract.outputs.sdk_inputs == 'true' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 25
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -530,6 +530,9 @@ jobs:
cargo +1.88.0 fmt --manifest-path cmux-tui/bindings/rust/Cargo.toml -- --check
cargo +1.88.0 test --manifest-path cmux-tui/bindings/rust/Cargo.toml --locked
cargo +1.88.0 test --manifest-path cmux-tui/bindings/rust-sidebar/Cargo.toml --locked
+ cargo +1.88.0 test \
+ --manifest-path cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml \
+ --locked
cargo +1.88.0 clippy \
--manifest-path cmux-tui/bindings/examples/rust-agent-dashboard/Cargo.toml \
--locked \
@@ -544,7 +547,7 @@ jobs:
conformance:
name: seven-language live conformance
needs: contract
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-8vcpu-ubuntu-2404' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/cmux-tui-spec.yml b/.github/workflows/cmux-tui-spec.yml
index 5b11f65a044c..6c10e7a8f3fc 100644
--- a/.github/workflows/cmux-tui-spec.yml
+++ b/.github/workflows/cmux-tui-spec.yml
@@ -22,7 +22,7 @@ permissions:
jobs:
inventory:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/cmux-tui.yml b/.github/workflows/cmux-tui.yml
index 0f1db7083c07..f63d8de85b9d 100644
--- a/.github/workflows/cmux-tui.yml
+++ b/.github/workflows/cmux-tui.yml
@@ -466,6 +466,19 @@ jobs:
- name: Test isolated TUI test-runner policy
run: python3 .github/scripts/test_run_cmux_tui_core_tests_isolated.py
+ - name: cargo fmt
+ id: rustfmt-check
+ working-directory: cmux-tui
+ run: cargo fmt --check
+
+ - name: userland agent plugin package tests
+ working-directory: cmux-tui
+ # The reference plugin is an independent Cargo workspace, so the
+ # daemon workspace test does not compile it. Keep this explicit check
+ # in both hosted OS lanes.
+ run: cargo test --manifest-path bindings/examples/rust-agent-screen-detection/Cargo.toml --locked
+
+
- name: focused Linux journal process-fence test
if: inputs.mode == 'focused' && runner.os == 'Linux'
working-directory: cmux-tui
@@ -819,6 +832,8 @@ jobs:
package_pypi: ${{ inputs.mode == 'full' }}
include_windows: ${{ inputs.mode == 'full' }}
target_set: ${{ inputs.mode == 'full' && 'all' || 'macos-arm64' }}
+ # Build the exact reference plugin beside the daemon artifact.
+ build_agent_plugin: true
verify_linux_arm64: ${{ inputs.mode == 'full' }}
macos_runner: blacksmith-6vcpu-macos-15
linux_runner: blacksmith-4vcpu-ubuntu-2404
diff --git a/.github/workflows/contributor-welcome.yml b/.github/workflows/contributor-welcome.yml
new file mode 100644
index 000000000000..b32134358628
--- /dev/null
+++ b/.github/workflows/contributor-welcome.yml
@@ -0,0 +1,70 @@
+name: Contributor welcome
+
+# First-time contributors used to hear only from bots. This posts one short,
+# human-written note (.github/contributor/welcome.md) when their first pull
+# request opens. It never checks out or runs PR code.
+on:
+ pull_request_target:
+ types: [opened]
+ branches: [main]
+
+permissions: {}
+
+jobs:
+ welcome:
+ if: >-
+ github.event.pull_request.user.type == 'User' &&
+ contains(fromJSON('["FIRST_TIME_CONTRIBUTOR","FIRST_TIMER","NONE"]'), github.event.pull_request.author_association)
+ runs-on: ubuntu-24.04 # github-hosted-required: trusted pull-request-write token
+ timeout-minutes: 5
+ permissions:
+ contents: read
+ pull-requests: write
+ steps:
+ - name: Post welcome note
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ AUTHOR: ${{ github.event.pull_request.user.login }}
+ WORKFLOW_SHA: ${{ github.workflow_sha }}
+ run: |
+ set -euo pipefail
+
+ # `author_association` answers "has no merged commit here", not "this
+ # is their first PR". cmux merges few outside PRs, so a persistent
+ # contributor stays FIRST_TIME_CONTRIBUTOR indefinitely and would be
+ # greeted again on every PR. Count their PRs instead; this one is
+ # always included, so more than one means it is not their first.
+ #
+ # Treat an unusable answer as "skip", not as an error: the search API
+ # can rate-limit or 422, and a non-numeric reply would otherwise abort
+ # the step under `set -e` and leave a red X on a newcomer's first PR.
+ # A missed greeting is recoverable; a wrong one or a red check is not.
+ if ! count=$(gh api -X GET search/issues \
+ -f q="repo:$GH_REPO is:pr author:$AUTHOR" --jq '.total_count' 2>/dev/null) \
+ || ! [ "$count" -eq "$count" ] 2>/dev/null; then
+ echo "Could not count pull requests for $AUTHOR; skipping."
+ exit 0
+ fi
+ if [ "$count" -gt 1 ]; then
+ echo "$AUTHOR has $count pull requests here; not a first PR, skipping."
+ exit 0
+ fi
+
+ # Search is eventually consistent, so it can undercount a burst of
+ # PRs opened together. The marker makes a second greeting impossible
+ # even when the count above is stale.
+ marker=''
+ if gh api "repos/$GH_REPO/issues/$PR/comments" --paginate --jq '.[].body' \
+ | grep -qF "$marker"; then
+ echo "Already welcomed on #$PR, skipping."
+ exit 0
+ fi
+
+ # Read the note from the commit this workflow was loaded from, so a
+ # pull request cannot rewrite the text it gets welcomed with.
+ gh api "repos/$GH_REPO/contents/.github/contributor/welcome.md?ref=$WORKFLOW_SHA" \
+ -H "Accept: application/vnd.github.raw" > welcome.md
+ printf '\n%s\n' "$marker" >> welcome.md
+ gh pr comment "$PR" --body-file welcome.md
diff --git a/.github/workflows/ios-appstore-upload.yml b/.github/workflows/ios-appstore-upload.yml
index 82312b338685..d7341bb329f5 100644
--- a/.github/workflows/ios-appstore-upload.yml
+++ b/.github/workflows/ios-appstore-upload.yml
@@ -352,18 +352,62 @@ jobs:
echo "Shipped CFBundleVersion: $FINAL_BN"
- name: Record completed upload before group assignment
+ id: record_upload
+ # The next scheduled poll skips this revision only if this marker
+ # exists. The upload step can fail after Apple accepted the IPA (the
+ # notes step did, hourly, in #13690), so a failed step still records
+ # the upload when asc's receipt says "uploaded": true. The build number
+ # file alone is not evidence: the script writes it before archiving.
+ if: ${{ always() && steps.upload.outcome != 'skipped' }}
env:
+ UPLOAD_OUTCOME: ${{ steps.upload.outcome }}
BUILD_NUMBER: ${{ steps.upload.outputs.final_build_number }}
+ BUILD_NUMBER_FILE: ${{ runner.temp }}/cmux-final-build-number.txt
+ UPLOAD_RECEIPT: ${{ runner.temp }}/cmux-ios-upload/upload.log
run: |
mkdir -p "$RUNNER_TEMP/cmux-app-upload-marker"
python3 - <<'PY'
- import json, os
+ import json, os, re
from pathlib import Path
- marker = {'sha': os.environ['GITHUB_SHA'], 'app_id': '6783338052', 'build_number': os.environ['BUILD_NUMBER']}
+
+ def accepted(path):
+ try:
+ text = Path(path).read_text(encoding='utf-8', errors='replace')
+ except OSError:
+ return False
+ # asc prints one JSON object; tolerate it pretty-printed or
+ # surrounded by other lines.
+ decoder = json.JSONDecoder()
+ for start in (m.start() for m in re.finditer(r'\{', text)):
+ try:
+ value, _ = decoder.raw_decode(text, start)
+ except ValueError:
+ continue
+ if isinstance(value, dict) and value.get('uploaded') is True:
+ return True
+ return False
+
+ build_number = os.environ.get('BUILD_NUMBER', '').strip()
+ if os.environ['UPLOAD_OUTCOME'] != 'success':
+ if not accepted(os.environ['UPLOAD_RECEIPT']):
+ print('No App Store Connect upload receipt; not recording an upload.')
+ raise SystemExit(0)
+ try:
+ build_number = Path(os.environ['BUILD_NUMBER_FILE']).read_text().strip()
+ except OSError:
+ build_number = ''
+ if not re.fullmatch(r'[0-9]+', build_number):
+ print(f'No numeric build number ({build_number!r}); not recording an upload.')
+ raise SystemExit(0)
+ marker = {'sha': os.environ['GITHUB_SHA'], 'app_id': '6783338052', 'build_number': build_number}
Path(os.environ['RUNNER_TEMP'], 'cmux-app-upload-marker', 'upload.json').write_text(json.dumps(marker))
+ with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as output:
+ output.write('recorded=true\n')
+ print(f'Recorded upload of build {build_number} for {os.environ["GITHUB_SHA"]}.')
PY
- name: Retain completed upload for assignment retries
+ if: ${{ always() && steps.record_upload.outputs.recorded == 'true' }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: cmux-app-testflight-upload
diff --git a/.github/workflows/ios-streamed-validate.yml b/.github/workflows/ios-streamed-validate.yml
index 4d04b64ac12a..eb287fdef67b 100644
--- a/.github/workflows/ios-streamed-validate.yml
+++ b/.github/workflows/ios-streamed-validate.yml
@@ -19,10 +19,10 @@ permissions:
jobs:
validate:
# This end-to-end validation is expensive and is run only on explicit
- # dispatch. Default to the Blacksmith image the iOS simulator tests use; the
- # selector below picks its Xcode 26 toolchain. Keep an override for a
- # future isolated runner migration.
- runs-on: ${{ vars.MACOS_RUNNER_STREAMED_VALIDATION || 'blacksmith-6vcpu-macos-26' }}
+ # dispatch. It wants the image the iOS simulator tests use, so it reads the
+ # variable that names that pool rather than one named after this lane; the
+ # selector below picks its Xcode 26 toolchain.
+ runs-on: ${{ vars.MACOS_RUNNER_IOS || 'blacksmith-6vcpu-macos-26' }}
timeout-minutes: 120
env:
CMUX_PORT: "3000"
diff --git a/.github/workflows/iroh-release-gate.yml b/.github/workflows/iroh-release-gate.yml
index c0c3314cb0b1..a7230ced6e44 100644
--- a/.github/workflows/iroh-release-gate.yml
+++ b/.github/workflows/iroh-release-gate.yml
@@ -32,7 +32,7 @@ jobs:
tailscale-version-skew:
needs: resolve-ref
name: Tailscale version-skew compatibility
- runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 75
env:
CMUX_CI_XCODE_APP: ${{ vars.CMUX_CI_XCODE_APP_MACOS_15 }}
@@ -77,7 +77,7 @@ jobs:
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 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 120
steps:
- name: Checkout
diff --git a/.github/workflows/localization-catalog.yml b/.github/workflows/localization-catalog.yml
index 86c16e870573..510f10538251 100644
--- a/.github/workflows/localization-catalog.yml
+++ b/.github/workflows/localization-catalog.yml
@@ -23,7 +23,7 @@ concurrency:
jobs:
catalog-structure:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 568156772ee8..4cd8a35c114c 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -393,7 +393,7 @@ jobs:
needs: decide
if: github.event_name == 'schedule' && github.event.schedule == '17 */6 * * *' || github.event_name == 'workflow_dispatch' && inputs.seed_only
# Match the PR Release-build runner and Xcode so its compilation cache is reusable.
- runs-on: ${{ vars.MACOS_RUNNER_26_RELEASE || 'blacksmith-6vcpu-macos-26' }}
+ runs-on: ${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }}
# This job rebuilds the cache after the store drops it, so it runs cold
# by design; scheduled run 35134963192 was cancelled at 45 minutes.
timeout-minutes: 90
@@ -711,7 +711,7 @@ jobs:
if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *') && needs.decide.outputs.build_only != 'true'
# Zig 0.15.2 cannot link the real helper on macOS 26. Match the stable
# release workflow by building and verifying it on macOS 15 instead.
- runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-6vcpu-macos-15' || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-6vcpu-macos-15' || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 20
env:
NIGHTLY_FAST_BUILD: ${{ needs.decide.outputs.fast_build }}
@@ -768,11 +768,13 @@ jobs:
daemon_build: ${{ steps.remote_daemon.outputs.build }}
daemon_version: ${{ steps.remote_daemon.outputs.version }}
if: needs.decide.outputs.should_build == 'true' && (github.event_name != 'schedule' || github.event.schedule == '47 8 * * *')
- # Full runs match the cache warmer and stable release lane. Fast branch
- # dogfood always uses the dedicated Blacksmith image. A repository-wide
- # runner override may point at a slower shared builder, which defeats the
- # purpose of the one-architecture path.
- runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-12vcpu-macos-26' || vars.MACOS_RUNNER_26_NIGHTLY_BUILD || 'blacksmith-6vcpu-macos-26' }}
+ # Full runs share the cache warmer's and stable release lane's image and
+ # toolchain, which is what the compilation cache is keyed on — OS, arch and
+ # toolchain, never instance size, which is deliberately larger here. Fast
+ # branch dogfood always uses the dedicated Blacksmith image. A
+ # repository-wide runner override may point at a slower shared builder,
+ # which defeats the purpose of the one-architecture path.
+ runs-on: ${{ needs.decide.outputs.fast_build == 'true' && 'blacksmith-12vcpu-macos-26' || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_26_LARGE || 'blacksmith-12vcpu-macos-26' }}
# The Blacksmith cache is scoped per branch and also drops main's own
# entry (runs 35179030871 and 35182663752 restored nothing and were
# cancelled at 45 minutes mid-compile), so the budget must fit a cold
diff --git a/.github/workflows/perf-activation.yml b/.github/workflows/perf-activation.yml
index e6af815412b0..c0e30d0fd2af 100644
--- a/.github/workflows/perf-activation.yml
+++ b/.github/workflows/perf-activation.yml
@@ -111,7 +111,7 @@ jobs:
activation-session-benchmark:
needs: activation_changes
if: ${{ needs.activation_changes.outputs.macos == 'true' }}
- runs-on: ${{ ((!inputs.runner || inputs.runner == 'auto') && (vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || inputs.runner) }}
+ runs-on: ${{ ((!inputs.runner || inputs.runner == 'auto') && (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') || inputs.runner) }}
timeout-minutes: 45
env:
PERF_TAG: perf-${{ github.run_id }}-${{ github.run_attempt }}
diff --git a/.github/workflows/plain-paste-worker.yml b/.github/workflows/plain-paste-worker.yml
index 41dd46f5e1cd..1e18897bada0 100644
--- a/.github/workflows/plain-paste-worker.yml
+++ b/.github/workflows/plain-paste-worker.yml
@@ -15,7 +15,7 @@ permissions:
contents: read
jobs:
macos-15:
- runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 10
defaults:
run:
diff --git a/.github/workflows/r2-upload-tests.yml b/.github/workflows/r2-upload-tests.yml
index cc823c420115..83f0de2643f0 100644
--- a/.github/workflows/r2-upload-tests.yml
+++ b/.github/workflows/r2-upload-tests.yml
@@ -23,7 +23,7 @@ concurrency:
jobs:
r2-upload-tests:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/relay-tls.yml b/.github/workflows/relay-tls.yml
index 84444dd03657..dd18689b14f7 100644
--- a/.github/workflows/relay-tls.yml
+++ b/.github/workflows/relay-tls.yml
@@ -20,7 +20,7 @@ permissions:
jobs:
diagnostic-presentation:
- runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -33,7 +33,7 @@ jobs:
swift test --package-path Packages/Shared/CmuxIrxTransport --filter IrxEndpointDiagnosticTests
system-keychain:
- runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b3bd71211738..bf25a034a73b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -39,7 +39,7 @@ jobs:
upload: false
build-ghostty-cli-helper:
- runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 20
steps:
- name: Clear stale git locks (self-hosted reused workspace)
diff --git a/.github/workflows/remote-daemon.yml b/.github/workflows/remote-daemon.yml
index 083475cda0d0..b566853c6c74 100644
--- a/.github/workflows/remote-daemon.yml
+++ b/.github/workflows/remote-daemon.yml
@@ -28,7 +28,7 @@ concurrency:
jobs:
remote-daemon-admission:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
permissions:
pull-requests: read
@@ -53,7 +53,7 @@ jobs:
remote-daemon-tests:
needs: remote-daemon-admission
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -77,7 +77,7 @@ jobs:
# request only Linux packaging validation for publishing-only changes.
if: ${{ github.event_name != 'pull_request' || inputs.native_tests }}
needs: remote-daemon-admission
- runs-on: ${{ vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/terminal-hang-diagnostics.yml b/.github/workflows/terminal-hang-diagnostics.yml
index 7fb8ed6e1f38..5f95ce671b0f 100644
--- a/.github/workflows/terminal-hang-diagnostics.yml
+++ b/.github/workflows/terminal-hang-diagnostics.yml
@@ -32,7 +32,7 @@ concurrency:
jobs:
portal-reconciliation:
- runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -44,7 +44,7 @@ jobs:
run: bash tests/run_terminal_portal_reconciliation_tests.sh
release-gate:
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -53,7 +53,7 @@ jobs:
- run: python3 tests/test_terminal_hang_release_gate.py
phase-attribution:
- runs-on: ${{ github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'macos-15' || (github.event_name == 'pull_request' && (vars.MACOS_RUNNER_PR || 'blacksmith-6vcpu-macos-15') || vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15') }}
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
diff --git a/.github/workflows/test-depot.yml b/.github/workflows/test-depot.yml
index 661d32f8908b..39f94bab6ee2 100644
--- a/.github/workflows/test-depot.yml
+++ b/.github/workflows/test-depot.yml
@@ -42,7 +42,7 @@ on:
default: false
type: boolean
unit_test_suites:
- description: "Comma-separated unit suites; empty runs the full unit target"
+ description: "Comma-separated unit suites, or Suite/testName (XCTest) / Suite/testName() (Swift Testing) for one test; empty runs the full unit target"
required: false
default: ""
test_filter:
@@ -235,12 +235,18 @@ jobs:
IFS=',' read -r -a suites <<< "$UNIT_TEST_SUITES"
local failed=0
for suite in "${suites[@]}"; do
- if [[ ! "$suite" =~ ^[A-Za-z0-9_]+$ ]]; then
+ # A suite, or one test in it: `Suite/testName` for XCTest,
+ # `Suite/testName()` for Swift Testing. Without the parens a
+ # Swift Testing selector matches nothing, and the positive-summary
+ # check below fails the run rather than reporting a pass.
+ if [[ ! "$suite" =~ ^[A-Za-z0-9_]+(/[A-Za-z0-9_]+(\(\))?)?$ ]]; then
echo "Invalid unit suite identifier" >&2
return 1
fi
local suite_status=0
- local output_path="$TEST_RESULTS_ROOT/$suite.log"
+ local log_name="${suite//\//.}"
+ log_name="${log_name%()}"
+ local output_path="$TEST_RESULTS_ROOT/$log_name.log"
set +e
run_unit_suite "$output_path" "-only-testing:cmuxTests/$suite"
suite_status=$?
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index 03b94ffa9e52..7c625b2f94a8 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -191,6 +191,11 @@ jobs:
build:
needs: [resolve-ref, filter]
runs-on: ${{ (!inputs.runner || inputs.runner == 'auto') && (vars.MACOS_RUNNER_TESTS || 'blacksmith-6vcpu-macos-26') || inputs.runner }}
+ # Reuse lists this contract's artifacts and downloads one from an earlier
+ # run. Nothing else in this lane reads the Actions API, and nothing writes.
+ permissions:
+ contents: read
+ actions: read
timeout-minutes: ${{ fromJSON(inputs.job_timeout || '45') }}
outputs:
artifact_id: ${{ steps.upload-product.outputs.artifact-id }}
@@ -281,28 +286,42 @@ jobs:
run: |
./scripts/install-rust-ci.sh
- - name: Identify the compiled product this revision needs
- id: product-key
- run: python3 scripts/ci/reuse_app_host_products.py key "$CMUX_DERIVED_DATA_PATH"
-
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: "1.3.14"
+ - name: Install zig
+ run: |
+ ./scripts/install-zig-ci.sh
+
+ # Every tool `contract()` fingerprints is installed by now. Computing the
+ # key earlier recorded some of them as absent, which made the published
+ # artifact's name disagree with the receipt sealed inside it, so nothing
+ # could ever adopt it.
+ - name: Identify the compiled product this revision needs
+ id: product-key
+ run: python3 scripts/ci/reuse_app_host_products.py key "$CMUX_DERIVED_DATA_PATH"
+
+ - name: Reuse a compiled product instead of building one
+ id: reuse
+ continue-on-error: true
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: python3 scripts/ci/reuse_app_host_products.py restore "$CMUX_DERIVED_DATA_PATH"
+
+ # Only an input to compiling, and not part of the product contract, so an
+ # adopted product never needs it.
- name: Download pre-built GhosttyKit.xcframework
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
./scripts/download-prebuilt-ghosttykit.sh
- - name: Install zig
- run: |
- ./scripts/install-zig-ci.sh
-
-
- name: Restore E2E compilation cache
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
id: compilation-cache-restore
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@@ -312,7 +331,7 @@ jobs:
restore-keys: e2e-compilation-v1-${{ runner.os }}-${{ runner.arch }}-all-${{ steps.compilation-cache-key.outputs.fingerprint }}-
- name: Discard incomplete E2E compilation cache
- if: ${{ steps.compilation-cache-restore.outcome == 'failure' }}
+ if: ${{ steps.reuse.outputs.hit != 'true' && steps.compilation-cache-restore.outcome == 'failure' }}
run: |
set -euo pipefail
workspace_path="$(cd "$GITHUB_WORKSPACE" && pwd -P)"
@@ -325,6 +344,7 @@ jobs:
echo "::warning::E2E cache restore failed; compiling with an empty cache"
- name: Cache Swift packages
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .ci-source-packages
@@ -332,9 +352,11 @@ jobs:
restore-keys: spm-
- name: Sanitize Swift package cache
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
run: python3 scripts/ci/sanitize-xcode-source-packages-cache.py .ci-source-packages
- name: Resolve Swift packages
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
run: |
set -euo pipefail
SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages"
@@ -360,8 +382,32 @@ jobs:
sleep $((attempt * 5))
done
+ # A fresh checkout makes every file newer than any earlier build, so
+ # without this even a one-line test change recompiles the app host.
+ - name: Adopt main's DerivedData
+ id: warm
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
+ continue-on-error: true
+ timeout-minutes: 10
+ env:
+ GH_TOKEN: ${{ github.token }}
+ WARM_KEY: ${{ steps.compilation-cache-key.outputs.fingerprint }}
+ run: |
+ set -euo pipefail
+ # A fresh checkout gives every file a new inode; without this the
+ # build system reruns every task whose inputs merely moved.
+ defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES
+ python3 scripts/ci/e2e_warm_derived_data.py restore "$GITHUB_WORKSPACE" "$CMUX_DERIVED_DATA_PATH" "$WARM_KEY"
+
+ - name: Record build input times
+ id: record-inputs
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
+ continue-on-error: true
+ run: python3 scripts/ci/e2e_warm_derived_data.py record "$GITHUB_WORKSPACE" "$CMUX_DERIVED_DATA_PATH/cmux-e2e-input-mtimes.json"
+
- name: Build the app-host and UI test product
id: compile
+ if: ${{ steps.reuse.outputs.hit != 'true' }}
run: |
set -euo pipefail
# Builds cmux, cmux-unit and cmux-numeric-locale, so one product
@@ -383,7 +429,10 @@ jobs:
python3 scripts/ci/app_host_test_products.py stamp "$CMUX_DERIVED_DATA_PATH"
python3 scripts/ci/reuse_app_host_products.py seal "$CMUX_DERIVED_DATA_PATH"
archive="$RUNNER_TEMP/app-host-products.tar.gz"
- tar -chzf "$archive" -C "$CMUX_DERIVED_DATA_PATH" Build/Products
+ # Still a gzip stream, so every consumer reads it unchanged. The
+ # default level spends about a minute squeezing Mach-O binaries that
+ # travel between two jobs on the same fleet and are deleted after.
+ tar -chf - -C "$CMUX_DERIVED_DATA_PATH" Build/Products | gzip -1 > "$archive"
echo "sha256=$(shasum -a 256 "$archive" | awk '{print $1}')" >> "$GITHUB_OUTPUT"
echo "bytes=$(wc -c < "$archive" | tr -d ' ')" >> "$GITHUB_OUTPUT"
@@ -402,12 +451,32 @@ jobs:
env:
COMPILE_SECONDS: ${{ steps.compile.outputs.seconds }}
ARCHIVE_BYTES: ${{ steps.package.outputs.bytes }}
+ REUSE_HIT: ${{ steps.reuse.outputs.hit }}
+ REUSE_MISSES: ${{ steps.reuse.outputs.miss_reasons }}
+ REUSE_PRODUCER: ${{ steps.reuse.outputs.producer_run_id }}
+ REUSE_SAVED: ${{ steps.reuse.outputs.macos_runner_minutes_saved }}
+ REUSE_TRANSFER: ${{ steps.reuse.outputs.transfer_seconds }}
+ WARM_HIT: ${{ steps.warm.outputs.hit }}
+ WARM_PRODUCER: ${{ steps.warm.outputs.producer_run_id }}
+ WARM_CHANGED: ${{ steps.warm.outputs.changed_inputs }}
+ WARM_REASON: ${{ steps.warm.outputs.reason }}
run: |
set -euo pipefail
{
echo "### Compiled test product"
echo
- echo "Compiled from source in ${COMPILE_SECONDS:-unknown} s."
+ if [ "$REUSE_HIT" = "true" ]; then
+ echo "Adopted the product run $REUSE_PRODUCER compiled; transferred in ${REUSE_TRANSFER:-unknown} s."
+ echo "Avoided ${REUSE_SAVED:-unknown} macOS runner-minutes of compilation."
+ else
+ echo "Compiled from source in ${COMPILE_SECONDS:-unknown} s."
+ echo "No product to adopt (${REUSE_MISSES:-none})."
+ if [ "$WARM_HIT" = "true" ]; then
+ echo "Started from the DerivedData run $WARM_PRODUCER built; ${WARM_CHANGED:-unknown} inputs differed."
+ else
+ echo "Started from empty DerivedData (${WARM_REASON:-not attempted})."
+ fi
+ fi
echo "Archive: ${ARCHIVE_BYTES:-unknown} bytes."
} >> "$GITHUB_STEP_SUMMARY"
@@ -423,6 +492,36 @@ jobs:
echo "contained=$contained" >> "$GITHUB_OUTPUT"
echo "Selected revision contained in main: $contained"
+ # Same trust rule as the compilation cache: only code main already
+ # contains may seed builds of other revisions.
+ - name: Package DerivedData for later builds
+ id: warm-package
+ continue-on-error: true
+ if: ${{ !cancelled() && github.ref == 'refs/heads/main' && steps.compile.outcome == 'success' && steps.record-inputs.outcome == 'success' && steps.revision-on-main.outputs.contained == 'true' }}
+ run: |
+ set -euo pipefail
+ archive="$RUNNER_TEMP/derived-data.tar.gz"
+ tar -cf - -C "$CMUX_DERIVED_DATA_PATH" --exclude ./Logs --exclude ./Index.noindex . | gzip -1 > "$archive"
+ bytes="$(wc -c < "$archive" | tr -d ' ')"
+ echo "DerivedData archive: $bytes bytes"
+ if [ "$bytes" -gt $((12 * 1024 * 1024 * 1024)) ]; then
+ echo "::warning::DerivedData archive exceeds 12 GiB; not publishing"
+ exit 0
+ fi
+ echo "bytes=$bytes" >> "$GITHUB_OUTPUT"
+ echo "publish=true" >> "$GITHUB_OUTPUT"
+
+ - name: Publish DerivedData for later builds
+ continue-on-error: true
+ if: ${{ !cancelled() && steps.warm-package.outputs.publish == 'true' }}
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: e2e-derived-data-v1-${{ steps.compilation-cache-key.outputs.fingerprint }}
+ path: ${{ runner.temp }}/derived-data.tar.gz
+ if-no-files-found: error
+ retention-days: 3
+ compression-level: 0
+
- name: Bound E2E compilation cache
id: compilation-cache-bound
continue-on-error: true
diff --git a/.github/workflows/testbox-broker-guard.yml b/.github/workflows/testbox-broker-guard.yml
index 177473a2f9bb..1f06eeaea38f 100644
--- a/.github/workflows/testbox-broker-guard.yml
+++ b/.github/workflows/testbox-broker-guard.yml
@@ -19,7 +19,7 @@ concurrency:
jobs:
guard:
name: Testbox broker trust boundary
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 10
permissions:
contents: read
diff --git a/.github/workflows/tmux-corpus.yml b/.github/workflows/tmux-corpus.yml
index fee7709b2782..1834e09591c6 100644
--- a/.github/workflows/tmux-corpus.yml
+++ b/.github/workflows/tmux-corpus.yml
@@ -15,7 +15,7 @@ concurrency:
jobs:
terminal-nightly:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
- runs-on: ${{ vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
+ runs-on: ${{ vars.CI_PAID_MACOS_OVERFLOW == '1' && vars.MACOS_RUNNER_15 || 'blacksmith-6vcpu-macos-15' }}
timeout-minutes: 30
env:
# XCTest app-host crashes can leave xcodebuild waiting in Swift's crash
diff --git a/.github/workflows/web-validation.yml b/.github/workflows/web-validation.yml
index 7d24a0b71b0a..36c29cb824f6 100644
--- a/.github/workflows/web-validation.yml
+++ b/.github/workflows/web-validation.yml
@@ -23,7 +23,7 @@ concurrency:
jobs:
changes:
if: ${{ github.event_name != 'pull_request' && github.event_name != 'merge_group' }}
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
outputs:
required: ${{ steps.route.outputs.required }}
@@ -46,7 +46,7 @@ jobs:
name: web-build
needs: changes
if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group'
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
defaults:
run:
@@ -76,7 +76,7 @@ jobs:
name: web-tests
needs: changes
if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group'
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 20
defaults:
run:
@@ -100,7 +100,7 @@ jobs:
name: web-database-tests
needs: changes
if: needs.changes.outputs.required == 'true' && github.event_name != 'pull_request' && github.event_name != 'merge_group'
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 15
defaults:
run:
@@ -138,7 +138,7 @@ jobs:
name: web-validation
needs: [changes, build, tests, database]
if: always()
- runs-on: ${{ vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+ runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
timeout-minutes: 5
steps:
- name: Accept CI-owned pull-request validation
diff --git a/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md b/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md
new file mode 100644
index 000000000000..0f5418c1b525
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/ATTRIBUTIONS.md
@@ -0,0 +1,5 @@
+# Agent icons
+
+Cursor, Gemini, Kiro, GitHub Copilot, CodeBuddy, Qoder, Kimi and Ollama SVGs come from [Lobe Icons](https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons) under the MIT license in `LOBE-LICENSE.txt`. Original SVG paths are unchanged; currentColor is replaced with black and white for light and dark appearances.
+
+Factory uses its [published site icon](https://factory.ai/icon.svg), retrieved on 2026-09-17. The redundant outer SVG wrapper is removed for asset-catalog compatibility.
diff --git a/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg
new file mode 100644
index 000000000000..ec353e26418e
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/CodeBuddy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json
new file mode 100644
index 000000000000..4700385540b8
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/CodeBuddy.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "CodeBuddy.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json b/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json
new file mode 100644
index 000000000000..29834aaeedf3
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images": [
+ {
+ "filename": "Copilot.svg",
+ "idiom": "universal"
+ },
+ {
+ "filename": "Copilot-dark.svg",
+ "idiom": "universal",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg
new file mode 100644
index 000000000000..ef5e74f4541f
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg
new file mode 100644
index 000000000000..fd0bc9ed7aa2
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Copilot.imageset/Copilot.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json b/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json
new file mode 100644
index 000000000000..fefa738bedee
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images": [
+ {
+ "filename": "Cursor.svg",
+ "idiom": "universal"
+ },
+ {
+ "filename": "Cursor-dark.svg",
+ "idiom": "universal",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg
new file mode 100644
index 000000000000..4b6f0ed522cc
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor-dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg
new file mode 100644
index 000000000000..f0cb42874a75
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Cursor.imageset/Cursor.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json b/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json
new file mode 100644
index 000000000000..efc8a52ab5a1
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Factory.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "Factory.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg b/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg
new file mode 100644
index 000000000000..38aad643a75e
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Factory.imageset/Factory.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json b/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json
new file mode 100644
index 000000000000..e237b2d5b5fd
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Gemini.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "Gemini.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg b/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg
new file mode 100644
index 000000000000..e15f1f7efae4
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Gemini.imageset/Gemini.svg
@@ -0,0 +1 @@
+Gemini CLI
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json b/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json
new file mode 100644
index 000000000000..1eb219aa99fc
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Kimi.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "Kimi.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg b/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg
new file mode 100644
index 000000000000..83878fa28476
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Kimi.imageset/Kimi.svg
@@ -0,0 +1 @@
+Kimi
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json b/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json
new file mode 100644
index 000000000000..c5437ff6ad83
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Kiro.imageset/Contents.json
@@ -0,0 +1,15 @@
+{
+ "images": [
+ {
+ "filename": "Kiro.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg b/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg
new file mode 100644
index 000000000000..0c651b9747d0
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Kiro.imageset/Kiro.svg
@@ -0,0 +1 @@
+Kiro
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt b/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt
new file mode 100644
index 000000000000..1dd53d2a9d99
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/LOBE-LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 LobeHub
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json b/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json
new file mode 100644
index 000000000000..53532f35516e
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images": [
+ {
+ "filename": "Ollama.svg",
+ "idiom": "universal"
+ },
+ {
+ "filename": "Ollama-dark.svg",
+ "idiom": "universal",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg
new file mode 100644
index 000000000000..a758e0f55587
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama-dark.svg
@@ -0,0 +1 @@
+Ollama
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg
new file mode 100644
index 000000000000..06947b8d698a
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Ollama.imageset/Ollama.svg
@@ -0,0 +1 @@
+Ollama
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json b/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json
new file mode 100644
index 000000000000..14cc6097d0ab
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Contents.json
@@ -0,0 +1,25 @@
+{
+ "images": [
+ {
+ "filename": "Qoder.svg",
+ "idiom": "universal"
+ },
+ {
+ "filename": "Qoder-dark.svg",
+ "idiom": "universal",
+ "appearances": [
+ {
+ "appearance": "luminosity",
+ "value": "dark"
+ }
+ ]
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true
+ }
+}
diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg
new file mode 100644
index 000000000000..1301a9de4582
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder-dark.svg
@@ -0,0 +1 @@
+Qoder
\ No newline at end of file
diff --git a/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg
new file mode 100644
index 000000000000..36ffff45c37d
--- /dev/null
+++ b/Assets.xcassets/AgentIcons/Qoder.imageset/Qoder.svg
@@ -0,0 +1 @@
+Qoder
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index cca8b1373490..e1991829a6e4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -196,6 +196,16 @@ Three things about it are easy to get wrong:
A callsign is attribution, never authority. The worker attempt is identified by `callsign + run ID + session ID + lease generation`; that tuple records who acted and grants nothing. Do not gate an action on a callsign, and do not treat a comment bearing one as authenticated — marker text is not an authenticated principal, which is the defect `teamleaderleo/quarry` #1103 tracks.
+## Outside contributors
+
+Most open PRs from people outside the team never got a human reply: of 810 open on 2026-09-23, 765 had only bot comments. Several were fixed on `main` by a maintainer PR while the contributor's PR sat open, and the contributor found out on their own.
+
+Before fixing a bug or building a feature, run `gh search prs --repo manaflow-ai/cmux --state open ''` and look for an outside PR (author not on the team). If one exists:
+
+- Prefer landing theirs. Push fixups to their branch when "Allow edits by maintainers" is on, and say what you changed.
+- If you write your own fix instead, add `Co-authored-by: Name ` for them to every commit that uses their approach, using the email from their commits (`git log --format='%an <%ae>'` on their branch). Then comment on their PR with a link to yours and a plain thank-you, and let a human close it.
+- Never close an outside PR without a human-written comment saying why.
+
## Choosing CI coverage
`full-ci` requests the expensive full macOS suite policy. It is not shorthand
@@ -227,6 +237,35 @@ The main agent owns dogfood, approval, mergeability, and every pushed fix. Mergi
Notify through `cmux notify` so the user can leave and return. Handoff: `--title "Dogfood ready: " --subtitle " · " --body "Was: . Now: . . PR: "`. Later closeout notifications use `"CI green: "` or `"CI blocked: "` with a one-line cause and the next decision. Titles carry outcome and branch, bodies carry the single next action. Skip notify if there is no cmux socket.
+## Reading CI cost
+
+Three measurements that are routinely read wrong, each established against
+`test-e2e.yml` on 2026-09-23 over a 98-run window.
+
+**A cancelled job's duration is usually queue, not spend.** GitHub sets a
+queued job's `started_at` to when it entered the queue, so a run that waited 45
+minutes for a runner and was then cancelled reports a 45-minute job. Check
+`runner_name` and `steps`: both empty means no runner was ever assigned and the
+job burned nothing. Of 20 cancelled runs totalling an apparent 239 macOS
+runner-minutes, 15 never got a runner and the real spend was 46. All 15 were
+waiting on `blacksmith-6vcpu-macos-15`, whose queue then ran a 26-minute median
+against 0.6 minutes for the macOS 26 pool.
+
+**Compiling fewer schemes saves almost nothing.** `build-for-testing` over
+`cmux`, `cmux-unit` and `cmux-numeric-locale` costs 691 s, 28 s and 16 s. The
+app scheme is 94% of it and is the test host every app-host test needs, so
+selecting schemes per test target is not a lever. What the schemes cost is
+worth re-measuring before any plan depends on splitting them.
+
+**The compile is close to binary, and one file decides it.** Against the same
+restored compilation cache, a revision with no changed native sources compiled
+in 280 s; a revision differing by a single file in `Sources/` took 737 s. The
+cause is not established (Debug builds are not whole-module), but "small diff"
+does not mean "short build",
+and a cache seeded from a commit that has since drifted is worth much less than
+its hit rate suggests. Prefer adopting an already-compiled product over
+reasoning about cache warmth.
+
## Pitfalls
Each of these has full detail in the skill named in parentheses.
diff --git a/CLI/CMUXCLI+AutoNamingSummarizers.swift b/CLI/CMUXCLI+AutoNamingSummarizers.swift
index a7d5d884dba4..1439f588e80f 100644
--- a/CLI/CMUXCLI+AutoNamingSummarizers.swift
+++ b/CLI/CMUXCLI+AutoNamingSummarizers.swift
@@ -66,7 +66,7 @@ extension CMUXCLI {
"--verbatim"
]
stdinPrompt = ""
- case "pi", "omp":
+ case "pi":
guard let promptPath = promptFile() else { return nil }
executablePath = executable()
arguments = [
@@ -81,6 +81,22 @@ extension CMUXCLI {
"Generate a 2-5 word title from the attached conversation excerpt. Output only the title."
]
stdinPrompt = ""
+ case "omp":
+ guard let promptPath = promptFile() else { return nil }
+ executablePath = executable()
+ // OMP uses rules for context isolation and rejects Pi's
+ // --no-prompt-templates and --no-context-files flags.
+ arguments = [
+ "--print",
+ "--no-tools",
+ "--no-session",
+ "--no-extensions",
+ "--no-skills",
+ "--no-rules",
+ "@\(promptPath)",
+ "Generate a 2-5 word title from the attached conversation excerpt. Output only the title."
+ ]
+ stdinPrompt = ""
default:
return nil
}
diff --git a/CLI/CMUXCLI+SSHStartupScripts.swift b/CLI/CMUXCLI+SSHStartupScripts.swift
index 0281cbfb398e..470a618c4451 100644
--- a/CLI/CMUXCLI+SSHStartupScripts.swift
+++ b/CLI/CMUXCLI+SSHStartupScripts.swift
@@ -344,13 +344,14 @@ extension CMUXCLI {
scriptLines += ["cmux_ssh_foreground_auth() {", trimmedOneTimeCommand, "}"]
scriptLines.append(authRetryPolicy.processTreeTerminationShellFunction())
}
- let reconnectConfiguration = retryPTYAttachStatus ? [
- // A missing limit used to mean infinity, which left a corrupt or
- // permanently unavailable daemon spinning forever in the pane.
- // Keep the supervisor finite even when an old persisted launcher
- // omitted CMUX_SSH_RECONNECT_LIMIT.
- "cmux_ssh_reconnect_limit=\"${CMUX_SSH_RECONNECT_LIMIT:-20}\"",
- "case \"$cmux_ssh_reconnect_limit\" in ''|*[!0-9]*) cmux_ssh_reconnect_limit=20 ;; *) while [ \"${cmux_ssh_reconnect_limit#0}\" != \"$cmux_ssh_reconnect_limit\" ] && [ \"$cmux_ssh_reconnect_limit\" != 0 ]; do cmux_ssh_reconnect_limit=\"${cmux_ssh_reconnect_limit#0}\"; done; case \"$cmux_ssh_reconnect_limit\" in [1-9]|1[0-9]|20) ;; *) cmux_ssh_reconnect_limit=20 ;; esac ;; esac",
+ // A missing limit used to mean infinity, which left a corrupt or
+ // permanently unavailable daemon spinning forever in the pane.
+ // Keep the supervisor finite even when an old persisted launcher
+ // omitted CMUX_SSH_RECONNECT_LIMIT.
+ let reconnectLimitLines = SSHReconnectBudget().limitNormalizationShellLines(
+ variable: "cmux_ssh_reconnect_limit"
+ )
+ let reconnectConfiguration = retryPTYAttachStatus ? reconnectLimitLines + [
"cmux_ssh_reconnect_delay=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"",
"case \"$cmux_ssh_reconnect_delay\" in ''|*[!0-9]*|0*) cmux_ssh_reconnect_delay=2 ;; esac",
"cmux_ssh_reconnect_max_delay=\"${CMUX_SSH_RECONNECT_MAX_DELAY_SECONDS:-30}\"",
diff --git a/CLI/CMUXCLI+VMTui.swift b/CLI/CMUXCLI+VMTui.swift
index 783777f88442..fffca7052881 100644
--- a/CLI/CMUXCLI+VMTui.swift
+++ b/CLI/CMUXCLI+VMTui.swift
@@ -457,7 +457,20 @@ extension CMUXCLI {
// create sessions; opening or reconnecting the machine does not.
let terminalStartedAt = Date()
do {
- let catalog = try client.sendV2(method: "surface.catalog", params: ["machine": vmId, "refresh": true], responseTimeout: 180)
+ // The snapshot contract creates the first remote workspace and
+ // terminal before the daemon accepts clients, so one link plus
+ // one graph read is all New Machine needs to find it.
+ //
+ // `ensure_linked` is that minimum, and it is required: a machine
+ // created a moment ago has no provider and no link in this app,
+ // so a plain cached read returns no graph and the resolver
+ // reports `.unavailable` ("The machine's sessions are
+ // unavailable"). That regression shipped once when the flag was
+ // dropped to "save work". Do not remove it, and do not upgrade it
+ // to `refresh: true`: a forced pass waits behind the fleet poll's
+ // in-flight connect and rescans ports for nothing. A reopen of a
+ // machine that is already linked costs no network at all.
+ let catalog = try client.sendV2(method: "surface.catalog", params: ["machine": vmId, "ensure_linked": true], responseTimeout: 180)
let opened: [String: Any]
switch VMRemoteWorkspaceResolver().resolveVMMachineTerminal(machine: vmId, catalog: catalog) {
case .resolved(let remoteWorkspaceID, let terminalID, let tabID):
diff --git a/CLI/cmux.swift b/CLI/cmux.swift
index 78de43ac076d..771838a37ad8 100644
--- a/CLI/cmux.swift
+++ b/CLI/cmux.swift
@@ -11756,7 +11756,7 @@ struct CMUXCLI {
} else {
let splitAttachCommand = [
"env",
- "CMUX_SSH_RECONNECT_LIMIT=${CMUX_SSH_RECONNECT_LIMIT:-86400}",
+ "CMUX_SSH_RECONNECT_LIMIT=${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}",
"CMUX_SSH_RECONNECT_DELAY_SECONDS=${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}",
shellQuote(executablePath),
"vm",
@@ -11769,7 +11769,7 @@ struct CMUXCLI {
sshCommand: splitAttachCommand,
shellFeatures: shellFeaturesValue,
remoteRelayPort: 0,
- reconnectLimitDefault: 86400
+ reconnectLimitDefault: SSHReconnectBudget().maximumLimit
)
}
} else {
@@ -13410,7 +13410,7 @@ struct CMUXCLI {
let quotedVMID = shellQuote(vmID)
let lines = [
"cmux_freestyle_cli=\(quotedCLI)",
- "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-86400}\"",
+ "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}\"",
"CMUX_SSH_RECONNECT_DELAY_SECONDS=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"",
"CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT:-$CMUX_SSH_RECONNECT_LIMIT}\"",
"CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS:-$CMUX_SSH_RECONNECT_DELAY_SECONDS}\"",
diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift
index b88d383e5d39..7d2299dbe5b6 100644
--- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift
+++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+PresenceRouteSync.swift
@@ -190,9 +190,17 @@ extension MobileShellComposite {
macDeviceID: instance.deviceId,
instanceTag: instance.tag
)
+ // An untagged legacy row adopts its device's sole
+ // route-advertising build; `applyPushedRoutes` checks that
+ // this instance is that build before writing.
+ let legacyPairingID = MobilePairedMac.pairingID(
+ macDeviceID: instance.deviceId,
+ instanceTag: nil
+ )
if await self.applyPushedRoutes(
from: instance,
- pairedMac: pairedMacsByPairingID[pairingID],
+ pairedMac: pairedMacsByPairingID[pairingID]
+ ?? pairedMacsByPairingID[legacyPairingID],
scope: scope
) {
persistedRoutes = true
diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
index 9aa9706b46e1..8860565d025d 100644
--- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
+++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
@@ -6499,8 +6499,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
// A renamed/repaired row may be the currently authenticated
// identity even though presence still names its historical id.
// Let physical-route coalescing choose that authoritative row.
+ // The alias set holds bare device ids, so match the build too: an
+ // online sibling build on the same device is not this row's alias.
let aliasIDs =
physicalAliasIDsByCanonicalID[pairingID] ?? [canonicalID]
+ let instanceTag = $0.instanceTag
return visibleLoadedMacs.contains { candidate in
let candidatePairingID = MobilePairedMac.pairingID(
macDeviceID: candidate.macDeviceID,
@@ -6508,6 +6511,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
)
return exactOnlinePairingIDs.contains(candidatePairingID)
&& aliasIDs.contains(cmxCanonicalDeviceID(candidate.macDeviceID))
+ && macInstanceTagAuthority.sameStoredAuthority(
+ candidate.instanceTag,
+ instanceTag
+ )
}
}
// Sibling builds of one physical Mac are distinct aggregation targets,
diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
index 378f8c73df79..f8f41f8e6a19 100644
--- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
+++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerPendingAttachmentTests.swift
@@ -14,8 +14,10 @@ import Testing
private static let terminalA = MobileTerminalPreview(id: "term-a", name: "a")
private static let terminalB = MobileTerminalPreview(id: "term-b", name: "b")
- /// A composite selected on `term-a`. Selection is set by `init` (no `didSet`
- /// draft swap fires), so the store contents stay exactly what each test seeds.
+ /// A composite selected on `term-a`. `init` leaves the selection to the
+ /// workspace synchronizer, which picks `term-a` and records one
+ /// `surfaceFocused` event. The draft swap it runs loads `term-a`'s empty
+ /// draft, so the store contents stay exactly what each test seeds.
private static func makeComposite(diagnosticLog: DiagnosticLog? = nil) -> MobileShellComposite {
MobileShellComposite(
workspaces: [
@@ -55,17 +57,21 @@ import Testing
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(1))
- while await log.processedCount() < 3, clock.now < deadline {
+ while await log.processedCount() < 4, clock.now < deadline {
await Task.yield()
}
- #expect(await log.processedCount() >= 3)
+ #expect(await log.processedCount() >= 4)
let report = await log.snapshot()
+ // The first event is construction focusing `term-a` (see
+ // `makeComposite`), not an attachment mutation.
#expect(report.events.map(\.a) == [
+ DiagnosticAppEventKind.surfaceFocused.rawValue,
DiagnosticAppEventKind.terminalAttachmentStaged.rawValue,
DiagnosticAppEventKind.terminalAttachmentRemoved.rawValue,
DiagnosticAppEventKind.terminalAttachmentRejected.rawValue,
])
#expect(report.events.map(\.b) == [
+ nil,
nil,
nil,
DiagnosticFailureKind.protocolViolation.rawValue,
diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift
index a359f7af636e..cf45f11d64b9 100644
--- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift
+++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacConnectionPoolTests.swift
@@ -318,13 +318,13 @@ import Testing
let historicalAlias = paired(
id: "mac-before-rename",
displayName: "Old Name",
- instanceTag: "old-tag",
+ instanceTag: "renamed-tag",
seenAt: .distantPast
)
let currentIdentity = paired(
id: "mac-after-rename",
displayName: "New Name",
- instanceTag: "new-tag",
+ instanceTag: "renamed-tag",
seenAt: Date()
)
let shell = MobileShellComposite(
@@ -343,7 +343,7 @@ import Testing
Self.snapshot([
Self.instance(
deviceID: historicalAlias.macDeviceID,
- tag: "old-tag",
+ tag: "renamed-tag",
online: true
),
]),
@@ -394,13 +394,13 @@ import Testing
let historicalAlias = paired(
id: "mac-auth-before-rename",
displayName: "Old Name",
- instanceTag: "old-tag",
+ instanceTag: "renamed-tag",
seenAt: .distantPast
)
let currentIdentity = paired(
id: "mac-auth-after-rename",
displayName: "New Name",
- instanceTag: "new-tag",
+ instanceTag: "renamed-tag",
seenAt: Date()
)
let pairedStore = DelayedTeamPairedMacStore(
@@ -473,7 +473,7 @@ import Testing
isActive: false,
stackUserID: "user-1",
teamID: "team-1",
- instanceTag: "old-aggregate-tag"
+ instanceTag: "aggregate-tag"
)
let currentIdentity = MobilePairedMac(
macDeviceID: "mac-aggregate-after-rename",
@@ -484,7 +484,7 @@ import Testing
isActive: false,
stackUserID: "user-1",
teamID: "team-1",
- instanceTag: "new-aggregate-tag"
+ instanceTag: "aggregate-tag"
)
let pairedStore = DelayedTeamPairedMacStore(
recordsByTeam: [
@@ -1401,6 +1401,7 @@ import Testing
)
}
store.foregroundMacDeviceID = focused.macDeviceID
+ store.activeMacInstanceTag = focused.instanceTag
store.activeRoute = focusedRoute
let candidates = store.secondaryAggregationCandidateMacs(
@@ -3621,7 +3622,10 @@ import Testing
probeTimeoutNanoseconds: 1_000_000_000
)
let macDeviceID = try #require(shell.foregroundMacDeviceID)
- let connection = try #require(shell.connections[macDeviceID])
+ let connection = try #require(shell.connections[MacPairingKey(
+ macDeviceID: macDeviceID,
+ instanceTag: shell.activeMacInstanceTag
+ )])
let initialSubscribeCount =
await router.count(of: "mobile.events.subscribe")
await router.delaySubscribeRequest(
diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
index cd34ec176c76..a6ed58459d89 100644
--- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
+++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
@@ -259,13 +259,118 @@ extension ControlCommandCoordinator {
])
}
+ /// The id params `workspace.reorder` resolves, in the order a caller should
+ /// hear about a failure: the subject before the relative target.
+ private func workspaceReorderIDKeys() -> [String] {
+ ["workspace_id", "before_workspace_id", "after_workspace_id"]
+ }
+
+ /// Whether a value could ever name a workspace: a UUID, or a minted
+ /// `kind:N` handle ref. `uuid(_:_:)` accepts exactly these two spellings,
+ /// so anything else is a value the registry was never going to resolve —
+ /// a typo, not an object that went away. Both `workspace.reorder` and
+ /// `workspace.reorder_many` split on this, so the two methods agree on the
+ /// same input.
+ private func isWorkspaceReferenceShaped(_ raw: String) -> Bool {
+ if UUID(uuidString: raw) != nil { return true }
+ guard let colon = raw.firstIndex(of: ":") else { return false }
+ let kind = String(raw[raw.startIndex.. Set? {
+ guard case .resolved(_, let workspaces, _)? = context?.controlWorkspaceList(
+ routing: routingSelectors(params)
+ ) else { return nil }
+ return Set(workspaces.map(\.id))
+ }
+
+ /// Builds one failure reply for a reorder, naming the id that could not be
+ /// resolved instead of always naming the subject workspace.
+ ///
+ /// `workspace` carries the caller's own spelling, so a stale `kind:N` ref
+ /// comes back verbatim and the caller can see which value to replace;
+ /// `workspace_id` stays a UUID, or JSON `null` when the value never
+ /// resolved to one. The planner reports one opaque `notFound` for "subject
+ /// missing" and "target missing" alike, so the workspace list is re-read —
+ /// on this error path only — to tell them apart.
+ private func workspaceReorderResolutionFailure(
+ _ params: [String: JSONValue],
+ subject: String
+ ) -> ControlCallResult {
+ let strings = context?.controlWorkspaceStrings()
+ func failure(param: String, value: String, id: UUID?) -> ControlCallResult {
+ .err(code: "not_found", message: strings?.workspaceNotFound ?? "", data: .object([
+ "param": .string(param),
+ "workspace": .string(value),
+ "workspace_id": orNull(id?.uuidString),
+ ]))
+ }
+ let supplied = workspaceReorderIDKeys().compactMap { key -> (key: String, raw: String)? in
+ guard let raw = string(params, key) else { return nil }
+ return (key, raw)
+ }
+ if let unresolvable = supplied.first(where: { uuid(params, $0.key) == nil }) {
+ // A stale `workspace:7` named a real workspace once, so it reports
+ // the object as gone. `"potato"` never could, so it stays a param
+ // error — the same split `workspace.reorder_many` makes.
+ guard isWorkspaceReferenceShaped(unresolvable.raw) else {
+ return .err(
+ code: "invalid_params",
+ message: strings?.invalidWorkspaceRef ?? "",
+ data: .object([
+ "param": .string(unresolvable.key),
+ "workspace": .string(unresolvable.raw),
+ ])
+ )
+ }
+ return failure(param: unresolvable.key, value: unresolvable.raw, id: nil)
+ }
+ // With no relative target, `supplied` holds only the subject, so the
+ // list read cannot distinguish anything: the branch below and the
+ // fallback return byte-identical payloads. Skip it. That is the only
+ // shape the sidebar sends (`SwiftViewInterpreter` defaults `Reorderable`
+ // to workspace_id + index), and `controlWorkspaceList` bridges a remote
+ // status payload and formats timestamps for every workspace on the main
+ // actor, so this is the difference between one wasted full list read per
+ // failed drop and none.
+ let hasRelativeTarget = hasNonNull(params, "before_workspace_id")
+ || hasNonNull(params, "after_workspace_id")
+ if hasRelativeTarget,
+ let live = workspaceReorderLiveIDs(params),
+ let absent = supplied.first(where: { entry in
+ guard let id = uuid(params, entry.key) else { return false }
+ return !live.contains(id)
+ }) {
+ return failure(param: absent.key, value: absent.raw, id: uuid(params, absent.key))
+ }
+ return failure(param: "workspace_id", value: subject, id: uuid(params, "workspace_id"))
+ }
+
/// `workspace.reorder` — move one workspace to an index/relative target.
func workspaceReorder(_ params: [String: JSONValue]) -> ControlCallResult {
+ let strings = context?.controlWorkspaceStrings()
guard context?.controlWorkspaceRoutingResolvesTabManager(routing: routingSelectors(params)) ?? false else {
- return .err(code: "unavailable", message: "TabManager not available", data: nil)
+ return .err(code: "unavailable", message: strings?.tabManagerUnavailable ?? "", data: nil)
}
+ guard let subject = string(params, "workspace_id") else {
+ return .err(code: "invalid_params", message: strings?.reorderMissingWorkspaceID ?? "", data: nil)
+ }
+ // A ref the registry once minted names an object that is gone, which is
+ // the same failure a stale `before_workspace_id` reports. A value that
+ // could never have named a workspace stays `invalid_params`, and so
+ // does a param `string(_:_:)` cannot read at all.
guard let workspaceID = uuid(params, "workspace_id") else {
- return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
+ return workspaceReorderResolutionFailure(params, subject: subject)
}
let index = int(params, "index")
@@ -277,13 +382,35 @@ extension ControlCommandCoordinator {
// must neither look like a missing target nor hide a conflicting one.
let targetCount = ["index", "before_workspace_id", "after_workspace_id"]
.filter { hasNonNull(params, $0) }.count
- if targetCount != 1 || (hasNonNull(params, "index") && index == nil) {
+ if targetCount != 1 {
return .err(
code: "invalid_params",
- message: "Specify exactly one target: index, before_workspace_id, or after_workspace_id",
+ message: strings?.reorderTargetRequired ?? "",
data: nil
)
}
+ if hasNonNull(params, "index"), index == nil {
+ return .err(
+ code: "invalid_params",
+ message: strings?.reorderIndexNotAnInteger ?? "",
+ data: .object(["param": .string("index")])
+ )
+ }
+ // `hasNonNull` is true for values `uuid` can never read — `""`,
+ // whitespace, and non-string JSON. There is no id to look up, so this
+ // is a type error rather than a missing workspace.
+ if let malformed = ["before_workspace_id", "after_workspace_id"].first(where: {
+ hasNonNull(params, $0) && string(params, $0) == nil
+ }) {
+ // The message stays flat and shared with `workspace.reorder_many`;
+ // `data.param` already names the param, so interpolating it would
+ // only make the string untranslatable.
+ return .err(
+ code: "invalid_params",
+ message: strings?.invalidWorkspaceRef ?? "",
+ data: .object(["param": .string(malformed)])
+ )
+ }
let resolution: ControlWorkspaceReorderResolution
if (hasNonNull(params, "before_workspace_id") && beforeID == nil)
@@ -301,9 +428,7 @@ extension ControlCommandCoordinator {
}
switch resolution {
case .notFound:
- return .err(code: "not_found", message: "Workspace not found", data: .object([
- "workspace_id": .string(workspaceID.uuidString),
- ]))
+ return workspaceReorderResolutionFailure(params, subject: subject)
case .resolved(let windowID, let plan):
var object: [String: JSONValue] = [
"workspace_id": .string(plan.workspaceID.uuidString),
@@ -331,7 +456,7 @@ extension ControlCommandCoordinator {
if let invalid = rawOrder.invalidValue {
return .err(
code: "invalid_params",
- message: strings?.reorderManyInvalidWorkspace ?? "",
+ message: strings?.invalidWorkspaceRef ?? "",
data: .object(["workspace": .string(invalid)])
)
}
@@ -348,9 +473,25 @@ extension ControlCommandCoordinator {
workspaceIDs.reserveCapacity(order.count)
for raw in order {
guard let workspaceID = uuidAny(.string(raw)) else {
+ // The registry forgets a ref when its workspace closes, so a
+ // stale `workspace:7` lands here too. It named something once:
+ // report it gone, as `workspace.reorder` does for the same ref.
+ // The id keys stay present, as `null`, so this reply has the
+ // same shape as the `.workspaceNotFound` one below.
+ if isWorkspaceReferenceShaped(raw) {
+ return .err(
+ code: "not_found",
+ message: strings?.workspaceNotFound ?? "",
+ data: .object([
+ "workspace": .string(raw),
+ "workspace_id": .null,
+ "workspace_ref": .null,
+ ])
+ )
+ }
return .err(
code: "invalid_params",
- message: strings?.reorderManyInvalidWorkspace ?? "",
+ message: strings?.invalidWorkspaceRef ?? "",
data: .object(["workspace": .string(raw)])
)
}
@@ -382,7 +523,7 @@ extension ControlCommandCoordinator {
case .workspaceNotFound(let workspaceID):
return .err(
code: "not_found",
- message: strings?.reorderManyWorkspaceNotFound ?? "",
+ message: strings?.workspaceNotFound ?? "",
data: .object([
"workspace_id": .string(workspaceID.uuidString),
"workspace_ref": ref(.workspace, workspaceID),
diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift
index 5de11528070d..fe8ad43f63f9 100644
--- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift
+++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift
@@ -17,10 +17,19 @@ public struct ControlWorkspaceStrings: Sendable, Equatable {
public let reorderManyMissingOrder: String
/// `socket.workspace.reorderMany.duplicateWorkspace`.
public let reorderManyDuplicateWorkspace: String
- /// `socket.workspace.reorderMany.workspaceNotFound`.
- public let reorderManyWorkspaceNotFound: String
- /// `socket.workspace.reorderMany.invalidWorkspace`.
- public let reorderManyInvalidWorkspace: String
+ /// `socket.workspace.reorderMany.workspaceNotFound` — shared by
+ /// `workspace.reorder`, which reports the same failure for the same reason.
+ public let workspaceNotFound: String
+ /// `socket.workspace.reorderMany.invalidWorkspace` — shared by
+ /// `workspace.reorder`, so a value neither method can read reads the same
+ /// either way.
+ public let invalidWorkspaceRef: String
+ /// `socket.workspace.reorder.indexNotAnInteger`.
+ public let reorderIndexNotAnInteger: String
+ /// `socket.workspace.reorder.missingWorkspaceID`.
+ public let reorderMissingWorkspaceID: String
+ /// `socket.workspace.reorder.targetRequired`.
+ public let reorderTargetRequired: String
/// `socket.workspace.reorderMany.tabManagerUnavailable`.
public let reorderManyTabManagerUnavailable: String
/// `socket.workspace.list.tabManagerUnavailable`.
@@ -35,8 +44,11 @@ public struct ControlWorkspaceStrings: Sendable, Equatable {
/// - closeFailed: The `workspace.close` local-teardown failure message.
/// - reorderManyMissingOrder: The missing-order message.
/// - reorderManyDuplicateWorkspace: The duplicate-workspace message.
- /// - reorderManyWorkspaceNotFound: The workspace-not-found message.
- /// - reorderManyInvalidWorkspace: The invalid-workspace message.
+ /// - workspaceNotFound: The workspace-not-found message.
+ /// - invalidWorkspaceRef: The invalid-workspace message.
+ /// - reorderIndexNotAnInteger: The unreadable-`index` message.
+ /// - reorderMissingWorkspaceID: The missing-subject message.
+ /// - reorderTargetRequired: The wrong-target-count message.
/// - reorderManyTabManagerUnavailable: The TabManager-unavailable message.
/// - tabManagerUnavailable: The localized workspace-list unavailable message.
/// - relayOwnerUnavailable: The stale relay-owner message.
@@ -45,8 +57,11 @@ public struct ControlWorkspaceStrings: Sendable, Equatable {
closeFailed: String,
reorderManyMissingOrder: String,
reorderManyDuplicateWorkspace: String,
- reorderManyWorkspaceNotFound: String,
- reorderManyInvalidWorkspace: String,
+ workspaceNotFound: String,
+ invalidWorkspaceRef: String,
+ reorderIndexNotAnInteger: String,
+ reorderMissingWorkspaceID: String,
+ reorderTargetRequired: String,
reorderManyTabManagerUnavailable: String,
tabManagerUnavailable: String = "TabManager not available",
relayOwnerUnavailable: String
@@ -55,8 +70,11 @@ public struct ControlWorkspaceStrings: Sendable, Equatable {
self.closeFailed = closeFailed
self.reorderManyMissingOrder = reorderManyMissingOrder
self.reorderManyDuplicateWorkspace = reorderManyDuplicateWorkspace
- self.reorderManyWorkspaceNotFound = reorderManyWorkspaceNotFound
- self.reorderManyInvalidWorkspace = reorderManyInvalidWorkspace
+ self.workspaceNotFound = workspaceNotFound
+ self.invalidWorkspaceRef = invalidWorkspaceRef
+ self.reorderIndexNotAnInteger = reorderIndexNotAnInteger
+ self.reorderMissingWorkspaceID = reorderMissingWorkspaceID
+ self.reorderTargetRequired = reorderTargetRequired
self.reorderManyTabManagerUnavailable = reorderManyTabManagerUnavailable
self.tabManagerUnavailable = tabManagerUnavailable
self.relayOwnerUnavailable = relayOwnerUnavailable
diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift
index 80dfb3126a9a..f730b56148cc 100644
--- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift
+++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlClientAsyncTransportTests.swift
@@ -124,7 +124,6 @@ struct ControlClientAsyncTransportTests {
maximumBufferedBytes: 64 * 1024
)
var expected = ""
- let deadline = Date().addingTimeInterval(5)
var drainedEveryWrite = true
for index in 0..<200 {
let byte: [UInt8] = [UInt8(65 + (index % 26))]
@@ -135,7 +134,10 @@ struct ControlClientAsyncTransportTests {
}
// Deadline-poll the drain's byte accounting so every write is
// drained (one queued chunk each) before the next one, keeping
- // the many-short-chunks shape deterministic under load.
+ // the many-short-chunks shape deterministic under load. The
+ // deadline is per write: one shared budget across 200 polls ran
+ // out on a loaded host, where each 1 ms sleep wakes much later.
+ let deadline = Date().addingTimeInterval(5)
while bufferingState(reader).queued < index + 1, Date() < deadline {
try await Task.sleep(nanoseconds: 1_000_000)
}
diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
index 6439339f9b6c..ebf4fc940def 100644
--- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
+++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
@@ -293,8 +293,11 @@ extension ControlWorkspaceContext {
closeProtected: "", closeFailed: "",
reorderManyMissingOrder: "",
reorderManyDuplicateWorkspace: "",
- reorderManyWorkspaceNotFound: "",
- reorderManyInvalidWorkspace: "",
+ workspaceNotFound: "",
+ invalidWorkspaceRef: "",
+ reorderIndexNotAnInteger: "",
+ reorderMissingWorkspaceID: "",
+ reorderTargetRequired: "",
reorderManyTabManagerUnavailable: "", relayOwnerUnavailable: ""
)
}
diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift
index 4d9bfb4b0d84..c6745b96ad03 100644
--- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift
+++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlWorkspaceReorderTargetTests.swift
@@ -64,4 +64,271 @@ struct ControlWorkspaceReorderTargetTests {
#expect(call.after == (key == "after_workspace_id" ? targetID : nil))
#expect(call.dryRun == dryRun)
}
+
+ private func summary(id: UUID) -> ControlWorkspaceSummary {
+ ControlWorkspaceSummary(
+ id: id,
+ title: "Workspace",
+ customTitle: nil,
+ customDescription: nil,
+ isPinned: false,
+ listeningPorts: [],
+ remoteStatus: .object([:]),
+ currentDirectory: nil,
+ customColor: nil,
+ latestConversationMessage: nil,
+ latestSubmittedMessage: nil,
+ latestSubmittedAt: nil
+ )
+ }
+
+ /// The `not_found` payload must name the id that failed to resolve. Naming
+ /// the subject tells the caller the one workspace that did resolve is the
+ /// missing one.
+ @Test(arguments: ["before_workspace_id", "after_workspace_id"])
+ func unresolvedRelativeTargetNamesTheTarget(key: String) throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let workspaceID = UUID()
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(workspaceID.uuidString),
+ key: .string("workspace:999999"),
+ "dry_run": .bool(true)
+ ]))
+ guard case .err(let code, _, .object(let data)) = result else {
+ Issue.record("An unknown relative target must fail with a payload")
+ return
+ }
+ #expect(code == "not_found")
+ #expect(data["param"] == .string(key))
+ #expect(data["workspace"] == .string("workspace:999999"))
+ #expect(data["workspace_id"] != .string(workspaceID.uuidString))
+ }
+
+ /// A well-formed target id that no live workspace matches is still the
+ /// target's failure, even though the planner reports one opaque `notFound`.
+ @Test(arguments: ["before_workspace_id", "after_workspace_id"])
+ func relativeTargetThatIsNotLiveNamesTheTarget(key: String) throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let workspaceID = UUID()
+ let missingID = UUID()
+ context.listResolution = .resolved(
+ windowID: nil,
+ workspaces: [summary(id: workspaceID)],
+ selectedIndex: 0
+ )
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(workspaceID.uuidString),
+ key: .string(missingID.uuidString),
+ "dry_run": .bool(true)
+ ]))
+ guard case .err(let code, _, .object(let data)) = result else {
+ Issue.record("A target that is not live must fail with a payload")
+ return
+ }
+ #expect(code == "not_found")
+ #expect(data["param"] == .string(key))
+ #expect(data["workspace_id"] == .string(missingID.uuidString))
+ }
+
+ /// `hasNonNull` is true for values `uuid` can never read. A type error is
+ /// not a missing workspace.
+ @Test(arguments: [
+ JSONValue.string(""), .string(" "), .int(5), .bool(true), .object([:]), .array([]),
+ ])
+ func malformedRelativeTargetIsInvalidParams(value: JSONValue) throws {
+ for key in ["before_workspace_id", "after_workspace_id"] {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(UUID().uuidString),
+ key: value
+ ]))
+ guard case .err(let code, _, .object(let data)) = result else {
+ Issue.record("A malformed \(key) must fail with a payload")
+ return
+ }
+ #expect(code == "invalid_params")
+ #expect(data["param"] == .string(key))
+ #expect(context.reorderCall == nil)
+ }
+ }
+
+ /// One target was specified; it was unreadable. "Specify exactly one
+ /// target" sends the caller after the wrong param.
+ @Test func unparsableIndexReportsAnInvalidIndex() throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(UUID().uuidString),
+ "index": .string("abc")
+ ]))
+ guard case .err(let code, let message, .object(let data)) = result else {
+ Issue.record("An unreadable index must fail with a payload")
+ return
+ }
+ #expect(code == "invalid_params")
+ #expect(data["param"] == .string("index"))
+ #expect(!message.contains("exactly one target"))
+ #expect(context.reorderCall == nil)
+ }
+
+ /// The subject and the relative target are the same kind of reference, so
+ /// an unresolvable ref reports the same way through either param.
+ @Test func unresolvedSubjectRefReportsNotFoundEchoingTheRef() throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string("workspace:999999"),
+ "index": .int(0)
+ ]))
+ guard case .err(let code, _, .object(let data)) = result else {
+ Issue.record("An unresolvable subject ref must fail with a payload")
+ return
+ }
+ #expect(code == "not_found")
+ #expect(data["param"] == .string("workspace_id"))
+ #expect(data["workspace"] == .string("workspace:999999"))
+ #expect(context.reorderCall == nil)
+ }
+
+ /// A missing or unreadable `workspace_id` stays a request-shape error.
+ @Test(arguments: [JSONValue.string(""), .int(7), .bool(true)])
+ func malformedSubjectStaysInvalidParams(value: JSONValue) throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": value,
+ "index": .int(0)
+ ]))
+ guard case .err(let code, _, _) = result else {
+ Issue.record("A malformed workspace_id must fail")
+ return
+ }
+ #expect(code == "invalid_params")
+ #expect(context.reorderCall == nil)
+ }
+
+ /// A value neither `uuid(_:_:)` spelling can read — not a UUID, not a
+ /// `kind:N` ref — is a typo, not a workspace that went away. Reporting it
+ /// as `not_found` sends the caller looking for a workspace that never
+ /// existed under that name. That includes a `kind:N` whose kind the
+ /// registry never mints (`unknown:1`), and a known kind in the wrong case
+ /// (`WORKSPACE:1`): refs are minted lowercase and looked up exactly.
+ @Test(arguments: [
+ "potato", "workspace", "workspace:", ":7", "workspace:abc", "7", "workspace 7",
+ "unknown:1", "WORKSPACE:1",
+ ])
+ func unreadableSubjectIsInvalidParams(raw: String) throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(raw),
+ "index": .int(0),
+ "dry_run": .bool(true)
+ ]))
+ guard case .err(let code, _, .object(let data)) = result else {
+ Issue.record("An unreadable workspace_id must fail with a payload")
+ return
+ }
+ #expect(code == "invalid_params")
+ #expect(data["param"] == .string("workspace_id"))
+ #expect(data["workspace"] == .string(raw))
+ #expect(context.reorderCall == nil)
+ }
+
+ /// The same split on a relative target.
+ @Test(arguments: ["before_workspace_id", "after_workspace_id"])
+ func unreadableRelativeTargetIsInvalidParams(key: String) throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(UUID().uuidString),
+ key: .string("potato"),
+ "dry_run": .bool(true)
+ ]))
+ guard case .err(let code, _, .object(let data)) = result else {
+ Issue.record("An unreadable relative target must fail with a payload")
+ return
+ }
+ #expect(code == "invalid_params")
+ #expect(data["param"] == .string(key))
+ #expect(data["workspace"] == .string("potato"))
+ #expect(context.reorderCall == nil)
+ }
+
+ /// The other half of the split: a ref the registry once minted names a
+ /// workspace that is gone, so it stays `not_found`.
+ /// `TAB:4` is included because the registry lowercases the `tab:` alias.
+ @Test(arguments: ["workspace:999999", "tab:4", "TAB:4", "pane:7", "workspace_group:2"])
+ func staleRefSubjectStaysNotFound(raw: String) throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(raw),
+ "index": .int(0),
+ "dry_run": .bool(true)
+ ]))
+ guard case .err(let code, _, .object(let data)) = result else {
+ Issue.record("A stale ref must fail with a payload")
+ return
+ }
+ #expect(code == "not_found")
+ #expect(data["workspace"] == .string(raw))
+ #expect(context.reorderCall == nil)
+ }
+
+ /// `workspace.reorder` and `workspace.reorder_many` must answer the same
+ /// unresolvable value with the same code. They disagreed before this change,
+ /// so a caller that fell back from one to the other saw the failure change
+ /// class without the input changing. `workspace:999999` is the shape a
+ /// closed workspace's ref takes once the registry forgets it.
+ @Test(arguments: [
+ ("potato", "invalid_params"),
+ ("workspace:abc", "invalid_params"),
+ ("unknown:1", "invalid_params"),
+ ("", "invalid_params"),
+ ("workspace:999999", "not_found"),
+ ])
+ func reorderAgreesWithReorderManyOnUnresolvableValues(raw: String, expected: String) throws {
+ func code(of result: ControlCallResult?) -> String? {
+ guard case .err(let code, _, _)? = result else { return nil }
+ return code
+ }
+ // The coordinator holds its context weakly: an inline fake is freed
+ // before `handle` runs, and `reorder` answers `unavailable`.
+ let singleContext = FakeWorkspaceControlCommandContext()
+ let manyContext = FakeWorkspaceControlCommandContext()
+ let single = ControlCommandCoordinator(context: singleContext)
+ let many = ControlCommandCoordinator(context: manyContext)
+ let reorder = single.handle(ControlRequest(id: .int(1), method: "workspace.reorder", params: [
+ "workspace_id": .string(raw), "index": .int(0), "dry_run": .bool(true)
+ ]))
+ let reorderMany = many.handle(ControlRequest(id: .int(1), method: "workspace.reorder_many", params: [
+ "workspace_ids": .array([.string(raw)]), "dry_run": .bool(true)
+ ]))
+ #expect(code(of: reorder) == expected)
+ #expect(code(of: reorderMany) == expected)
+ withExtendedLifetime((singleContext, manyContext)) {}
+ }
+
+ /// A stale ref through `workspace.reorder_many` echoes the caller's value
+ /// and keeps the id keys its UUID `not_found` reply carries, as `null`.
+ @Test func reorderManyStaleRefEchoesTheRef() throws {
+ let context = FakeWorkspaceControlCommandContext()
+ let coordinator = ControlCommandCoordinator(context: context)
+ let result = coordinator.handle(ControlRequest(id: .int(1), method: "workspace.reorder_many", params: [
+ "workspace_ids": .array([.string("workspace:999999")]), "dry_run": .bool(true)
+ ]))
+ guard case .err(let code, _, .object(let data))? = result else {
+ Issue.record("A stale ref must fail with a payload")
+ return
+ }
+ #expect(code == "not_found")
+ #expect(data["workspace"] == .string("workspace:999999"))
+ #expect(data["workspace_id"] == .null)
+ #expect(data["workspace_ref"] == .null)
+ }
+
}
diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift
index 2694f4a9a1a3..63a112c310a0 100644
--- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift
+++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeWorkspaceControlCommandContext.swift
@@ -104,8 +104,11 @@ final class FakeWorkspaceControlCommandContext: ControlCommandContext {
closeFailed: "close failed",
reorderManyMissingOrder: "missing order",
reorderManyDuplicateWorkspace: "duplicate workspace",
- reorderManyWorkspaceNotFound: "workspace not found",
- reorderManyInvalidWorkspace: "invalid workspace",
+ workspaceNotFound: "workspace not found",
+ invalidWorkspaceRef: "invalid workspace",
+ reorderIndexNotAnInteger: "index not an integer",
+ reorderMissingWorkspaceID: "missing workspace_id",
+ reorderTargetRequired: "exactly one target",
reorderManyTabManagerUnavailable: "tab manager unavailable",
relayOwnerUnavailable: "relay owner workspace unavailable"
)
diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift
index f50c961438e4..75de6ac73856 100644
--- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift
+++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHPTYAttachRetryScriptBuilder.swift
@@ -74,13 +74,18 @@ public struct SSHPTYAttachRetryScriptBuilder: Sendable {
let sessionRunningStatus = SSHPTYAttachExitCode.bridgeClosedSessionRunning.rawValue
let transientStatus = SSHPTYAttachExitCode.retryableTransient.rawValue
let terminalModeReset = SSHTerminalModeResetSequence().shellPrintfFormat.remoteCommandShellQuoted
+ // Persisted launchers may predate the retry policy. A missing or
+ // malformed limit must fail closed to the same finite supervisor used
+ // by newly generated SSH startup scripts; a well-formed larger budget
+ // is honored up to the shared ceiling.
+ let reconnectLimitLines = SSHReconnectBudget().limitNormalizationShellLines(
+ variable: "cmux_ssh_attach_reconnect_limit"
+ )
var lines = [
"cmux_ssh_attach_restore_terminal() { cmux_ssh_attach_flush_status=0; if [ \"${cmux_ssh_attach_input_paused:-0}\" = 1 ] && [ -n \"${cmux_ssh_attach_cli:-}\" ]; then \"$cmux_ssh_attach_cli\" __ssh-pty-flush-input <&0 >/dev/null 2>&1; cmux_ssh_attach_flush_status=$?; fi; cmux_ssh_attach_restore_status=0; if [ -n \"${cmux_ssh_attach_terminal_state:-}\" ]; then /bin/stty \"$cmux_ssh_attach_terminal_state\" <&0 2>/dev/null; cmux_ssh_attach_restore_status=$?; fi; cmux_ssh_attach_input_paused=0; if [ \"$cmux_ssh_attach_flush_status\" -ne 0 ] || [ \"$cmux_ssh_attach_restore_status\" -ne 0 ]; then cmux_ssh_attach_terminal_control_failed=1; fi; }",
- // Persisted launchers may predate the retry policy. A missing or
- // malformed limit must fail closed to the same finite supervisor
- // used by newly generated SSH startup scripts.
- "cmux_ssh_attach_reconnect_limit=\"${CMUX_SSH_RECONNECT_LIMIT:-20}\"",
- "case \"$cmux_ssh_attach_reconnect_limit\" in ''|*[!0-9]*) cmux_ssh_attach_reconnect_limit=20 ;; *) while [ \"${cmux_ssh_attach_reconnect_limit#0}\" != \"$cmux_ssh_attach_reconnect_limit\" ] && [ \"$cmux_ssh_attach_reconnect_limit\" != 0 ]; do cmux_ssh_attach_reconnect_limit=\"${cmux_ssh_attach_reconnect_limit#0}\"; done; case \"$cmux_ssh_attach_reconnect_limit\" in [1-9]|1[0-9]|20) ;; *) cmux_ssh_attach_reconnect_limit=20 ;; esac ;; esac",
+ ]
+ lines.append(contentsOf: reconnectLimitLines)
+ lines.append(contentsOf: [
"cmux_ssh_attach_reconnect_delay=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"",
"case \"$cmux_ssh_attach_reconnect_delay\" in ''|*[!0-9]*|0*) cmux_ssh_attach_reconnect_delay=2 ;; esac",
"cmux_ssh_attach_reconnect_max_delay=\"${CMUX_SSH_RECONNECT_MAX_DELAY_SECONDS:-30}\"",
@@ -89,7 +94,7 @@ public struct SSHPTYAttachRetryScriptBuilder: Sendable {
"cmux_ssh_attach_reconnect_initial_delay=\"$cmux_ssh_attach_reconnect_delay\"",
"cmux_ssh_attach_retry_reason=\(bridgeClosedReason)",
"cmux_ssh_attach_suppress_replay=0",
- ]
+ ])
lines.append(contentsOf: noProgressPolicy.configurationLines)
lines.append(contentsOf: [
"cmux_ssh_attach_no_progress_retry=0",
diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHReconnectBudget.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHReconnectBudget.swift
new file mode 100644
index 000000000000..657cb201af0a
--- /dev/null
+++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SSHReconnectBudget.swift
@@ -0,0 +1,74 @@
+/// Single source of truth for the persistent SSH reconnect budget.
+///
+/// `CMUX_SSH_RECONNECT_LIMIT` is read by generated shell supervisors in
+/// several entrypoints. Each used to hard-code its own numbers, so one name
+/// carried a 20-attempt ceiling in the attach supervisor and an 86400 default
+/// in the freestyle supervisors. Both sides now read these constants.
+public struct SSHReconnectBudget: Sendable {
+ /// Environment variable that carries an operator-chosen reconnect budget.
+ public let limitEnvironmentName: String
+
+ /// Attempts used when the variable is unset or carries unusable text.
+ ///
+ /// Failing closed to a small finite budget is deliberate: a persisted
+ /// launcher that predates the retry policy, or a typo, must not leave a
+ /// corrupt or permanently unavailable daemon spinning forever in a pane.
+ public let fallbackLimit: Int
+
+ /// Largest reconnect budget an operator can ask for.
+ ///
+ /// This is also what the freestyle supervisors default to, so the largest
+ /// value an operator may request matches the largest value the app itself
+ /// requests. Keeping it finite preserves the fail-closed property above;
+ /// at the capped 30s backoff, 86400 attempts is a supervisor that gives up
+ /// only after the host has been gone for weeks.
+ public let maximumLimit: Int
+
+ public init(
+ limitEnvironmentName: String = "CMUX_SSH_RECONNECT_LIMIT",
+ fallbackLimit: Int = 20,
+ maximumLimit: Int = 86400
+ ) {
+ self.limitEnvironmentName = limitEnvironmentName
+ self.fallbackLimit = fallbackLimit
+ self.maximumLimit = maximumLimit
+ }
+
+ /// Shell lines that resolve ``limitEnvironmentName`` into `variable`.
+ ///
+ /// A value of 1...``maximumLimit`` is honored, after leading zeros are
+ /// stripped. Anything else — non-digits, empty, zero, or a count above the
+ /// ceiling — falls back to `fallback` or the ceiling and prints one line to
+ /// stderr naming the value it rejected and the value it used. The oversized
+ /// case is length-tested before it is compared numerically, because a value
+ /// with more digits than the shell's integer range makes `[ … -gt … ]`
+ /// error out instead of answering.
+ ///
+ /// - Parameters:
+ /// - variable: Shell variable that receives the resolved budget. The
+ /// generator also writes `\(variable)_requested` and
+ /// `\(variable)_rejected`.
+ /// - fallback: Budget used when the supplied value is unusable.
+ /// - Returns: POSIX `/bin/sh` lines.
+ public func limitNormalizationShellLines(
+ variable: String,
+ fallback: Int? = nil
+ ) -> [String] {
+ let fallback = fallback ?? fallbackLimit
+ let ceilingDigits = String(maximumLimit).count
+ return [
+ "\(variable)=\"${\(limitEnvironmentName):-\(fallback)}\"",
+ "\(variable)_requested=\"$\(variable)\"",
+ "\(variable)_rejected=0",
+ "case \"$\(variable)\" in ''|*[!0-9]*) \(variable)_rejected=1; \(variable)=\(fallback) ;; *) "
+ + "while [ \"${\(variable)#0}\" != \"$\(variable)\" ] && [ \"$\(variable)\" != 0 ]; do "
+ + "\(variable)=\"${\(variable)#0}\"; done; "
+ + "if [ \"$\(variable)\" = 0 ]; then \(variable)_rejected=1; \(variable)=\(fallback); "
+ + "elif [ \"${#\(variable)}\" -gt \(ceilingDigits) ] || [ \"$\(variable)\" -gt \(maximumLimit) ]; then "
+ + "\(variable)_rejected=1; \(variable)=\(maximumLimit); fi ;; esac",
+ "if [ \"$\(variable)_rejected\" = 1 ]; then printf "
+ + "'[cmux] \(limitEnvironmentName)=%s is not an attempt count in 1-\(maximumLimit); using %s.\\n' "
+ + "\"$\(variable)_requested\" \"$\(variable)\" >&2 || true; fi",
+ ]
+ }
+}
diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift
index 0d0c48524d84..cecb8660c3c6 100644
--- a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift
+++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHPTYAttachRetryScriptBuilderTests.swift
@@ -432,8 +432,8 @@ struct SSHPTYAttachRetryScriptBuilderTests {
#expect(!transcript.contains("remote PTY bridge closed; reattaching"), Comment(rawValue: transcript))
}
- @Test(arguments: ["bad", "21", "999999999999999999999999999999"])
- func malformedOrOversizedReconnectLimitsRemainFinite(_ configuredLimit: String) throws {
+ @Test(arguments: ["bad", "-5", "0"])
+ func unusableReconnectLimitsRemainFinite(_ configuredLimit: String) throws {
let logURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-ssh-attach-limit-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: logURL) }
@@ -460,9 +460,46 @@ struct SSHPTYAttachRetryScriptBuilderTests {
.count
#expect(result.status == 255)
- // One initial attach plus at most the 20 reconnects is the hard
- // contract, regardless of user-provided limit text.
+ // One initial attach plus the 20 fallback reconnects is the contract
+ // for text the supervisor cannot use as an attempt count.
#expect(attempts == 21)
+ #expect(
+ result.stderr.contains("CMUX_SSH_RECONNECT_LIMIT=\(configuredLimit)"),
+ Comment(rawValue: result.stderr)
+ )
+ }
+
+ @Test func wellFormedReconnectLimitAboveTwentyIsHonored() throws {
+ let logURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("cmux-ssh-attach-limit-\(UUID().uuidString)")
+ defer { try? FileManager.default.removeItem(at: logURL) }
+
+ let retryLines = SSHPTYAttachRetryScriptBuilder().lines(
+ command: "cmux_test_attach",
+ reauthenticates: false
+ )
+ let script = ([
+ "cmux_ssh_attach_signal_exit() { exit \"$1\"; }",
+ "sleep() { :; }",
+ "cmux_test_attach() { printf '%s\\n' attach >> \"$CMUX_TEST_LOG\"; return 255; }",
+ ] + retryLines).joined(separator: "\n")
+
+ let result = try run(
+ script,
+ environment: [
+ "CMUX_TEST_LOG": logURL.path,
+ "CMUX_SSH_RECONNECT_LIMIT": "25",
+ ]
+ )
+ let attempts = try String(contentsOf: logURL, encoding: .utf8)
+ .split(separator: "\n")
+ .count
+
+ #expect(result.status == 255)
+ // 25 used to be rewritten to 20 without a word. The supervisor now
+ // spends the budget it was given, and stays silent about it.
+ #expect(attempts == 26)
+ #expect(!result.stderr.contains("CMUX_SSH_RECONNECT_LIMIT="), Comment(rawValue: result.stderr))
}
@Test
diff --git a/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHReconnectBudgetTests.swift b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHReconnectBudgetTests.swift
new file mode 100644
index 000000000000..638ffaa083f5
--- /dev/null
+++ b/Packages/macOS/CmuxFoundation/Tests/CmuxFoundationTests/SSHReconnectBudgetTests.swift
@@ -0,0 +1,94 @@
+import Foundation
+import Testing
+
+@testable import CmuxFoundation
+
+@Suite(.serialized)
+struct SSHReconnectBudgetTests {
+ @Test(arguments: [
+ (limit: "1", resolved: "1"),
+ (limit: "20", resolved: "20"),
+ // A well-formed budget above the historical 20-attempt ceiling used to
+ // be discarded without a word. It is honored now.
+ (limit: "21", resolved: "21"),
+ (limit: "50", resolved: "50"),
+ (limit: "86400", resolved: "86400"),
+ // Leading zeros are normalization, not rejection.
+ (limit: "007", resolved: "7"),
+ ])
+ func honorsWellFormedBudgetsSilently(_ testCase: (limit: String, resolved: String)) throws {
+ let result = try resolve(testCase.limit)
+
+ #expect(result.resolved == testCase.resolved)
+ #expect(result.stderr.isEmpty, Comment(rawValue: result.stderr))
+ }
+
+ @Test(arguments: ["abc", "-5", "1e3", "1.5", " 20", "0", "0000"])
+ func unusableBudgetsFailClosedAndSaySo(_ limit: String) throws {
+ let result = try resolve(limit)
+
+ #expect(result.resolved == String(SSHReconnectBudget().fallbackLimit))
+ #expect(result.stderr.contains("CMUX_SSH_RECONNECT_LIMIT=\(limit)"), Comment(rawValue: result.stderr))
+ #expect(result.stderr.contains("using \(SSHReconnectBudget().fallbackLimit)."), Comment(rawValue: result.stderr))
+ }
+
+ @Test(arguments: ["86401", "99999", "999999", "99999999999999999999"])
+ func oversizedBudgetsClampToTheCeilingAndSaySo(_ limit: String) throws {
+ let result = try resolve(limit)
+
+ // A value with more digits than the shell's integer range must be
+ // rejected by length, before any `[ … -gt … ]` tries to compare it.
+ #expect(result.resolved == String(SSHReconnectBudget().maximumLimit))
+ #expect(!result.stderr.contains("integer expression expected"), Comment(rawValue: result.stderr))
+ #expect(result.stderr.contains("using \(SSHReconnectBudget().maximumLimit)."), Comment(rawValue: result.stderr))
+ }
+
+ @Test func unsetBudgetUsesTheFallbackWithoutComplaining() throws {
+ let result = try resolve(nil)
+
+ #expect(result.resolved == String(SSHReconnectBudget().fallbackLimit))
+ #expect(result.stderr.isEmpty, Comment(rawValue: result.stderr))
+ }
+
+ @Test func callerSuppliedFallbackSurvivesItsOwnNormalization() throws {
+ let result = try resolve(nil, fallback: SSHReconnectBudget().maximumLimit)
+
+ #expect(result.resolved == String(SSHReconnectBudget().maximumLimit))
+ #expect(result.stderr.isEmpty, Comment(rawValue: result.stderr))
+ }
+
+ private func resolve(
+ _ limit: String?,
+ fallback: Int = SSHReconnectBudget().fallbackLimit
+ ) throws -> (resolved: String, stderr: String) {
+ let variable = "cmux_test_limit"
+ let script = (SSHReconnectBudget().limitNormalizationShellLines(
+ variable: variable,
+ fallback: fallback
+ ) + ["printf '%s' \"$\(variable)\""]).joined(separator: "\n")
+
+ let process = Process()
+ let stdoutPipe = Pipe()
+ let stderrPipe = Pipe()
+ process.executableURL = URL(fileURLWithPath: "/bin/sh")
+ process.arguments = ["-c", script]
+ var environment = ProcessInfo.processInfo.environment
+ environment.removeValue(forKey: SSHReconnectBudget().limitEnvironmentName)
+ if let limit {
+ environment[SSHReconnectBudget().limitEnvironmentName] = limit
+ }
+ process.environment = environment
+ process.standardInput = FileHandle.nullDevice
+ process.standardOutput = stdoutPipe
+ process.standardError = stderrPipe
+
+ try process.run()
+ let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
+ let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ return (
+ String(data: stdoutData, encoding: .utf8) ?? "",
+ String(data: stderrData, encoding: .utf8) ?? ""
+ )
+ }
+}
diff --git a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift
index b6a07ef3631e..42eab951ad57 100644
--- a/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift
+++ b/Packages/macOS/CmuxRemoteWorkspace/Tests/CmuxRemoteWorkspaceTests/RemoteCLIRelayPolicyTests.swift
@@ -154,6 +154,42 @@ struct RemoteCLIRelayPolicyTests {
}
}
+ /// `workspace.reorder` has no relay parameter contract, so the method gate
+ /// denies it before any selector is read.
+ ///
+ /// The selectors below are UUIDs on purpose. Ref-form selectors such as
+ /// `workspace:1` are rejected by the *selector* gate
+ /// (`RemoteRelayCommandPolicy.malformedSelector`) whether or not the method
+ /// is allowlisted, so a ref-form payload reports `remote_relay_denied`
+ /// either way and this test would stay green through exactly the
+ /// regression it exists to catch. With UUIDs, the method gate is the only
+ /// thing left denying these, so allowlisting `workspace.reorder` turns them
+ /// into `ALLOW` and fails the test.
+ @Test("workspace.reorder has no relay contract")
+ func workspaceReorderHasNoRelayContract() {
+ #expect(
+ RemoteRelayRoutingSchema().parameters(for: "workspace.reorder") == nil,
+ "workspace.reorder must stay absent from the relay routing schema"
+ )
+ }
+
+ @Test("workspace.reorder is denied through a relay", arguments: [
+ #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","index":0}}"#,
+ #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","before_workspace_id":"1EA7D9C4-0000-4000-8000-00000000A002"}}"#,
+ #"{"id":"p5r","method":"workspace.reorder","params":{"workspace_id":"1EA7D9C4-0000-4000-8000-00000000A001","after_workspace_id":"1EA7D9C4-0000-4000-8000-00000000A002"}}"#,
+ ])
+ func deniesWorkspaceReorder(commandLine: String) throws {
+ try withServer { port, unixServer in
+ let exchange = try runPolicyRelayExchange(
+ port: port,
+ relayID: relayID,
+ tokenHex: tokenHex,
+ commandLine: commandLine
+ )
+ expectDenial(exchange, unixServer, "workspace.reorder")
+ }
+ }
+
@Test("non-JSON command lines are denied")
func deniesNonJSONCommandLine() throws {
try withServer { port, unixServer in
diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift
index 96ce135095b5..a67a0758c927 100644
--- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift
+++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/TerminalCatalogSection.swift
@@ -43,6 +43,16 @@ public struct TerminalCatalogSection: SettingCatalogSection {
userDefaultsKey: "terminal.copyOnSelect"
)
+ /// Whether macOS text-editing gestures are replayed as their line-editor
+ /// equivalents: Command and Option arrow motion, and the Command and Option
+ /// deletion chords. Off by default, because the mode claims chords the
+ /// running application would otherwise receive.
+ public let textEditingGestures = DefaultsKey(
+ id: "terminal.textEditingGestures",
+ defaultValue: false,
+ userDefaultsKey: "terminal.textEditingGestures"
+ )
+
/// Whether cmux supplies its appearance-adaptive managed palette for an
/// Ghostty config without authored themes or terminal colors. Font and
/// behavior settings preserve the managed palette; it is enabled by default.
diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift
index 83f18d5b0b8f..4300c2129763 100644
--- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift
+++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift
@@ -170,6 +170,7 @@ extension Array where Element == CuratedSettingEntry {
synonyms: "terminal.scrollSpeed scroll speed multiplier wheel mouse trackpad sensitivity faster slower"
),
.init(section: .terminal, id: "copy-on-select", title: String(localized: "settings.terminal.copyOnSelect", defaultValue: "Copy on Selection"), synonyms: "Copy on Selection terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"),
+ .init(section: .terminal, id: "text-editing-gestures", title: String(localized: "settings.terminal.textEditingGestures", defaultValue: "Text Editing Gestures"), synonyms: "Text Editing Gestures terminal.textEditingGestures text editing gestures option alt word line kill readline emacs keybindings command arrow delete"),
.init(section: .terminal, id: "agent-auto-resume", title: String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), synonyms: "Resume Agent Sessions on Reopen terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"),
.init(section: .terminal, id: "agent-hibernation", title: String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), synonyms: "Agent Hibernation terminal.agentHibernation.enabled idle hibernate suspend background agents claude code codex opencode live terminals"),
.init(section: .terminal, id: "agent-hibernation-idle", title: String(localized: "settings.terminal.agentHibernation.idleSeconds", defaultValue: "Hibernate After Idle Seconds"), synonyms: "Hibernate After Idle Seconds terminal.agentHibernation.idleSeconds idle seconds timeout delay hibernate suspend"),
diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift
index 18fae3f909f2..32eb08406193 100644
--- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift
+++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/TerminalSection.swift
@@ -23,6 +23,7 @@ public struct TerminalSection: View {
@State private var sessionContentAlignment: DefaultsValueModel
@State private var scrollBar: DefaultsValueModel
@State private var copyOnSelect: DefaultsValueModel
+ @State private var textEditingGestures: DefaultsValueModel
@State private var adaptiveDefaultTheme: DefaultsValueModel
@State private var autoResume: DefaultsValueModel
@State private var hibernation: DefaultsValueModel
@@ -50,6 +51,7 @@ public struct TerminalSection: View {
_sessionContentAlignment = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.sessionContentAlignment))
_scrollBar = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.showScrollBar))
_copyOnSelect = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.copyOnSelect))
+ _textEditingGestures = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.terminal.textEditingGestures))
_adaptiveDefaultTheme = State(
initialValue: DefaultsValueModel(
store: defaultsStore,
@@ -84,6 +86,7 @@ public struct TerminalSection: View {
sessionContentAlignment,
scrollBar,
copyOnSelect,
+ textEditingGestures,
adaptiveDefaultTheme,
autoResume,
hibernation,
@@ -424,6 +427,19 @@ public struct TerminalSection: View {
.accessibilityIdentifier("SettingsTerminalCopyOnSelectToggle")
}
SettingsCardDivider()
+ SettingsCardRow(
+ configurationReview: .json("terminal.textEditingGestures"),
+ String(localized: "settings.terminal.textEditingGestures", defaultValue: "Text Editing Gestures"),
+ subtitle: textEditingGestures.current
+ ? String(localized: "settings.terminal.textEditingGestures.subtitleOn", defaultValue: "Command and Option arrow keys move by line and word, and the Command and Option delete keys kill by line and word. Applications receive these chords instead of the gesture, so turn this off before working in a full-screen TUI.")
+ : String(localized: "settings.terminal.textEditingGestures.subtitleOff", defaultValue: "Command and Option key combinations reach the terminal unchanged.")
+ ) {
+ Toggle("", isOn: Binding(get: { textEditingGestures.current }, set: { textEditingGestures.set($0) }))
+ .labelsHidden()
+ .controlSize(.small)
+ .accessibilityIdentifier("SettingsTerminalTextEditingGesturesToggle")
+ }
+ SettingsCardDivider()
SettingsCardRow(
configurationReview: .json("terminal.autoResumeAgentSessions"),
String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"),
diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift
index 272a69bdaa01..f5046a222b6c 100644
--- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift
+++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift
@@ -139,6 +139,7 @@ struct SettingsRowAnchorResolutionTests {
"terminal.rendererRealization.maxWarmRenderers",
"terminal.autoResumeAgentSessions",
"terminal.copyOnSelect",
+ "terminal.textEditingGestures",
"terminal.resumeCommands",
"terminal.sessionContentAlignment",
"terminal.sessionContentMaxWidth",
diff --git a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift
index 8345155fa79d..1d15b54af516 100644
--- a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift
+++ b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorBoundedCommandRunnerTests.swift
@@ -161,8 +161,10 @@ struct SimulatorBoundedCommandRunnerTests {
#expect(result.timedOut)
let pid = try #require(processIdentifier.value)
- #expect(Darwin.kill(pid, 0) != 0)
- #expect(errno == ESRCH)
+ let probeResult = Darwin.kill(pid, 0)
+ let probeErrno = errno
+ #expect(probeResult != 0)
+ #expect(probeErrno == ESRCH)
}
@Test("The public runner bounds timeout and kills descendants")
@@ -184,8 +186,10 @@ struct SimulatorBoundedCommandRunnerTests {
#expect(result.status == 124)
let descendant = try await requireMarkerPID(marker)
await expectProcessExited(descendant)
- #expect(Darwin.kill(descendant, 0) != 0)
- #expect(errno == ESRCH)
+ let probeResult = Darwin.kill(descendant, 0)
+ let probeErrno = errno
+ #expect(probeResult != 0)
+ #expect(probeErrno == ESRCH)
}
@Test("The owned command runner delegates asynchronously to its injected process runner")
diff --git a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift
index 1763f3cfee33..ae1298f6a7c8 100644
--- a/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift
+++ b/Packages/macOS/CmuxSimulator/Tests/CmuxSimulatorTests/SimulatorLengthPrefixedMessageChannelTests.swift
@@ -84,8 +84,10 @@ struct SimulatorLengthPrefixedMessageChannelTests {
await Task.yield()
}
- #expect(kill(processIdentifier, 0) == -1)
- #expect(errno == ESRCH)
+ let probeResult = kill(processIdentifier, 0)
+ let probeErrno = errno
+ #expect(probeResult == -1)
+ #expect(probeErrno == ESRCH)
#expect(throws: SimulatorChannelError.writeFailed) {
try connection.send(Data("must not reach the terminated worker".utf8))
}
diff --git a/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift b/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift
index 3058faac0e84..ae9e4912d5e2 100644
--- a/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift
+++ b/Packages/macOS/CmuxSudoBroker/Tests/CmuxSudoBrokerTests/SudoProcessLifecycleTests.swift
@@ -121,8 +121,10 @@ struct SudoProcessLifecycleTests {
#expect(reapedProcessIdentifier == process.identity.processIdentifier)
var status: Int32 = 0
- #expect(waitpid(process.identity.processIdentifier, &status, WNOHANG) == -1)
- #expect(errno == ECHILD)
+ let reapResult = waitpid(process.identity.processIdentifier, &status, WNOHANG)
+ let reapErrno = errno
+ #expect(reapResult == -1)
+ #expect(reapErrno == ECHILD)
}
@Test("Execution deadline terminates a script PTY tree", .timeLimit(.minutes(1)))
diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
index b6bc6d2a6b2c..141aef36ca52 100644
--- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
+++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift
@@ -85,48 +85,60 @@ extension TerminalSurface {
/// Sends paste-style text to the surface, queueing on a cold surface.
///
+ /// - Parameter text: Literal UTF-8 text to paste.
/// - Returns: Whether the text was delivered or queued.
@MainActor
@discardableResult
public func sendText(_ text: String) -> Bool {
- guard let data = text.data(using: .utf8), !data.isEmpty else { return true }
+ sendTextResult(text).accepted
+ }
+
+ /// Sends paste-style text and reports whether it was delivered or queued.
+ ///
+ /// Delivery means handed to the live terminal runtime, not consumed by its child process.
+ /// - Parameter text: Literal UTF-8 text to paste. Empty text succeeds without a write.
+ /// - Returns: The immediate delivery, queueing, or rejection outcome.
+ @MainActor
+ @discardableResult
+ public func sendTextResult(_ text: String) -> TextSendResult {
+ guard let data = text.data(using: .utf8), !data.isEmpty else { return .sent }
didReceiveExplicitInput()
- let accepted = sendTextAfterExplicitInput(data)
- if accepted {
+ let result = sendTextAfterExplicitInput(data)
+ if result.accepted {
hibernationRecorder.recordTerminalInput(
workspaceId: tabId,
panelId: id
)
}
- return accepted
+ return result
}
@MainActor
- private func sendTextAfterExplicitInput(_ data: Data) -> Bool {
+ private func sendTextAfterExplicitInput(_ data: Data) -> TextSendResult {
if deferInputDuringRuntimeClipboardRead(
estimatedBytes: data.count,
replay: { [weak self] in
_ = self?.sendTextAfterExplicitInput(data)
}
) {
- return true
+ return .queued
}
guard surface != nil else {
- guard allowsRuntimeSurfaceCreation() else { return false }
+ guard allowsRuntimeSurfaceCreation() else { return .surfaceUnavailable }
let queued = enqueuePendingSocketInput(.pasteText(data))
if queued {
requestInputDemandSurfaceStartIfNeeded()
didAcceptExplicitInput()
}
- return queued
+ return queued ? .queued : .inputQueueFull
}
guard let liveSurface = liveSurfaceForSocketWrite(reason: "socket.sendText") else {
- return false
+ return .surfaceUnavailable
}
- guard !ghostty_surface_process_exited(liveSurface) else { return false }
+ guard !ghostty_surface_process_exited(liveSurface) else { return .processExited }
writeTextData(data, to: liveSurface)
didAcceptExplicitInput()
- return true
+ return .sent
}
/// Sends raw key text as a single key event.
diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
index 619f5092ddb3..cde17691c997 100644
--- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
+++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift
@@ -33,6 +33,7 @@ public final class TerminalSurface: Identifiable, ObservableObject {
// nested TerminalSurface.NamedKeySendResult/.InputSendResult names that
// other files use.
public typealias NamedKeySendResult = CmuxTerminalCore.NamedKeySendResult
+ public typealias TextSendResult = CmuxTerminalCore.TextSendResult
public typealias InputSendResult = CmuxTerminalCore.InputSendResult
public typealias AgentCommandShimSet = TerminalSurfaceAgentCommandShimSet
public typealias CmuxContextEnvironment = TerminalSurfaceCmuxContextEnvironment
diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift
index 331599512abb..2e1923a1f410 100644
--- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift
+++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/TerminalSurfaceExplicitInputTests.swift
@@ -93,11 +93,46 @@ struct TerminalSurfaceExplicitInputTests {
let fixture = makeFixture()
defer { fixture.surface.releaseSurfaceForTesting() }
- #expect(fixture.surface.sendText("hello"))
+ #expect(fixture.surface.sendTextResult("hello") == .queued)
#expect(fixture.paneHost.explicitInputCount == 1)
}
+ @Test func pasteReportsClipboardDeferralAndRetainsOneReplay() {
+ let fixture = makeFixture()
+ defer { fixture.surface.releaseSurfaceForTesting() }
+ fixture.nativeView.shouldDeferRuntimeInput = true
+
+ #expect(fixture.surface.sendTextResult("literal\n世界") == .queued)
+ #expect(fixture.nativeView.deferredRuntimeInputs.count == 1)
+ #expect(fixture.surface.pendingSocketInputBytes == 0)
+
+ fixture.nativeView.shouldDeferRuntimeInput = false
+ fixture.nativeView.deferredRuntimeInputs.removeFirst()()
+ #expect(fixture.surface.pendingSocketInputBytes == "literal\n世界".utf8.count)
+ }
+
+ @Test func pasteReportsQueueFullWithoutAcceptingText() {
+ let fixture = makeFixture()
+ defer { fixture.surface.releaseSurfaceForTesting() }
+ fixture.surface.pendingSocketInputBytes = fixture.surface.maxPendingSocketInputBytes
+ var accepted = 0
+ fixture.surface.onExplicitInput = { accepted += 1 }
+
+ #expect(fixture.surface.sendTextResult("literal\n世界") == .inputQueueFull)
+ #expect(fixture.surface.pendingSocketInputBytes == fixture.surface.maxPendingSocketInputBytes)
+ #expect(accepted == 0)
+ }
+
+ @Test func pasteReportsClosedSurfaceWithoutQueueing() {
+ let fixture = makeFixture()
+ defer { fixture.surface.releaseSurfaceForTesting() }
+ fixture.surface.beginPortalCloseLifecycle(reason: "test.closed")
+
+ #expect(fixture.surface.sendTextResult("literal\n世界") == .surfaceUnavailable)
+ #expect(fixture.surface.pendingSocketInputBytes == 0)
+ }
+
@Test func parsedInputNotifiesPaneHostBeforeQueueingOnAColdSurface() {
let fixture = makeFixture()
defer { fixture.surface.releaseSurfaceForTesting() }
diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TextSendResult.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TextSendResult.swift
new file mode 100644
index 000000000000..63f5fbc081c7
--- /dev/null
+++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/SurfaceValues/TextSendResult.swift
@@ -0,0 +1,23 @@
+/// The outcome of sending literal paste text to a terminal surface.
+public enum TextSendResult: Equatable, Sendable {
+ /// Delivered to the live runtime surface.
+ case sent
+ /// Accepted for ordered delivery after surface startup or a clipboard read.
+ case queued
+ /// The pending-input queue is at capacity.
+ case inputQueueFull
+ /// No runtime surface exists and none is starting.
+ case surfaceUnavailable
+ /// The surface's child process already exited.
+ case processExited
+
+ /// Whether the text was delivered or accepted for ordered delivery.
+ public var accepted: Bool {
+ switch self {
+ case .sent, .queued:
+ true
+ case .inputQueueFull, .surfaceUnavailable, .processExited:
+ false
+ }
+ }
+}
diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingKeyResolution.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingKeyResolution.swift
new file mode 100644
index 000000000000..d21d904f30f1
--- /dev/null
+++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingKeyResolution.swift
@@ -0,0 +1,136 @@
+/// Virtual key codes the text-editing resolver recognises.
+///
+/// These mirror the Carbon `kVK_*` constants the app target already uses, kept
+/// here so the package stays free of a Carbon dependency.
+enum TerminalTextEditingKeyCode {
+ /// `kVK_Delete` — the Backspace key.
+ static let backspace: UInt16 = 0x33
+ /// `kVK_ForwardDelete` — the forward Delete key.
+ static let forwardDelete: UInt16 = 0x75
+ /// `kVK_LeftArrow`.
+ static let leftArrow: UInt16 = 0x7B
+ /// `kVK_RightArrow`.
+ static let rightArrow: UInt16 = 0x7C
+}
+
+/// The chord a text-editing gesture stands in for.
+///
+/// The resolver deliberately names a *chord* rather than the bytes it encodes
+/// to. The app target replays the chord through the ordinary key path, so
+/// Ghostty performs the encoding and the result stays correct under whichever
+/// keyboard protocol the running application negotiated. Emitting raw bytes
+/// would bypass that and send legacy control codes to an application expecting
+/// `CSI u`.
+public struct TerminalTextEditingChord: Equatable, Sendable {
+ /// The modifier the replayed chord carries.
+ public enum Modifier: Equatable, Sendable {
+ /// The Control modifier, as in `Ctrl+A`.
+ case control
+ /// The Option/Alt modifier, as in `Alt+b`.
+ case option
+ }
+
+ /// The ASCII lowercase letter of the chord.
+ public let letter: Character
+
+ /// The modifier held with ``letter``.
+ public let modifier: Modifier
+
+ /// Creates a chord.
+ ///
+ /// - Parameters:
+ /// - letter: The ASCII lowercase letter of the chord.
+ /// - modifier: The modifier held with `letter`.
+ public init(letter: Character, modifier: Modifier) {
+ self.letter = letter
+ self.modifier = modifier
+ }
+
+ /// `Ctrl+A` — move to the beginning of the line.
+ static let beginningOfLine = TerminalTextEditingChord(letter: "a", modifier: .control)
+ /// `Ctrl+E` — move to the end of the line.
+ static let endOfLine = TerminalTextEditingChord(letter: "e", modifier: .control)
+ /// `Alt+b` — move backward one word.
+ static let backwardWord = TerminalTextEditingChord(letter: "b", modifier: .option)
+ /// `Alt+f` — move forward one word.
+ static let forwardWord = TerminalTextEditingChord(letter: "f", modifier: .option)
+ /// `Ctrl+U` — kill from the cursor to the beginning of the line.
+ static let killToLineStart = TerminalTextEditingChord(letter: "u", modifier: .control)
+ /// `Ctrl+K` — kill from the cursor to the end of the line.
+ static let killToLineEnd = TerminalTextEditingChord(letter: "k", modifier: .control)
+ /// `Ctrl+W` — kill the word before the cursor.
+ static let killBackwardWord = TerminalTextEditingChord(letter: "w", modifier: .control)
+ /// `Alt+d` — kill the word after the cursor.
+ static let killForwardWord = TerminalTextEditingChord(letter: "d", modifier: .option)
+}
+
+/// Strips modifiers that never participate in gesture matching.
+private func terminalTextEditingNormalizedModifiers(
+ _ modifiers: TerminalTextEditingModifiers
+) -> TerminalTextEditingModifiers {
+ modifiers.subtracting([.numericPad, .function, .capsLock])
+}
+
+/// Resolves a macOS text-editing gesture into the line-editor chord it stands for.
+///
+/// Returns `nil` for anything the mode does not own, which the caller must pass
+/// through untouched. In particular this returns `nil` for every event carrying
+/// Control, so `Ctrl+C` and friends keep reaching the remote unchanged, and for
+/// Shift combinations, because readline and zle have no selection model for a
+/// shift-extended gesture to target.
+///
+/// `Cmd+A` is deliberately unmapped. In macOS it means select-all, which has no
+/// line-editor equivalent, and silently repurposing it as "beginning of line"
+/// would give the chord a second meaning users did not ask for.
+///
+/// ```swift
+/// let chord = terminalTextEditingResolve(
+/// keyCode: 0x7B, // Left arrow
+/// modifiers: [.option]
+/// )
+/// // chord == TerminalTextEditingChord(letter: "b", modifier: .option)
+/// ```
+///
+/// - Parameters:
+/// - keyCode: The virtual key code of the event.
+/// - modifiers: The event modifiers, already mapped off AppKit.
+/// - Returns: The chord to replay, or `nil` when the event is not a
+/// text-editing gesture and should pass through to the terminal unchanged.
+public func terminalTextEditingResolve(
+ keyCode: UInt16,
+ modifiers: TerminalTextEditingModifiers
+) -> TerminalTextEditingChord? {
+ let normalized = terminalTextEditingNormalizedModifiers(modifiers)
+
+ // Control-bearing events stay with the remote application, always.
+ guard !normalized.contains(.control) else { return nil }
+
+ // No selection model downstream, so a shift-extended gesture has nothing to
+ // resolve to. Pass it through rather than dropping the shift silently.
+ guard !normalized.contains(.shift) else { return nil }
+
+ let hasCommand = normalized.contains(.command)
+ let hasOption = normalized.contains(.option)
+
+ // Exactly one of Command or Option selects the gesture family. Both at once
+ // is ambiguous, and neither means an ordinary keystroke.
+ guard hasCommand != hasOption else { return nil }
+
+ if hasCommand {
+ switch keyCode {
+ case TerminalTextEditingKeyCode.leftArrow: return .beginningOfLine
+ case TerminalTextEditingKeyCode.rightArrow: return .endOfLine
+ case TerminalTextEditingKeyCode.backspace: return .killToLineStart
+ case TerminalTextEditingKeyCode.forwardDelete: return .killToLineEnd
+ default: return nil
+ }
+ }
+
+ switch keyCode {
+ case TerminalTextEditingKeyCode.leftArrow: return .backwardWord
+ case TerminalTextEditingKeyCode.rightArrow: return .forwardWord
+ case TerminalTextEditingKeyCode.backspace: return .killBackwardWord
+ case TerminalTextEditingKeyCode.forwardDelete: return .killForwardWord
+ default: return nil
+ }
+}
diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingModifiers.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingModifiers.swift
new file mode 100644
index 000000000000..1bcb5692f39d
--- /dev/null
+++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/TextEditing/TerminalTextEditingModifiers.swift
@@ -0,0 +1,51 @@
+/// Modifier keys relevant to terminal text-editing gesture resolution.
+///
+/// `TerminalTextEditingModifiers` is a small, platform-neutral option set that
+/// lets the gesture resolver avoid depending on AppKit event types. The app
+/// target maps `NSEvent.ModifierFlags` into this type before calling
+/// ``terminalTextEditingResolve(keyCode:modifiers:)``.
+///
+/// This is deliberately separate from ``TerminalKeyboardCopyModeModifiers``,
+/// which has no Option member because copy mode never needed one. Text editing
+/// is built around Option, so it carries its own set rather than widening a
+/// type that shipping copy-mode code depends on.
+///
+/// ```swift
+/// let modifiers: TerminalTextEditingModifiers = [.option]
+/// if modifiers.contains(.option) {
+/// print("word-wise motion")
+/// }
+/// ```
+public struct TerminalTextEditingModifiers: OptionSet, Equatable, Sendable {
+ /// The raw option-set storage.
+ public let rawValue: UInt8
+
+ /// Creates a modifier set from raw option bits.
+ ///
+ /// - Parameter rawValue: The raw option-set storage. Unknown bits are
+ /// preserved so callers can round-trip values produced by `OptionSet`.
+ public init(rawValue: UInt8) {
+ self.rawValue = rawValue
+ }
+
+ /// The Command modifier.
+ public static let command = TerminalTextEditingModifiers(rawValue: 1 << 0)
+
+ /// The Shift modifier.
+ public static let shift = TerminalTextEditingModifiers(rawValue: 1 << 1)
+
+ /// The Control modifier.
+ public static let control = TerminalTextEditingModifiers(rawValue: 1 << 2)
+
+ /// The Option modifier.
+ public static let option = TerminalTextEditingModifiers(rawValue: 1 << 3)
+
+ /// The numeric-pad modifier, ignored during gesture matching.
+ public static let numericPad = TerminalTextEditingModifiers(rawValue: 1 << 4)
+
+ /// The function-key modifier, ignored during gesture matching.
+ public static let function = TerminalTextEditingModifiers(rawValue: 1 << 5)
+
+ /// The caps-lock modifier, ignored during gesture matching.
+ public static let capsLock = TerminalTextEditingModifiers(rawValue: 1 << 6)
+}
diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift
index abc8c7da062f..a76ab43e9a21 100644
--- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift
+++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/SurfaceValueTests.swift
@@ -14,6 +14,16 @@ import GhosttyKit
}
}
+@Suite struct TextSendResultTests {
+ @Test func acceptedDistinguishesDeliveryFromQueueing() {
+ #expect(TextSendResult.sent.accepted)
+ #expect(TextSendResult.queued.accepted)
+ #expect(!TextSendResult.inputQueueFull.accepted)
+ #expect(!TextSendResult.surfaceUnavailable.accepted)
+ #expect(!TextSendResult.processExited.accepted)
+ }
+}
+
@Suite struct InputSendResultTests {
@Test func acceptedReflectsDelivery() {
#expect(InputSendResult.sent.accepted)
diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TextEditing/TerminalTextEditingKeyResolutionTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TextEditing/TerminalTextEditingKeyResolutionTests.swift
new file mode 100644
index 000000000000..31dd5c054a34
--- /dev/null
+++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TextEditing/TerminalTextEditingKeyResolutionTests.swift
@@ -0,0 +1,85 @@
+import CmuxTerminalCore
+import Testing
+
+@Suite("Terminal text-editing gesture resolver")
+struct TerminalTextEditingKeyResolutionTests {
+ private enum Key {
+ static let backspace: UInt16 = 0x33
+ static let forwardDelete: UInt16 = 0x75
+ static let leftArrow: UInt16 = 0x7B
+ static let rightArrow: UInt16 = 0x7C
+ static let letterC: UInt16 = 0x08
+ }
+
+ @Test func commandGesturesResolveToLineWiseEditing() {
+ let cases: [(keyCode: UInt16, chord: TerminalTextEditingChord)] = [
+ (Key.leftArrow, TerminalTextEditingChord(letter: "a", modifier: .control)),
+ (Key.rightArrow, TerminalTextEditingChord(letter: "e", modifier: .control)),
+ (Key.backspace, TerminalTextEditingChord(letter: "u", modifier: .control)),
+ (Key.forwardDelete, TerminalTextEditingChord(letter: "k", modifier: .control)),
+ ]
+ for testCase in cases {
+ let chord = terminalTextEditingResolve(keyCode: testCase.keyCode, modifiers: [.command])
+ #expect(chord == testCase.chord, "keyCode \(testCase.keyCode)")
+ }
+ }
+
+ @Test func optionGesturesResolveToWordWiseEditing() {
+ let cases: [(keyCode: UInt16, chord: TerminalTextEditingChord)] = [
+ (Key.leftArrow, TerminalTextEditingChord(letter: "b", modifier: .option)),
+ (Key.rightArrow, TerminalTextEditingChord(letter: "f", modifier: .option)),
+ (Key.backspace, TerminalTextEditingChord(letter: "w", modifier: .control)),
+ (Key.forwardDelete, TerminalTextEditingChord(letter: "d", modifier: .option)),
+ ]
+ for testCase in cases {
+ let chord = terminalTextEditingResolve(keyCode: testCase.keyCode, modifiers: [.option])
+ #expect(chord == testCase.chord, "keyCode \(testCase.keyCode)")
+ }
+ }
+
+ /// Control must always reach the remote, or the mode would eat Ctrl+C.
+ @Test func controlBearingEventsAlwaysPassThrough() {
+ let modifierSets: [TerminalTextEditingModifiers] = [
+ [.control],
+ [.control, .command],
+ [.control, .option],
+ [.control, .shift],
+ ]
+ for modifiers in modifierSets {
+ #expect(terminalTextEditingResolve(keyCode: Key.letterC, modifiers: modifiers) == nil)
+ #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: modifiers) == nil)
+ }
+ }
+
+ /// Readline and zle have no selection model, so shift has nothing to target.
+ @Test func shiftExtendedGesturesPassThrough() {
+ #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: [.command, .shift]) == nil)
+ #expect(terminalTextEditingResolve(keyCode: Key.rightArrow, modifiers: [.option, .shift]) == nil)
+ }
+
+ /// Command+Option is ambiguous; neither family should claim it.
+ @Test func commandAndOptionTogetherPassThrough() {
+ #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: [.command, .option]) == nil)
+ }
+
+ /// An unmodified keystroke is ordinary input, not a gesture.
+ @Test func unmodifiedKeysPassThrough() {
+ #expect(terminalTextEditingResolve(keyCode: Key.leftArrow, modifiers: []) == nil)
+ #expect(terminalTextEditingResolve(keyCode: Key.backspace, modifiers: []) == nil)
+ }
+
+ /// Only the four navigation/deletion keys are owned; Cmd+C must stay a shortcut.
+ @Test func unmappedKeysPassThroughEvenWithGestureModifiers() {
+ #expect(terminalTextEditingResolve(keyCode: Key.letterC, modifiers: [.command]) == nil)
+ #expect(terminalTextEditingResolve(keyCode: Key.letterC, modifiers: [.option]) == nil)
+ }
+
+ /// Lock and pad modifiers are noise and must not defeat a real gesture.
+ @Test func ignoredModifiersDoNotBlockResolution() {
+ let chord = terminalTextEditingResolve(
+ keyCode: Key.leftArrow,
+ modifiers: [.option, .capsLock, .numericPad, .function]
+ )
+ #expect(chord == TerminalTextEditingChord(letter: "b", modifier: .option))
+ }
+}
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index ef326b2dffe2..86980195f4a0 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -374718,6 +374718,183 @@
}
}
},
+ "settings.terminal.textEditingGestures": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Text Editing Gestures"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "テキスト編集ジェスチャー"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文本编辑手势"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Textbearbeitungsgesten"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Gestes d’édition de texte"
+ }
+ },
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "إيماءات تحرير النص"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Gestos de edición de texto"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "文字編輯手勢"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "텍스트 편집 제스처"
+ }
+ }
+ }
+ },
+ "settings.terminal.textEditingGestures.subtitleOn": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command and Option arrow keys move by line and word, and the Command and Option delete keys kill by line and word. Applications receive these chords instead of the gesture, so turn this off before working in a full-screen TUI."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command と Option の矢印キーで行単位・単語単位に移動し、Command と Option の削除キーで行単位・単語単位に削除します。アプリケーションにはジェスチャーではなくこれらのキーの組み合わせが送られるため、フルスクリーンの TUI で作業する前にオフにしてください。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command 和 Option 加方向键按行和按词移动,Command 和 Option 加删除键按行和按词删除。应用程序收到的是这些组合键而不是手势,因此在全屏 TUI 中工作前请关闭此项。"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Befehls- und Wahltaste mit Pfeiltasten bewegen zeilen- und wortweise, Befehls- und Wahltaste mit der Löschtaste löschen zeilen- und wortweise. Programme erhalten diese Tastenkombinationen statt der Geste, deshalb vor der Arbeit in einer Vollbild-TUI ausschalten."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Les flèches avec Commande et Option déplacent par ligne et par mot, et la touche Supprimer avec Commande et Option supprime par ligne et par mot. Les applications reçoivent ces combinaisons au lieu du geste ; désactivez donc cette option avant de travailler dans une TUI plein écran."
+ }
+ },
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "تنقل مفاتيح الأسهم مع Command وOption حسب السطر والكلمة، وتحذف مفاتيح الحذف مع Command وOption حسب السطر والكلمة. تتلقى التطبيقات هذه التركيبات بدلاً من الإيماءة، لذا أوقف هذا الخيار قبل العمل في واجهة TUI بملء الشاشة."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Las flechas con Comando y Opción mueven por línea y por palabra, y la tecla Eliminar con Comando y Opción borra por línea y por palabra. Las aplicaciones reciben estas combinaciones en lugar del gesto, así que desactívelo antes de trabajar en una TUI a pantalla completa."
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command 與 Option 加方向鍵可依行與依字移動,Command 與 Option 加刪除鍵可依行與依字刪除。應用程式收到的是這些組合鍵而非手勢,因此在全螢幕 TUI 中工作前請關閉此項。"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command 및 Option 화살표 키로 줄 및 단어 단위로 이동하고, Command 및 Option 삭제 키로 줄 및 단어 단위로 삭제합니다. 애플리케이션은 제스처 대신 이 키 조합을 받으므로 전체 화면 TUI에서 작업하기 전에 이 설정을 끄세요."
+ }
+ }
+ }
+ },
+ "settings.terminal.textEditingGestures.subtitleOff": {
+ "extractionState": "manual",
+ "localizations": {
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command and Option key combinations reach the terminal unchanged."
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command と Option のキーの組み合わせは変更されずにターミナルに送られます。"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command 和 Option 组合键会原样传递给终端。"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Tastenkombinationen mit Befehls- und Wahltaste erreichen das Terminal unverändert."
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Les combinaisons avec Commande et Option parviennent au terminal sans modification."
+ }
+ },
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "تصل تركيبات مفاتيح Command وOption إلى الطرفية دون تغيير."
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Las combinaciones con Comando y Opción llegan a la terminal sin cambios."
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command 與 Option 組合鍵會原封不動傳送到終端機。"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Command 및 Option 키 조합이 변경 없이 터미널로 전달됩니다."
+ }
+ }
+ }
+ },
"settings.textBox.betaWarning": {
"extractionState": "manual",
"localizations": {
@@ -442240,6 +442417,381 @@
}
}
},
+ "socket.workspace.reorder.indexNotAnInteger": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "يجب أن يكون index عددًا صحيحًا"
+ }
+ },
+ "bs": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "da": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index muss eine ganze Zahl sein"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index must be an integer"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index debe ser un número entero"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index doit être un entier"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index は整数である必要があります"
+ }
+ },
+ "km": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index는 정수여야 합니다"
+ }
+ },
+ "nb": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "th": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "tr": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "uk": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "index must be an integer"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index 必须是整数"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index 必須是整數"
+ }
+ }
+ }
+ },
+ "socket.workspace.reorder.missingWorkspaceID": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "قيمة workspace_id مفقودة أو غير صالحة"
+ }
+ },
+ "bs": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "da": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Fehlende oder ungültige workspace_id"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Falta workspace_id o no es válido"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "workspace_id manquant ou invalide"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "workspace_id がないか無効です"
+ }
+ },
+ "km": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "workspace_id가 없거나 유효하지 않습니다"
+ }
+ },
+ "nb": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "th": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "tr": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "uk": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Missing or invalid workspace_id"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "缺少或无效的 workspace_id"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "缺少或無效的 workspace_id"
+ }
+ }
+ }
+ },
+ "socket.workspace.reorder.targetRequired": {
+ "extractionState": "manual",
+ "localizations": {
+ "ar": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "حدّد هدفًا واحدًا فقط: index أو before_workspace_id أو after_workspace_id"
+ }
+ },
+ "bs": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "da": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "de": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Genau ein Ziel angeben: index, before_workspace_id oder after_workspace_id"
+ }
+ },
+ "en": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "es": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Especifica exactamente un destino: index, before_workspace_id o after_workspace_id"
+ }
+ },
+ "fr": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "Indiquez exactement une cible : index, before_workspace_id ou after_workspace_id"
+ }
+ },
+ "it": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "ja": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index、before_workspace_id、after_workspace_id のいずれか 1 つだけを指定してください"
+ }
+ },
+ "km": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "ko": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "index, before_workspace_id, after_workspace_id 중 정확히 하나만 지정하세요"
+ }
+ },
+ "nb": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "pl": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "pt-BR": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "ru": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "th": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "tr": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "uk": {
+ "stringUnit": {
+ "state": "needs_review",
+ "value": "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ }
+ },
+ "zh-Hans": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "请只指定一个目标:index、before_workspace_id 或 after_workspace_id"
+ }
+ },
+ "zh-Hant": {
+ "stringUnit": {
+ "state": "translated",
+ "value": "請只指定一個目標:index、before_workspace_id 或 after_workspace_id"
+ }
+ }
+ }
+ },
"socket.workspace.reorderMany.duplicateWorkspace": {
"extractionState": "manual",
"localizations": {
diff --git a/Sources/App/AgentHibernationController.swift b/Sources/App/AgentHibernationController.swift
index 0e3d4e70e2e9..0d4fadba597f 100644
--- a/Sources/App/AgentHibernationController.swift
+++ b/Sources/App/AgentHibernationController.swift
@@ -22,7 +22,7 @@ struct AgentHibernationRecord {
let panelProcessIDs: Set
let processIDs: Set
let processIdentities: [Int: AgentPIDProcessIdentity]
- let processLiveness: RestorableAgentProcessLiveness
+ private(set) var processLiveness: RestorableAgentProcessLiveness = .unknown
init(
key: AgentHibernationPanelKey,
workspace: Workspace,
diff --git a/Sources/Auth/AuthEnvironment.swift b/Sources/Auth/AuthEnvironment.swift
index 4bedbebf341e..28e9e018dbf7 100644
--- a/Sources/Auth/AuthEnvironment.swift
+++ b/Sources/Auth/AuthEnvironment.swift
@@ -15,9 +15,73 @@ enum AuthEnvironment {
private static let productionStackProjectID = "9790718f-14cd-4f7e-824d-eaf527a82b82"
private static let productionStackPublishableClientKey = "pck_kzj80gx4mh2jrzn1cx6y5e8jk0kwa01vkevh2p9zd4twr"
+ /// Debug-only values that may be changed after the app is built. The file
+ /// is intentionally an allowlist of routing/channel knobs, never secrets.
+ /// It wins over inherited process variables because launchers commonly
+ /// inherit a stale port from another tagged cmux process.
+ private static let debugRuntimeOverrideKeys: [String] = [
+ "CMUX_API_BASE_URL",
+ "CMUX_VM_API_BASE_URL",
+ "CMUX_WWW_ORIGIN",
+ "CMUX_AUTH_WWW_ORIGIN",
+ "CMUX_BILLING_WWW_ORIGIN",
+ "CMUX_PUSH_API_BASE_URL",
+ "CMUX_DEVICE_REGISTRY_API_BASE_URL",
+ "CMUX_IROH_BROKER_BASE_URL",
+ "CMUX_IROH_V2_BASE_URL",
+ "CMUX_IROH_V2_ENVIRONMENT",
+ "CMUX_IROH_V2_FORCE_RELAY",
+ "CMUX_STACK_BASE_URL",
+ "CMUX_STACK_PROJECT_ID",
+ "CMUX_STACK_PUBLISHABLE_CLIENT_KEY",
+ "CMUX_AUTH_ENVIRONMENT",
+ "CMUX_AUTH_CALLBACK_SCHEME",
+ "CMUX_PORT",
+ "PORT",
+ "CMUX_DEV_BACKEND_TAILSCALE_HOST",
+ "CMUX_DEV_BACKEND_TRANSPORT",
+ ]
+
+ /// The one runtime configuration surface for a Debug app. A file override
+ /// is explicit operator state and therefore takes precedence over the
+ /// parent process environment; Release never reads it.
+ private static var runtimeEnvironment: [String: String] {
+ #if DEBUG
+ return mergedRuntimeEnvironment(
+ environment: ProcessInfo.processInfo.environment,
+ fileOverrides: debugRuntimeOverrides()
+ )
+ #else
+ return ProcessInfo.processInfo.environment
+ #endif
+ }
+
+ /// Merges only the allowlisted local routing/channel values. Keeping this
+ /// pure makes the precedence rule testable without touching a developer's
+ /// actual home directory or process environment.
+ static func mergedRuntimeEnvironment(
+ environment: [String: String],
+ fileOverrides: [String: String]
+ ) -> [String: String] {
+ var merged = environment
+ for key in debugRuntimeOverrideKeys {
+ if let value = fileOverrides[key]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !value.isEmpty {
+ merged[key] = value
+ }
+ }
+ return merged
+ }
+
+ private static func debugRuntimeOverrides() -> [String: String] {
+ Dictionary(uniqueKeysWithValues: debugRuntimeOverrideKeys.compactMap { key in
+ devOverride(key: key).map { (key, $0) }
+ })
+ }
+
static var callbackScheme: String {
callbackScheme(
- environment: ProcessInfo.processInfo.environment,
+ environment: runtimeEnvironment,
bundleIdentifier: Bundle.main.bundleIdentifier
)
}
@@ -91,16 +155,15 @@ enum AuthEnvironment {
}
static var websiteOrigin: URL {
- appWebOrigin(environment: ProcessInfo.processInfo.environment)
+ appWebOrigin(environment: runtimeEnvironment)
}
/// Pricing page used by every "Upgrade to cmux Pro" entrypoint
- /// (Settings, command palette, Help menu). Resolution order mirrors
- /// ``vmAPIBaseURL``: process env `CMUX_WWW_ORIGIN`, then the DEBUG-only
- /// `~/.cmux-dev.env` file (so a deeplink-launched dev build can point at
- /// a local web server), then the production website.
+ /// (Settings, command palette, Help menu). The DEBUG-only
+ /// `~/.cmux-dev.env` file wins over inherited process values, so a
+ /// deeplink-launched dev build can point at the current tagged backend.
static var pricingURL: URL {
- resolvedPricingURL(environment: ProcessInfo.processInfo.environment)
+ resolvedPricingURL(environment: runtimeEnvironment)
}
static func resolvedPricingURL(environment: [String: String]) -> URL {
@@ -108,11 +171,11 @@ enum AuthEnvironment {
}
static var appPricingURL: URL {
- resolvedAppPricingURL(environment: ProcessInfo.processInfo.environment)
+ resolvedAppPricingURL(environment: runtimeEnvironment)
}
static var appWebOrigin: URL {
- resolvedAppWebOrigin(environment: ProcessInfo.processInfo.environment)
+ resolvedAppWebOrigin(environment: runtimeEnvironment)
}
/// Credential-bearing native-to-web handoffs are pinned to cmux.com in
@@ -126,7 +189,7 @@ enum AuthEnvironment {
let isDebugBuild = false
#endif
return resolvedAppSessionHandoffOrigin(
- environment: ProcessInfo.processInfo.environment,
+ environment: runtimeEnvironment,
isDebugBuild: isDebugBuild
)
}
@@ -181,7 +244,7 @@ enum AuthEnvironment {
}
static var appProWelcomeURL: URL {
- resolvedAppProWelcomeURL(environment: ProcessInfo.processInfo.environment)
+ resolvedAppProWelcomeURL(environment: runtimeEnvironment)
}
static func resolvedAppProWelcomeURL(environment: [String: String]) -> URL {
@@ -195,7 +258,7 @@ enum AuthEnvironment {
/// request on the same origin that rendered pricing instead of crossing to
/// production.
static var billingCheckoutURL: URL {
- resolvedBillingCheckoutURL(environment: ProcessInfo.processInfo.environment)
+ resolvedBillingCheckoutURL(environment: runtimeEnvironment)
}
static func resolvedBillingCheckoutURL(environment: [String: String]) -> URL {
@@ -206,7 +269,7 @@ enum AuthEnvironment {
}
static var billingPortalURL: URL {
- resolvedBillingPortalURL(environment: ProcessInfo.processInfo.environment)
+ resolvedBillingPortalURL(environment: runtimeEnvironment)
}
static func resolvedBillingPortalURL(environment: [String: String]) -> URL {
@@ -214,12 +277,12 @@ enum AuthEnvironment {
}
static var signInWebsiteOrigin: URL {
- resolvedAuthWebOrigin(environment: ProcessInfo.processInfo.environment)
+ resolvedAuthWebOrigin(environment: runtimeEnvironment)
}
static var apiBaseURL: URL {
resolvedAPIBaseURL(
- environment: ProcessInfo.processInfo.environment,
+ environment: runtimeEnvironment,
isDebugBuild: isDebugBuild
)
}
@@ -251,13 +314,13 @@ enum AuthEnvironment {
/// Base URL for the cmux-owned cloud VM backend (`/api/vm`).
///
- /// Resolution order (first hit wins):
- /// 1. process env `CMUX_VM_API_BASE_URL` — works when the app is launched from a shell.
- /// 2. `~/.cmux-dev.env` file `CMUX_VM_API_BASE_URL=...` line — works regardless of how
- /// the app was launched (click-through, Dock, `open`, etc.). Only honored in DEBUG.
+ /// Resolution order for Debug (first hit wins):
+ /// 1. `~/.cmux-dev.env` file `CMUX_VM_API_BASE_URL=...` line — explicit
+ /// operator state that works for click-through, Dock, and `open`.
+ /// 2. process env `CMUX_VM_API_BASE_URL` — useful for one-off launches.
/// 3. VM backend dev origin (`http://localhost:$CMUX_PORT` in Debug, cmux.com in Release).
static var vmAPIBaseURL: URL {
- let environment = ProcessInfo.processInfo.environment
+ let environment = runtimeEnvironment
#if DEBUG
let debugBuild = true
#else
@@ -295,7 +358,7 @@ enum AuthEnvironment {
/// defaults to shared staging (mirroring `irohBrokerBaseURL`); Release
/// keeps the production VM-API origin.
static var pushAPIBaseURL: URL {
- let environment = ProcessInfo.processInfo.environment
+ let environment = runtimeEnvironment
#if DEBUG
let debugBuild = true
#else
@@ -337,7 +400,7 @@ enum AuthEnvironment {
/// identity keeps dialing the dead endpoint forever. Mirrors
/// `pushAPIBaseURL`; Release keeps the production VM-API origin.
static var deviceRegistryAPIBaseURL: URL {
- let environment = ProcessInfo.processInfo.environment
+ let environment = runtimeEnvironment
#if DEBUG
let debugBuild = true
#else
@@ -380,7 +443,7 @@ enum AuthEnvironment {
/// shared staging in Debug so separately launched processes publish into one
/// account-scoped registry. Release keeps the production cmux origin.
static var irohBrokerBaseURL: URL? {
- let environment = ProcessInfo.processInfo.environment
+ let environment = runtimeEnvironment
#if DEBUG
let debugBuild = true
#else
@@ -453,23 +516,16 @@ enum AuthEnvironment {
return canonicalizedLoopbackURL(url)
}
- /// Look up `key=value` in `~/.cmux-dev.env` for the DEBUG build. Returns nil in Release.
- /// Kept tiny on purpose — this is a "drop a file, restart the app, it picks up" override,
- /// not a real config system.
+ /// Look up a Debug runtime override. A tag profile wins over the global
+ /// file, so several tagged apps can run against different backends without
+ /// rebuilding or inheriting a stale parent-process port. Returns nil in
+ /// Release and never reads arbitrary keys as configuration.
private static func devOverride(key: String) -> String? {
#if DEBUG
guard let home = ProcessInfo.processInfo.environment["HOME"] else { return nil }
- let path = (home as NSString).appendingPathComponent(".cmux-dev.env")
- guard let data = try? String(contentsOfFile: path, encoding: .utf8) else { return nil }
- for raw in data.split(separator: "\n") {
- let line = raw.trimmingCharacters(in: .whitespaces)
- guard !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue }
- let k = String(line[.. [String] {
+ var paths: [String] = []
+ if let tag = ProcessInfo.processInfo.environment["CMUX_TAG"],
+ tag.range(of: #"^[A-Za-z0-9._-]+$"#, options: .regularExpression) != nil {
+ paths.append((home as NSString).appendingPathComponent(".config/cmux/dev-profiles/\(tag).env"))
+ }
+ paths.append((home as NSString).appendingPathComponent(".cmux-dev.env"))
+ return paths
+ }
+
+ static func parseDebugOverride(key: String, contents: String) -> String? {
+ for raw in contents.split(separator: "\n") {
+ let line = raw.trimmingCharacters(in: .whitespaces)
+ guard !line.hasPrefix("#"), let eq = line.firstIndex(of: "=") else { continue }
+ let parsedKey = String(line[.. URL {
@@ -576,7 +656,7 @@ enum AuthEnvironment {
}
private static func environmentPort(_ key: String) -> String? {
- environmentPort(key, environment: ProcessInfo.processInfo.environment)
+ environmentPort(key, environment: runtimeEnvironment)
}
private static func environmentPort(_ key: String, environment: [String: String]) -> String? {
@@ -591,7 +671,7 @@ enum AuthEnvironment {
}
private static var defaultWebOrigin: String {
- resolvedDefaultWebOrigin(environment: ProcessInfo.processInfo.environment)
+ resolvedDefaultWebOrigin(environment: runtimeEnvironment)
}
private static func resolvedDefaultWebOrigin(environment: [String: String]) -> String {
@@ -628,7 +708,7 @@ enum AuthEnvironment {
}
private static var defaultAPIBaseURL: String {
- if let url = ProcessInfo.processInfo.environment["CMUX_API_BASE_URL"]?
+ if let url = runtimeEnvironment["CMUX_API_BASE_URL"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!url.isEmpty {
return url
@@ -650,12 +730,12 @@ enum AuthEnvironment {
static var stackProjectID: String {
#if DEBUG
return resolvedStackProjectID(
- environment: ProcessInfo.processInfo.environment,
+ environment: runtimeEnvironment,
isDebugBuild: true
)
#else
return resolvedStackProjectID(
- environment: ProcessInfo.processInfo.environment,
+ environment: runtimeEnvironment,
isDebugBuild: false
)
#endif
@@ -711,12 +791,12 @@ enum AuthEnvironment {
static var stackPublishableClientKey: String {
#if DEBUG
return resolvedStackPublishableClientKey(
- environment: ProcessInfo.processInfo.environment,
+ environment: runtimeEnvironment,
isDebugBuild: true
)
#else
return resolvedStackPublishableClientKey(
- environment: ProcessInfo.processInfo.environment,
+ environment: runtimeEnvironment,
isDebugBuild: false
)
#endif
@@ -750,7 +830,7 @@ enum AuthEnvironment {
/// The website origin used for the after-sign-in handler.
static var afterSignInOrigin: URL {
- resolvedAfterSignInOrigin(environment: ProcessInfo.processInfo.environment)
+ resolvedAfterSignInOrigin(environment: runtimeEnvironment)
}
static func resolvedAfterSignInOrigin(environment: [String: String]) -> URL {
@@ -818,7 +898,7 @@ enum AuthEnvironment {
resolvedURL(
environmentKey: environmentKey,
fallback: fallback,
- environment: ProcessInfo.processInfo.environment
+ environment: runtimeEnvironment
)
}
diff --git a/Sources/Cloud/CloudMachineLinkManager.swift b/Sources/Cloud/CloudMachineLinkManager.swift
index 0a458900a7bd..5fbf49d2e46e 100644
--- a/Sources/Cloud/CloudMachineLinkManager.swift
+++ b/Sources/Cloud/CloudMachineLinkManager.swift
@@ -110,6 +110,16 @@ actor CloudMachineLinkManager {
setPrivateAddresses(address.map { [$0] } ?? [], for: machineID)
}
+ /// A create receipt proved the machine's image serves the trusted
+ /// private-network listener (snapshot-v2), so its first link dials
+ /// `--carrier` like a machine linked before. Without this, New Machine's
+ /// first link paid a control-plane attach request (a Mac-to-backend round
+ /// trip plus a provider status read, ~0.3 s) before its first dial.
+ func markTrustedCarrier(machineID: String) {
+ guard paths.deviceFingerprint(for: machineID) == nil else { return }
+ paths.saveDeviceFingerprint(CloudTuiClientPaths.carrierDeviceMarker, for: machineID)
+ }
+
func setPrivateAddresses(_ addresses: [String], for machineID: String) {
var seen = Set()
let addresses = addresses.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
diff --git a/Sources/Cloud/CloudTreeRowContentView.swift b/Sources/Cloud/CloudTreeRowContentView.swift
index a056f58951e5..c969c29ed2dc 100644
--- a/Sources/Cloud/CloudTreeRowContentView.swift
+++ b/Sources/Cloud/CloudTreeRowContentView.swift
@@ -172,6 +172,7 @@ struct CloudTreeLeafRow: View {
let style: CloudTreeStyle
let icon: String
let tint: Color
+ var iconAsset: String? = nil
let title: String
var titleWeight: Font.Weight = .regular
var titleDimmed: Bool = false
@@ -187,6 +188,7 @@ struct CloudTreeLeafRow: View {
style: CloudTreeStyle,
icon: String,
tint: Color,
+ iconAsset: String? = nil,
title: String,
titleWeight: Font.Weight = .regular,
titleDimmed: Bool = false,
@@ -197,6 +199,7 @@ struct CloudTreeLeafRow: View {
self.style = style
self.icon = icon
self.tint = tint
+ self.iconAsset = iconAsset
self.title = title
self.titleWeight = titleWeight
self.titleDimmed = titleDimmed
@@ -208,7 +211,13 @@ struct CloudTreeLeafRow: View {
var body: some View {
HStack(alignment: .center, spacing: GlobalFontMagnification.scaledSize(style.iconGap, percent: magnification)) {
if style.iconSlot > 0 {
- CloudTreeRowIcon(style: style, systemName: icon, tint: tint, dimmed: titleDimmed)
+ CloudTreeRowIcon(
+ style: style,
+ systemName: icon,
+ tint: tint,
+ assetName: iconAsset,
+ dimmed: titleDimmed
+ )
}
switch style.leafLayout {
case .twoLine:
@@ -274,6 +283,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView {
style: CloudTreeStyle,
icon: String,
tint: Color,
+ iconAsset: String? = nil,
title: String,
titleWeight: Font.Weight = .regular,
titleDimmed: Bool = false,
@@ -284,6 +294,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView {
style: style,
icon: icon,
tint: tint,
+ iconAsset: iconAsset,
title: title,
titleWeight: titleWeight,
titleDimmed: titleDimmed,
@@ -294,7 +305,7 @@ extension CloudTreeLeafRow where Accessories == EmptyView {
}
}
-/// A cmux-tui terminal row: lifecycle glyph and title, with secondary details on hover.
+/// A cmux-tui terminal row with its provider mark, title, directory and optional view count.
struct CloudTreeTerminalRowContent: View {
let row: CloudTreeTerminalRow
var style: CloudTreeStyle = CloudTreeStyleStore.current
@@ -319,6 +330,7 @@ struct CloudTreeTerminalRowContent: View {
style: style,
icon: glyph,
tint: CloudTreeIconPalette.terminal,
+ iconAsset: terminal.terminalAgentIconAssetName,
title: row.displayTitle.isEmpty ? String(localized: "cloudTree.terminal.untitled", defaultValue: "terminal") : row.displayTitle,
titleDimmed: terminal.lifecycle == .exited || showsDetachedState
)
diff --git a/Sources/Cloud/CloudTreeRowIcon.swift b/Sources/Cloud/CloudTreeRowIcon.swift
index e43440ca8b13..99df661b9ee5 100644
--- a/Sources/Cloud/CloudTreeRowIcon.swift
+++ b/Sources/Cloud/CloudTreeRowIcon.swift
@@ -1,4 +1,6 @@
import CmuxFoundation
+import AppKit
+import CmuxAppKitSupportUI
import SwiftUI
/// A row glyph in the shared icon slot, drawn per the style's icon treatment:
@@ -9,12 +11,30 @@ struct CloudTreeRowIcon: View {
let style: CloudTreeStyle
let systemName: String
let tint: Color
+ var assetName: String? = nil
var dimmed: Bool = false
var weight: Font.Weight = .regular
var size: CGFloat? = nil
@Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification
var body: some View {
+ if let assetName {
+ CmuxResolvedIconImage(request: CmuxResolvedIconRequest(
+ source: .asset(name: assetName, bundle: .main),
+ size: NSSize(width: style.iconSize, height: style.iconSize),
+ fallbackSource: .systemSymbol(name: systemName, accessibilityDescription: nil),
+ fallbackTintColor: .secondaryLabelColor
+ ))
+ .frame(width: style.iconSlot, height: style.iconSize, alignment: .center)
+ .opacity(dimmed ? 0.45 : 1)
+ .accessibilityHidden(true)
+ } else {
+ systemIcon
+ }
+ }
+
+ @ViewBuilder
+ private var systemIcon: some View {
switch style.iconTreatment {
case .monochrome:
// `Color.tertiary` needs macOS 15; the label colors match the
diff --git a/Sources/Cloud/PortForward/CloudHubConnector.swift b/Sources/Cloud/PortForward/CloudHubConnector.swift
index 9e78a73dee62..465f029d39f1 100644
--- a/Sources/Cloud/PortForward/CloudHubConnector.swift
+++ b/Sources/Cloud/PortForward/CloudHubConnector.swift
@@ -5,10 +5,24 @@ import Network
/// A family can blackhole independently of the other after a VM joins its VPC.
/// Race actual SOCKS CONNECT handshakes, retaining the winning stream and closing
/// every loser before returning, so terminal and browser callers share the policy.
+///
+/// Each address is also redialed every `redialInterval` until one handshake
+/// succeeds or `timeout` passes. A machine created a moment ago is not
+/// reachable until its VPC fabric has seen a frame from it; the SYNs of an
+/// attempt started before that are lost, and the hub's TCP retransmit backoff
+/// (1 s, then 2 s, ...) left New Machine waiting ~3.7 s, or failing at the 15 s
+/// deadline, for a daemon that was reachable ~0.4 s after the create response.
+/// A fresh attempt costs one local SOCKS connect, so hedging is cheap.
struct CloudHubConnector: Sendable {
var timeout: Duration = .seconds(15)
/// A cancellable head start for the preferred family, driven by the injected clock.
var fallbackDelay: Duration = .milliseconds(250)
+ /// How often a still-unanswered address gets another, independent attempt.
+ var redialInterval: Duration = .milliseconds(50)
+ /// Redial rounds after the first. The fresh-machine window is well under a
+ /// second; after 3 s the in-flight attempts ride normal retransmits, so a
+ /// blackholed family never holds more than this many sockets per address.
+ var maxRedials: Int = 60
var clock: any Clock = ContinuousClock()
#if compiler(>=6.2)
@@ -21,46 +35,107 @@ struct CloudHubConnector: Sendable {
target: CloudPortForwardTarget,
queue: DispatchQueue
) async throws -> CloudHubConnection {
- let candidates = target.hosts.map { host in
- CloudHubConnection(connection: NWConnection(to: endpoint, using: .tcp), host: host)
- }
- return try await withTaskCancellationHandler {
- try await withThrowingTaskGroup(of: Result.self) { group in
- for (index, candidate) in candidates.enumerated() {
+ let hosts = target.hosts
+ return try await Self.hedged(
+ candidates: hosts.count,
+ fallbackDelay: fallbackDelay,
+ redialInterval: redialInterval,
+ maxRedials: maxRedials,
+ timeout: timeout,
+ clock: clock,
+ attempt: { index in
+ let candidate = CloudHubConnection(connection: NWConnection(to: endpoint, using: .tcp), host: hosts[index])
+ do {
+ try await handshake(candidate.connection, host: candidate.host, port: target.port, queue: queue)
+ return candidate
+ } catch {
+ candidate.connection.cancel()
+ throw error
+ }
+ },
+ discard: { $0.connection.cancel() }
+ )
+ }
+
+ /// Runs `attempt(candidate)` for every candidate (each later one delayed by
+ /// `fallbackDelay`) and starts a new attempt for every candidate each
+ /// `redialInterval`, until the first success. Every other in-flight or later
+ /// success is passed to `discard`. Throws the last failure (or a timeout)
+ /// when nothing succeeds within `timeout`.
+ static func hedged(
+ candidates: Int,
+ fallbackDelay: Duration,
+ redialInterval: Duration,
+ maxRedials: Int,
+ timeout: Duration,
+ clock: any Clock,
+ attempt: @escaping @Sendable (Int) async throws -> Value,
+ discard: @escaping @Sendable (Value) -> Void
+ ) async throws -> Value {
+ guard candidates > 0 else { throw CancellationError() }
+ return try await withThrowingTaskGroup(of: CloudHubHedgeEvent.self) { group in
+ func launch(round: Int) {
+ for index in 0.. 0 && round == 0 ? fallbackDelay : .zero
group.addTask {
do {
- if index > 0 { try await clock.sleep(for: fallbackDelay) }
- try Task.checkCancellation()
- try await handshake(candidate.connection, host: candidate.host, port: target.port, queue: queue)
+ if delay > .zero { try await clock.sleep(for: delay) }
try Task.checkCancellation()
- return .success(candidate)
+ return .success(try await attempt(index))
} catch {
- candidate.connection.cancel()
return .failure(error)
}
}
}
- defer { group.cancelAll() }
- var lastError: any Error = CancellationError()
- while let result = try await group.next() {
- switch result {
- case .success(let connected):
- for other in candidates where other.connection !== connected.connection {
- other.connection.cancel()
- }
- if Task.isCancelled {
- connected.connection.cancel()
- throw CancellationError()
- }
- return connected
- case .failure(let error):
- lastError = error
+ }
+ launch(round: 0)
+ group.addTask {
+ try? await clock.sleep(for: redialInterval)
+ return .tick
+ }
+ group.addTask {
+ try? await clock.sleep(for: timeout)
+ return .deadline
+ }
+ var round = 1
+ var expired = false
+ var lastError: any Error = CloudPortForwardRelay.RelayError.handshakeTimedOut(timeout)
+ var winner: Value?
+ while let event = try await group.next() {
+ switch event {
+ case .success(let value):
+ if winner == nil {
+ winner = value
+ group.cancelAll()
+ } else {
+ discard(value)
}
+ case .failure(let error):
+ if !(error is CancellationError) { lastError = error }
+ case .tick:
+ guard winner == nil, !expired, round <= maxRedials else { continue }
+ launch(round: round)
+ round += 1
+ group.addTask {
+ try? await clock.sleep(for: redialInterval)
+ return .tick
+ }
+ case .deadline:
+ expired = true
+ if winner == nil { group.cancelAll() }
}
- throw lastError
}
- } onCancel: {
- for candidate in candidates { candidate.connection.cancel() }
+ // The group drains every child before returning, so late winners
+ // were discarded above and no attempt outlives this call.
+ if let winner {
+ if Task.isCancelled {
+ discard(winner)
+ throw CancellationError()
+ }
+ return winner
+ }
+ try Task.checkCancellation()
+ throw lastError
}
}
@@ -91,3 +166,10 @@ struct CloudHubConnector: Sendable {
}
}
}
+
+enum CloudHubHedgeEvent: Sendable {
+ case success(Value)
+ case failure(any Error)
+ case tick
+ case deadline
+}
diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift
index 02bd9fa27c26..fb8dddb85655 100644
--- a/Sources/Cloud/VMClient.swift
+++ b/Sources/Cloud/VMClient.swift
@@ -286,6 +286,10 @@ struct VMSummary {
/// the WireGuard tunnel); nil for machines created before private networking.
var addressIPv4: String?
var addressIPv6: String?
+ /// The image's cmux-tui attach contract from the create receipt
+ /// (`"snapshot-v2"`: baked daemon, trusted private-network listener).
+ /// Only the create response carries it; list reads leave it nil.
+ var cmuxTuiContract: String?
/// The name to show people: the label when set, else the generated slug,
/// else the machine id.
@@ -1234,6 +1238,10 @@ actor VMClient {
extraHeaders: headers,
timeoutSeconds: Self.createTimeoutSeconds
)
+ #if DEBUG
+ // Per-stage server time for the New Machine critical path.
+ cmuxDebugLog("cloud.vm.create.serverTiming status=\(http.statusCode) \(http.value(forHTTPHeaderField: "Server-Timing") ?? "none")")
+ #endif
try ensureOK(http, data: data)
let obj = try decodeJSONObject(data)
guard let id = obj["id"] as? String,
@@ -1254,6 +1262,14 @@ actor VMClient {
summary.capabilities = VMCapabilities(vmResponse: obj)
summary.displayName = (obj["displayName"] as? String).flatMap { $0.isEmpty ? nil : $0 }
summary.slug = (obj["slug"] as? String).flatMap { $0.isEmpty ? nil : $0 }
+ // The create receipt names the new machine's private address and
+ // attach contract, so the app can register and dial it without a
+ // fleet re-read or an attach request (see createdMachineAttach).
+ if let address = obj["address"] as? [String: Any] {
+ summary.addressIPv4 = (address["ipv4"] as? String).flatMap { $0.isEmpty ? nil : $0 }
+ summary.addressIPv6 = (address["ipv6"] as? String).flatMap { $0.isEmpty ? nil : $0 }
+ }
+ summary.cmuxTuiContract = (obj["cmuxTuiContract"] as? String).flatMap { $0.isEmpty ? nil : $0 }
machineCache.record(hasAnyMachine: true)
return summary
}
diff --git a/Sources/Cloud/VMClientSocketCommands.swift b/Sources/Cloud/VMClientSocketCommands.swift
index bf4f3ca254dd..8513ab1eb716 100644
--- a/Sources/Cloud/VMClientSocketCommands.swift
+++ b/Sources/Cloud/VMClientSocketCommands.swift
@@ -577,7 +577,23 @@ extension TerminalController {
throw CloudMachineLinkManager.ManagerError.wireGuardHubUnsupported
}
var payload: [String: Any]
- if let deviceFingerprint {
+ var isCreatedReceipt = false
+ if deviceFingerprint == nil,
+ let createdRoute = await registry.takeCreatedTrustedCarrierRoute(machineID: vmId) {
+ isCreatedReceipt = true
+ // New Machine: the create receipt already proved the
+ // snapshot-v2 trusted listener and named the private
+ // address. Skip POST /attach-endpoint (~2 s measured);
+ // it would return this same route from the same row.
+ payload = [
+ "transport": "cmux-remote",
+ "route": createdRoute,
+ "token": "",
+ "expires_at_unix": 0,
+ "session": "cmux",
+ "trusted_carrier": true,
+ ]
+ } else if let deviceFingerprint {
guard let knownRoute = await registry.privateRoute(machineID: vmId) else {
throw CloudMachineLinkManager.ManagerError.privateRouteRequired(vmId)
}
@@ -627,9 +643,14 @@ extension TerminalController {
guard let hub else { throw CloudMachineLinkManager.ManagerError.wireGuardHubMissing }
let ready = try await hub.pinForExternalClient()
payload["wireguard_hub_socket"] = ready.socketPath
- let addresses = payload["network_addresses"] as? [String: Any] ?? [:]
- let resolvedRoute = try await registry.resolvedPrivateRoute(machineID: vmId, through: ready, fallbackRoute: route, addresses: ["ipv4", "ipv6"].compactMap { addresses[$0] as? String })
- payload["route"] = resolvedRoute
+ // A just-created machine keeps the route its receipt declared
+ // (IPv4 first, like the server). Racing families here would
+ // dial a machine that is still coming up and double the wait
+ // the app's own link (already started) is paying.
+ if !isCreatedReceipt {
+ let addresses = payload["network_addresses"] as? [String: Any] ?? [:]
+ payload["route"] = try await registry.resolvedPrivateRoute(machineID: vmId, through: ready, fallbackRoute: route, addresses: ["ipv4", "ipv6"].compactMap { addresses[$0] as? String })
+ }
return payload
}
case "vm.sessions":
diff --git a/Sources/CmuxSettingsFileStore+SupportedPaths.swift b/Sources/CmuxSettingsFileStore+SupportedPaths.swift
index dac7a82b4913..589a61d57b58 100644
--- a/Sources/CmuxSettingsFileStore+SupportedPaths.swift
+++ b/Sources/CmuxSettingsFileStore+SupportedPaths.swift
@@ -28,6 +28,7 @@ extension CmuxSettingsFileStore {
"app.reorderOnNotification",
"app.sendAnonymousTelemetry",
"app.confirmQuit",
+ "app.globalFontMagnification",
"app.warnBeforeQuit",
"app.warnBeforeClosingTab",
"app.warnBeforeClosingTabXButton",
@@ -39,6 +40,7 @@ extension CmuxSettingsFileStore {
"terminal.showScrollBar",
"terminal.scrollSpeed",
"terminal.copyOnSelect",
+ "terminal.textEditingGestures",
"terminal.autoResumeAgentSessions",
"terminal.showTextBoxOnNewTerminals",
"terminal.focusTextBoxOnNewTerminals",
@@ -153,5 +155,6 @@ extension CmuxSettingsFileStore {
"fileEditor.tabWidth",
"fileExplorer.doubleClickAction",
"shortcuts.bindings",
+ "shortcuts.showModifierHoldHints",
]
}
diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift
index dc28913d7521..789ea75401b2 100644
--- a/Sources/CmuxSettingsJSONPathSupport.swift
+++ b/Sources/CmuxSettingsJSONPathSupport.swift
@@ -236,6 +236,11 @@ enum TerminalSettingsFileMapping {
defaultsKey: AgentSessionAutoResumeSettings.autoResumeAgentSessionsKey,
invalidPath: "terminal.autoResumeAgentSessions"
),
+ .init(
+ jsonKey: "textEditingGestures",
+ defaultsKey: terminal.textEditingGestures.userDefaultsKey,
+ invalidPath: terminal.textEditingGestures.id
+ ),
]
}
diff --git a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift
index 771eb9605878..9883b3fc9cc9 100644
--- a/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift
+++ b/Sources/CmuxTaskManagerCodingAgentDefinition+BuiltIns.swift
@@ -10,7 +10,7 @@ extension CmuxTaskManagerCodingAgentDefinition {
.init(id: "codex", displayName: "Codex", assetName: "AgentIcons/Codex",
launchKinds: ["codex", "omx"], directBasenames: ["codex", "omx"],
argumentNeedles: ["codex", "@openai/codex", "oh-my-codex"]),
- .init(id: "grok", displayName: "Grok", assetName: nil,
+ .init(id: "grok", displayName: "Grok", assetName: "AgentIcons/Grok",
launchKinds: ["grok"], directBasenames: ["grok", "grok-macos-aarch64", "grok-macos-aarch"],
argumentNeedles: ["grok", "grok-build", "@xai/grok"]),
.init(id: "opencode", displayName: "OpenCode", assetName: "AgentIcons/OpenCode",
@@ -26,11 +26,11 @@ extension CmuxTaskManagerCodingAgentDefinition {
argumentNeedles: ["@mariozechner/pi-coding-agent", "pi-coding-agent"]),
.init(id: "amp", displayName: "Amp", assetName: "AgentIcons/Amp",
launchKinds: ["amp"], directBasenames: ["amp"], argumentNeedles: ["@ampcode"]),
- .init(id: "cursor", displayName: "Cursor", assetName: nil,
+ .init(id: "cursor", displayName: "Cursor", assetName: "AgentIcons/Cursor",
launchKinds: ["cursor"], directBasenames: ["cursor-agent"], argumentNeedles: ["cursor-agent"]),
- .init(id: "gemini", displayName: "Gemini", assetName: nil,
+ .init(id: "gemini", displayName: "Gemini", assetName: "AgentIcons/Gemini",
launchKinds: ["gemini"], directBasenames: ["gemini"], argumentNeedles: ["gemini"]),
- .init(id: "kiro", displayName: "Kiro", assetName: nil,
+ .init(id: "kiro", displayName: "Kiro", assetName: "AgentIcons/Kiro",
launchKinds: ["kiro"], directBasenames: ["kiro", "kiro-cli"], argumentNeedles: ["kiro", "kiro-cli"]),
.init(id: "antigravity", displayName: "Antigravity", assetName: "AgentIcons/Antigravity",
launchKinds: ["antigravity", "agy"], directBasenames: ["agy", "antigravity"],
@@ -39,18 +39,18 @@ extension CmuxTaskManagerCodingAgentDefinition {
launchKinds: ["rovodev", "rovo"], directBasenames: ["rovodev"], argumentNeedles: ["rovodev"]),
.init(id: "hermes-agent", displayName: "Hermes Agent", assetName: "AgentIcons/HermesAgent",
launchKinds: ["hermes-agent"], directBasenames: ["hermes", "hermes-agent"], argumentNeedles: ["hermes-agent"]),
- .init(id: "copilot", displayName: "Copilot", assetName: nil,
- launchKinds: ["copilot"], directBasenames: ["copilot"], argumentNeedles: ["copilot"]),
- .init(id: "codebuddy", displayName: "CodeBuddy", assetName: nil,
+ .init(id: "copilot", displayName: "Copilot", assetName: "AgentIcons/Copilot",
+ launchKinds: ["copilot"], directBasenames: ["copilot", "github-copilot"], argumentNeedles: ["copilot"]),
+ .init(id: "codebuddy", displayName: "CodeBuddy", assetName: "AgentIcons/CodeBuddy",
launchKinds: ["codebuddy"], directBasenames: ["codebuddy"], argumentNeedles: ["codebuddy"]),
- .init(id: "factory", displayName: "Factory", assetName: nil,
+ .init(id: "factory", displayName: "Factory", assetName: "AgentIcons/Factory",
launchKinds: ["factory"], directBasenames: ["droid", "factory"], argumentNeedles: ["factory"]),
- .init(id: "qoder", displayName: "Qoder", assetName: nil,
+ .init(id: "qoder", displayName: "Qoder", assetName: "AgentIcons/Qoder",
launchKinds: ["qoder"], directBasenames: ["qoder", "qodercli"], argumentNeedles: ["qoder", "qodercli"]),
.init(
id: "kimi",
displayName: String(localized: "agent.kimi.displayName", defaultValue: "Kimi Code"),
- assetName: nil,
+ assetName: "AgentIcons/Kimi",
launchKinds: ["kimi"],
// Kimi's Python entrypoint deliberately overwrites its OS process title and argv with
// "Kimi Code". This is process-status/foreground detection only; session persistence
@@ -61,7 +61,7 @@ extension CmuxTaskManagerCodingAgentDefinition {
.init(
id: "ollama",
displayName: String(localized: "agent.ollama.displayName", defaultValue: "Ollama"),
- assetName: nil,
+ assetName: "AgentIcons/Ollama",
launchKinds: ["ollama"],
directBasenames: ["ollama"],
// No argument needles: a bare "ollama" token plus the "run"
diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift
index 5950929cbe70..fb8e02df974b 100644
--- a/Sources/GhosttyTerminalView.swift
+++ b/Sources/GhosttyTerminalView.swift
@@ -3953,6 +3953,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
fileprivate private(set) var keyboardCopyModeActive = false
private var wordPathHoverActive = false
private var keyboardCopyModeConsumedKeyUps: Set = []
+ private var textEditingGestureConsumedKeyUps: Set = []
private var imeConsumedKeyUps: Set = []
private var manualNamedKeyConsumedKeyUps: Set = []
/// Deferred native input actions retain their authored order until the
@@ -5809,6 +5810,93 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
syncKeyboardCopyModeCursorOverlay(surface: surface)
}
+ /// Whether opt-in terminal text-editing gestures are active.
+ ///
+ /// Reads the same defaults key as `terminal.textEditingGestures` in the
+ /// settings catalog, whose default is `false`, so an unset key leaves the
+ /// mode off.
+ private var textEditingGesturesEnabled: Bool {
+ UserDefaults.standard.bool(forKey: "terminal.textEditingGestures")
+ }
+
+ /// Maps AppKit modifier flags onto the resolver's platform-neutral set.
+ private func textEditingModifiers(
+ from flags: NSEvent.ModifierFlags
+ ) -> TerminalTextEditingModifiers {
+ var modifiers: TerminalTextEditingModifiers = []
+ if flags.contains(.command) { modifiers.insert(.command) }
+ if flags.contains(.shift) { modifiers.insert(.shift) }
+ if flags.contains(.control) { modifiers.insert(.control) }
+ if flags.contains(.option) { modifiers.insert(.option) }
+ if flags.contains(.numericPad) { modifiers.insert(.numericPad) }
+ if flags.contains(.function) { modifiers.insert(.function) }
+ if flags.contains(.capsLock) { modifiers.insert(.capsLock) }
+ return modifiers
+ }
+
+ /// Carbon virtual key codes for the letters a resolved chord can name.
+ private static let textEditingChordKeyCodes: [Character: UInt16] = [
+ "a": 0x00, "b": 0x0B, "d": 0x02, "e": 0x0E,
+ "f": 0x03, "k": 0x28, "u": 0x20, "w": 0x0D,
+ ]
+
+ /// Replays a macOS text-editing gesture as the line-editor chord it means.
+ ///
+ /// The chord is sent as a synthesized key press rather than as raw bytes so
+ /// Ghostty performs the encoding, keeping the result correct under whichever
+ /// keyboard protocol the running application negotiated.
+ ///
+ /// - Parameters:
+ /// - event: The key-down event to consider.
+ /// - surface: The surface that receives the replayed chord.
+ /// - Returns: `true` when the gesture was consumed and must not reach the
+ /// terminal as the original keystroke.
+ private func handleTextEditingGestureIfNeeded(
+ _ event: NSEvent,
+ surface: ghostty_surface_t
+ ) -> Bool {
+ // Keyboard copy mode owns the keyboard while it is active. It lets
+ // Command-modified events through on purpose so menu shortcuts still
+ // fire, and every gesture that survives its filter is Command-modified,
+ // so without this guard reading scrollback with a half-typed command at
+ // the prompt would replay Ctrl+U/Ctrl+K and destroy that line.
+ guard !keyboardCopyModeActive, !hasMarkedText() else { return false }
+ guard let chord = terminalTextEditingResolve(
+ keyCode: event.keyCode,
+ modifiers: textEditingModifiers(from: event.modifierFlags)
+ ) else { return false }
+ // The defaults read is the costly half, so it runs only after the pure
+ // resolver has confirmed this keystroke is gesture-shaped at all. Every
+ // other keystroke leaves this path having done no I/O.
+ guard textEditingGesturesEnabled else { return false }
+ guard
+ let chordKeyCode = Self.textEditingChordKeyCodes[chord.letter],
+ let scalar = chord.letter.unicodeScalars.first
+ else { return false }
+
+ var keyEvent = ghostty_input_key_s()
+ keyEvent.action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
+ keyEvent.keycode = UInt32(chordKeyCode)
+ keyEvent.mods = chord.modifier == .control ? GHOSTTY_MODS_CTRL : GHOSTTY_MODS_ALT
+ keyEvent.consumed_mods = GHOSTTY_MODS_NONE
+ keyEvent.composing = false
+ keyEvent.unshifted_codepoint = scalar.value
+ keyEvent.text = nil
+ if sendGhosttyKey(surface, keyEvent) { return true }
+ // Only an Option chord can legitimately encode nothing. libghostty
+ // prefixes ESC for Alt only when `macos-option-as-alt` resolves true,
+ // and `detectOptionAsAlt` returns true solely for the US and
+ // US-International layouts, so a synthesized Alt+b writes nothing at
+ // all on AZERTY, German, Dvorak and friends -- and on any layout when
+ // the setting is `false` or a `right` that the synthesized left bit
+ // cannot match. Under the kitty protocol the key event already
+ // succeeded, so this runs only for the legacy encoding that `esc:`
+ // matches. A false return means nothing reached the pty, so re-sending
+ // here cannot double-write.
+ guard chord.modifier == .option else { return false }
+ return performBindingAction("esc:\(chord.letter)")
+ }
+
private func handleKeyboardCopyModeIfNeeded(_ event: NSEvent, surface: ghostty_surface_t) -> Bool {
guard keyboardCopyModeActive else { return false }
reconcileKeyboardCopyModeViewport(surface: surface)
@@ -6081,6 +6169,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if result {
imeConsumedKeyUps.removeAll()
manualNamedKeyConsumedKeyUps.removeAll()
+ textEditingGestureConsumedKeyUps.removeAll()
if let terminalSurface,
AppDelegate.shared?.allowsTerminalKeyboardFocus(
workspaceId: terminalSurface.tabId,
@@ -6136,6 +6225,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
)
}
}
+ // Mirror the intent before requiring a live runtime, the way
+ // resignFirstResponder already does. createSurface re-applies
+ // desiredFocusState once the runtime exists, so a focus taken while the
+ // surface is still spawning survives; gating the mirror on the runtime
+ // left nothing for that reconciliation to converge to.
+ if result, shouldApplySurfaceFocus {
+ terminalSurface?.recordExternalFocusState(true)
+ terminalSurface?.hostedView.cancelSuppressedFirstResponderFocusReapply()
+ }
if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput(reassertInputFocus: false) {
let now = CACurrentMediaTime()
let deltaMs = (now - lastScrollEventTime) * 1000
@@ -6163,8 +6261,6 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
userInfo: userInfo
)
}
- terminalSurface?.recordExternalFocusState(true)
- terminalSurface?.hostedView.cancelSuppressedFirstResponderFocusReapply()
ghostty_surface_set_focus(surface, true)
// Ghostty only restarts its vsync display link on display-id changes while focused.
@@ -6185,6 +6281,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if result {
imeConsumedKeyUps.removeAll()
manualNamedKeyConsumedKeyUps.removeAll()
+ textEditingGestureConsumedKeyUps.removeAll()
desiredFocus = false
deferReleaseAllGhosttyMouseButtons(
reason: "resignFirstResponder"
@@ -6541,6 +6638,20 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
keyboardCopyModeConsumedKeyUps.insert(event.keyCode)
return
}
+ if handleTextEditingGestureIfNeeded(event, surface: surface) {
+ // sendGhosttyKey already reported the accepted input; only the
+ // originating gesture's key-up still needs suppressing, because the
+ // synthesized press has no matching release.
+ //
+ // AppKit never delivers a Command-modified key-up to the responder
+ // chain, so recording one would strand the code in this set and
+ // swallow the next *unmodified* release of the same physical key --
+ // leaving a stuck arrow in any app that reads releases.
+ if !event.modifierFlags.contains(.command) {
+ textEditingGestureConsumedKeyUps.insert(event.keyCode)
+ }
+ return
+ }
#if DEBUG
keyboardCopyModeMs = (ProcessInfo.processInfo.systemUptime - keyboardCopyModeStart) * 1000.0
#endif
@@ -7009,6 +7120,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
if keyboardCopyModeConsumedKeyUps.remove(event.keyCode) != nil {
return
}
+ if textEditingGestureConsumedKeyUps.remove(event.keyCode) != nil {
+ return
+ }
if imeConsumedKeyUps.remove(event.keyCode) != nil {
return
}
diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift
index 565028c85cce..553dd2ecbb67 100644
--- a/Sources/Panels/TerminalPanel.swift
+++ b/Sources/Panels/TerminalPanel.swift
@@ -716,8 +716,12 @@ final class TerminalPanel: Panel, ObservableObject {
@discardableResult
func sendText(_ text: String) -> Bool {
+ sendTextResult(text).accepted
+ }
+
+ func sendTextResult(_ text: String) -> TerminalSurface.TextSendResult {
resumeForExplicitInputIfNeeded()
- return surface.sendText(text)
+ return surface.sendTextResult(text)
}
func sendInput(_ text: String) {
diff --git a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift
index 3a7961c6dd7f..59889c30fc63 100644
--- a/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift
+++ b/Sources/SessionRemoteWorkspaceSnapshot+Restore.swift
@@ -536,7 +536,7 @@ extension SessionRemoteWorkspaceSnapshot {
"cmux_freestyle_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"",
"if [ -z \"$cmux_freestyle_cli\" ] || [ ! -x \"$cmux_freestyle_cli\" ]; then cmux_freestyle_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi",
"if [ -z \"$cmux_freestyle_cli\" ]; then printf '%s\\n' '[cmux] bundled CLI not found for Cloud VM SSH attach.' >&2; exit 127; fi",
- "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-86400}\"",
+ "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}\"",
"CMUX_SSH_RECONNECT_DELAY_SECONDS=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"",
"CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT:-$CMUX_SSH_RECONNECT_LIMIT}\"",
"CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS:-$CMUX_SSH_RECONNECT_DELAY_SECONDS}\"",
diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift
index cb858146fd15..e681ec6c9d5a 100644
--- a/Sources/SettingsSearchAliases.swift
+++ b/Sources/SettingsSearchAliases.swift
@@ -114,6 +114,7 @@ enum SettingsSearchAliasIndex {
"terminal:session-content-width": localized("settings.search.alias.setting.terminal.session-content-width", defaultValue: "terminal.sessionContentMaxWidth terminal agent chat max width readable line length points pt narrow wide"),
"terminal:session-content-alignment": localized("settings.search.alias.setting.terminal.session-content-alignment", defaultValue: "terminal.sessionContentAlignment terminal agent chat left center right alignment position"),
"terminal:copy-on-select": localized("settings.search.alias.setting.terminal.copy-on-select", defaultValue: "terminal.copyOnSelect copy on selection select clipboard mouse double click triple click iterm"),
+ "terminal:text-editing-gestures": localized("settings.search.alias.setting.terminal.text-editing-gestures", defaultValue: "terminal.textEditingGestures text editing gestures option alt word line kill readline emacs keybindings command arrow delete"),
"terminal:tab-bar-font-size": localized("settings.search.alias.setting.terminal.tab-bar-font-size", defaultValue: "surface-tab-bar-font-size tab bar font size text scale terminal browser pane tab title"),
"terminal:resume-commands": localized("settings.search.alias.setting.terminal.resume-commands", defaultValue: "surface resume commands approvals command prefixes auto restore ask manual tmux hibernation sticky process"),
"textBox:show-textbox-new-terminals": localized("settings.search.alias.setting.textBox.show-textbox-new-terminals", defaultValue: "terminal.showTextBoxOnNewTerminals show textbox text box rich input prompt default new terminal workspace split tab beta"),
diff --git a/Sources/SettingsSearchIndex.swift b/Sources/SettingsSearchIndex.swift
index 6b055121f00f..429d9f4b429b 100644
--- a/Sources/SettingsSearchIndex.swift
+++ b/Sources/SettingsSearchIndex.swift
@@ -94,6 +94,7 @@ enum SettingsSearchIndex {
setting(.terminal, "session-content-width", String(localized: "settings.terminal.sessionContentWidth", defaultValue: "Session Content Width"), "terminal.sessionContentMaxWidth terminal agent chat max width readable line length narrow wide"),
setting(.terminal, "session-content-alignment", String(localized: "settings.terminal.sessionContentAlignment", defaultValue: "Session Content Alignment"), "terminal.sessionContentAlignment left center right align terminal agent chat"),
setting(.terminal, "copy-on-select", String(localized: "settings.terminal.copyOnSelect", defaultValue: "Copy on Selection"), "terminal.copyOnSelect clipboard selection mouse double click triple click"),
+ setting(.terminal, "text-editing-gestures", String(localized: "settings.terminal.textEditingGestures", defaultValue: "Text Editing Gestures"), "terminal.textEditingGestures text editing gestures option alt word line kill readline emacs keybindings command arrow delete"),
setting(.terminal, "tab-bar-font-size", String(localized: "settings.terminal.tabBarFontSize", defaultValue: "Tab Bar Font Size"), "font size text scale terminal browser pane tab title surface-tab-bar-font-size"),
setting(.terminal, "agent-auto-resume", String(localized: "settings.terminal.agentAutoResume", defaultValue: "Resume Agent Sessions on Reopen"), "terminal.autoResumeAgentSessions auto resume restore reopen relaunch quit sessions agents claude code codex opencode rovo dev rovodev toggle"),
setting(.terminal, "agent-hibernation", String(localized: "settings.terminal.agentHibernation", defaultValue: "Agent Hibernation"), "terminal.agentHibernation idle hibernate suspend background agents claude code codex opencode live terminals"),
@@ -284,6 +285,7 @@ enum SettingsSearchIndex {
"terminal.textBoxDefaultSubmitAction": settingID(for: .textBox, idSuffix: "default-submit-action"),
"terminal.textBoxMaxLines": settingID(for: .textBox, idSuffix: "textbox-max-lines"),
"terminal.copyOnSelect": settingID(for: .terminal, idSuffix: "copy-on-select"),
+ "terminal.textEditingGestures": settingID(for: .terminal, idSuffix: "text-editing-gestures"),
"terminal.sessionContentMaxWidth": settingID(for: .terminal, idSuffix: "session-content-width"),
"terminal.sessionContentAlignment": settingID(for: .terminal, idSuffix: "session-content-alignment"),
"terminal.autoResumeAgentSessions": settingID(for: .terminal, idSuffix: "agent-auto-resume"),
diff --git a/Sources/SharedLiveAgentIndex.swift b/Sources/SharedLiveAgentIndex.swift
index 64b77305156f..11ddee0acc54 100644
--- a/Sources/SharedLiveAgentIndex.swift
+++ b/Sources/SharedLiveAgentIndex.swift
@@ -663,6 +663,26 @@ final class SharedLiveAgentIndex {
_ = await applyPendingForkValidations(
pendingRequestIDsToRemoveOnCancellation: pendingRequestIDsOwnedByRequest
)
+ // The pass above may find this caller's request already claimed by
+ // another drainer: the unguarded tail restart in
+ // `applyPendingForkValidations` can spawn a detached refresh that
+ // wins the race against a contention waiter it just resumed, and
+ // that waiter then returns to an empty queue. Returning here would
+ // break this method's contract -- the queued validation must be
+ // applied before it returns -- so callers could read stale fork
+ // availability.
+ //
+ // This is a symptom fix, not the root cause. The root cause is that
+ // the tail restart in `applyPendingForkValidations` lacks the
+ // `!resumedWaiters` guard its in-loop sibling has, so it can resume
+ // a waiter and then immediately race it. Guarding it there is the
+ // real repair, but the obvious form can strand a pending request
+ // when the resumed waiter's task is cancelled right after resuming,
+ // so it needs its own change. The live-index branch below has the
+ // same hole when `didReload` is true -- `reload()` runs
+ // `applyPendingForkValidations` internally, so the same steal can
+ // happen and that path returns without waiting.
+ await waitForForkValidationRequestCompletions(pendingRequestIDsOwnedByRequest)
return
}
let reloadResult = await reloadIfLiveAgentProcessFingerprintChanged(
diff --git a/Sources/Surfaces/CmuxTuiSnapshotParser.swift b/Sources/Surfaces/CmuxTuiSnapshotParser.swift
index 78c0b058d156..f43347cc8d6a 100644
--- a/Sources/Surfaces/CmuxTuiSnapshotParser.swift
+++ b/Sources/Surfaces/CmuxTuiSnapshotParser.swift
@@ -191,7 +191,16 @@ struct CmuxTuiSnapshotParser: Sendable {
}
let agents = ((snapshot["agents"] as? [[String: Any]]) ?? []).compactMap { raw -> CloudVMAgentState? in
guard let terminalID = nonEmptyString(raw["terminal_id"]), let state = nonEmptyString(raw["state"]) else { return nil }
- return CloudVMAgentState(id: nonEmptyString(raw["id"]), terminalID: terminalID, state: state, source: nonEmptyString(raw["source"]))
+ return CloudVMAgentState(
+ id: nonEmptyString(raw["id"]),
+ terminalID: terminalID,
+ state: state,
+ source: nonEmptyString(raw["source"]),
+ agent: nonEmptyString((raw["extra"] as? [String: Any])?["agent"])
+ ?? nonEmptyString(raw["agent"])
+ ?? nonEmptyString(raw["agent_type"])
+ ?? nonEmptyString(raw["provider"])
+ )
}
return CloudVMState(
@@ -536,7 +545,7 @@ struct CmuxTuiSnapshotParser: Sendable {
lifecycle: SurfaceLifecycle(rawValue: terminal.lifecycle)
?? (terminal.running == true ? .running : .exited),
agent: state.lookupIndex.agent(terminalID: terminal.id).map {
- SurfaceAgentBadge(state: $0.state, source: $0.source)
+ SurfaceAgentBadge(state: $0.state, source: $0.source, agent: $0.agent)
},
remoteWorkspace: nil,
port: nil,
@@ -1019,7 +1028,11 @@ struct CmuxTuiSnapshotParser: Sendable {
id: nonEmptyString(value["id"]),
terminalID: terminalID,
state: state,
- source: nonEmptyString(value["source"])
+ source: nonEmptyString(value["source"]),
+ agent: nonEmptyString((value["extra"] as? [String: Any])?["agent"])
+ ?? nonEmptyString(value["agent"])
+ ?? nonEmptyString(value["agent_type"])
+ ?? nonEmptyString(value["provider"])
)
}
@@ -1421,7 +1434,14 @@ struct CmuxTuiSnapshotParser: Sendable {
var agentByTerminal: [String: SurfaceAgentBadge] = [:]
for agent in agentsRaw {
guard let terminalID = agent["terminal_id"] as? String, let state = agent["state"] as? String else { continue }
- agentByTerminal[terminalID] = SurfaceAgentBadge(state: state, source: agent["source"] as? String)
+ agentByTerminal[terminalID] = SurfaceAgentBadge(
+ state: state,
+ source: agent["source"] as? String,
+ agent: (agent["extra"] as? [String: Any])?["agent"] as? String
+ ?? (agent["agent"] as? String)
+ ?? (agent["agent_type"] as? String)
+ ?? (agent["provider"] as? String)
+ )
}
let workspaces = Self.workspaces(fromSnapshot: snapshot)
diff --git a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift
index ea4bf038a31f..08d4992ec9cf 100644
--- a/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift
+++ b/Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift
@@ -95,6 +95,10 @@ extension CmuxTuiSurfaceProvider {
) else {
throw CancellationError()
}
+ workspace.updateCloudTerminalTabIcon(
+ panelID: adopted.panelID,
+ assetName: resource.terminalAgentIconAssetName
+ )
created = adopted
reservation.inputRelay.attach(inputRouter)
// The card's grace counts from the moment the pane appeared.
@@ -103,6 +107,7 @@ extension CmuxTuiSurfaceProvider {
created = try SurfacePaneFactory.makeCloudManualMirrorPane(
at: destination,
focus: focus,
+ iconAssetName: resource.terminalAgentIconAssetName,
onInput: { input in inputRouter.send(input) },
keyNameResolver: { RemoteTmuxKeyName(inputEvent: $0)?.value },
onResize: { [weak session] sample in
diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift
index d7b59e9bb721..4b1f9392e5fe 100644
--- a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift
+++ b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift
@@ -58,6 +58,10 @@ final class CmuxTuiSurfaceProviderRegistry {
/// this registry until a fleet page positively observes them. A stale page
/// must not prune a receipt that is still converging into discovery.
private var pendingMachineCreationIDs: Set = []; private var hasCompletedInitialRefresh = false; private var refreshedMachineIDs: Set = []
+ /// Create receipts that proved a trusted, directly dialable daemon
+ /// (snapshot-v2 contract plus a private address). Consumed by the first
+ /// `vm.cmux_remote_info` for that machine instead of an attach request.
+ private var createdTrustedCarrierIDs: Set = []
/// Whether account access has ended. Retired registries reject all new Cloud work
/// until ``start(catalog:)`` reactivates them for the next account.
private var isRetired = true
@@ -108,14 +112,57 @@ final class CmuxTuiSurfaceProviderRegistry {
/// Publishes the create response's friendly name before the first workspace bind.
/// The response need not contain private addresses; provider discovery still
/// owns transport initialization and registration.
- func recordCreatedMachine(_ summary: VMSummary, scope: UUID?) {
+ ///
+ /// A receipt that carries the machine's private address registers its
+ /// provider directly, exactly as discovery would. New Machine then links
+ /// without first re-reading the whole fleet list (`GET /api/vm`, ~0.3 s).
+ /// Receipts from older backends without an address keep the old path.
+ func recordCreatedMachine(_ summary: VMSummary, scope: UUID?) async {
guard let scope, scope == creationScope, let catalog else { return }
// A replay cannot overwrite names or status already accepted by discovery.
- guard catalog.machines[.cloud(summary.id)] == nil else { return }
+ guard catalog.machines[.cloud(summary.id)] == nil, providers[summary.id] == nil else { return }
pendingMachineCreationIDs.insert(summary.id)
catalog.admitMachineCreationReceipt(CmuxTuiSurfaceProvider.info(
from: summary, linkState: .connecting, linkError: nil, stats: nil
))
+ let addresses = [summary.addressIPv4, summary.addressIPv6].compactMap { $0 }
+ guard !addresses.isEmpty, machineTeardowns[registeredMachineID(matching: summary.id)] == nil else { return }
+ let generation = refreshGeneration
+ await links.setPrivateAddresses(addresses, for: summary.id)
+ if summary.cmuxTuiContract == Self.trustedCarrierContract {
+ await links.markTrustedCarrier(machineID: summary.id)
+ }
+ // Same fences as discovery: a delete or account change during the
+ // await must not receive a provider.
+ guard !isRetired, generation == refreshGeneration, scope == creationScope,
+ providers[summary.id] == nil else { return }
+ let provider = CmuxTuiSurfaceProvider(
+ summary: summary, links: links, catalog: catalog,
+ portForwards: portForwards, portAccessStore: portAccess
+ )
+ providers[summary.id] = provider
+ catalog.register(provider)
+ if summary.cmuxTuiContract == Self.trustedCarrierContract {
+ createdTrustedCarrierIDs.insert(summary.id)
+ }
+ // Start the first link and graph read now, while the caller is still
+ // creating its workspace. The open's `ensure_linked` catalog read joins
+ // this pass instead of starting its own after the fact.
+ Task { [weak provider] in
+ _ = await provider?.refreshCurrentGraph(force: false)
+ }
+ }
+
+ /// The image contract whose daemon serves the trusted private-network
+ /// listener with no enrollment (web: FreestyleProvider `cmuxTuiContract`).
+ static let trustedCarrierContract = "snapshot-v2"
+
+ /// The private route for a machine this registry just created from a
+ /// trusted-carrier receipt, consumed once. Nil means ask the control plane.
+ func takeCreatedTrustedCarrierRoute(machineID: String) async -> String? {
+ guard createdTrustedCarrierIDs.remove(machineID) != nil,
+ !isRetired, isCloudEnabled(), providers[machineID] != nil else { return nil }
+ return await links.privateRoute(for: machineID)
}
/// True while the periodic fleet read is scheduled.
@@ -151,6 +198,7 @@ final class CmuxTuiSurfaceProviderRegistry {
accessEpoch &+= 1
creationEpoch = UUID()
pendingMachineCreationIDs.removeAll(); hasCompletedInitialRefresh = false; refreshedMachineIDs.removeAll()
+ createdTrustedCarrierIDs.removeAll()
refreshGeneration &+= 1
let epoch = accessEpoch
// Replacing block observers prevents stale callbacks after a restart.
@@ -360,6 +408,7 @@ final class CmuxTuiSurfaceProviderRegistry {
// Match the registered casing so every ownership table is removed.
let id = registeredMachineID(matching: rawID)
pendingMachineCreationIDs.remove(id); refreshedMachineIDs.remove(.cloud(id))
+ createdTrustedCarrierIDs.remove(id)
let provider = providers.removeValue(forKey: id)
provider?.suspendForFeatureFlag()
catalog?.removeCloudMachine(.cloud(id))
@@ -511,6 +560,7 @@ final class CmuxTuiSurfaceProviderRegistry {
accessEpoch &+= 1
creationEpoch = UUID()
pendingMachineCreationIDs.removeAll(); hasCompletedInitialRefresh = false; refreshedMachineIDs.removeAll()
+ createdTrustedCarrierIDs.removeAll()
refreshGeneration &+= 1
pollTask?.cancel()
pollTask = nil
diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift
index 17c89407417d..caf9313ac105 100644
--- a/Sources/Surfaces/CmuxTuiSurfaceProviders.swift
+++ b/Sources/Surfaces/CmuxTuiSurfaceProviders.swift
@@ -243,7 +243,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider {
scannedPorts = portsCache?.ports
}
guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false }
- var currentPorts = scannedPorts ?? portsCache?.ports ?? []
+ let currentPorts = scannedPorts ?? portsCache?.ports ?? []
guard isAwake, let client = vmClient else {
tabByTerminal = [:]
let remoteWorkspaces = remoteWorkspaces(for: cloudState)
@@ -278,7 +278,7 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider {
if hasDesktop, catalog.authoritativeSnapshot.resources(on: machine).isEmpty {
catalog.replaceResources(displayResources, on: machine, info: info, from: self)
}
- async let stats = try? client.stats(id: machineID)
+ let statsRead = Task { try? await client.stats(id: machineID) }
var linkState: SurfaceLinkState = .connected
var linkError: String?
// A decoded snapshot is not automatically an authorization boundary. It
@@ -286,6 +286,17 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider {
// Callers must use only a graph established by this refresh as mutation
// evidence, never the retained stale graph.
var snapshotEstablishedCurrentGraph = false
+ var portScan: Task<[Int]?, Never>?
+ // Stats and the port scan never gate this pass: joined readers (a
+ // New Machine open's `ensure_linked`) wait for the pass, not for them.
+ // They are cancelled only when the pass ends before handing them off.
+ var handedOffFollowUps = false
+ defer {
+ if !handedOffFollowUps {
+ statsRead.cancel()
+ portScan?.cancel()
+ }
+ }
do {
guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false }
let connected = try await links.connected(machineID: machineID)
@@ -293,16 +304,15 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider {
guard let link = await links.link(machineID: machineID) else { throw ProviderError.machineAsleep(machineID) }
guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false }
// The port scan and graph snapshot use independent daemon requests.
- // Start both after the link is ready, so refresh latency is the slower
- // request rather than their sum. Each result remains guarded by the
- // same generation fence before it publishes.
- async let refreshedPorts = ports(link: link, socketPath: connected.socketPath, force: force, generation: generation, privateAddress: privateAddress, displayPortsOwned: hasDesktop)
- async let snapshotData = link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath))
- if let refreshedPorts = await refreshedPorts {
- guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false }
- scannedPorts = refreshedPorts
- currentPorts = refreshedPorts
+ // Start both after the link is ready. The graph publishes as soon as
+ // the snapshot lands; ports publish when their scan finishes. The
+ // scan runs a guest command and took most of a second on a machine
+ // that had just resumed, which held New Machine's first terminal
+ // back for nothing (it only feeds port-preview rows).
+ portScan = Task { [weak self] in
+ await self?.ports(link: link, socketPath: connected.socketPath, force: force, generation: generation, privateAddress: privateAddress, displayPortsOwned: hasDesktop)
}
+ async let snapshotData = link.run(arguments: CloudTuiRequests.snapshotArguments(socketPath: connected.socketPath))
watchChanges(link: link, generation: lifecycle)
configureGuestURLOpen(link: link, socketPath: connected.socketPath)
let data = try await snapshotData
@@ -359,13 +369,18 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider {
linkError = eventsFeedWarning
}
let remoteWorkspaces = cloudState.map(Self.remoteWorkspaces)
+ // Publish the graph without waiting for stats. Stats is a control-plane
+ // HTTP read (provider status plus a guest exec, ~0.8 s) that only fills
+ // the CPU/memory/disk gauges; awaiting it here held a new machine's
+ // first terminal back by that long after the link was already up.
+ // Keep the last gauges until the new read lands below.
info = Self.info(
from: summary,
linkState: linkState,
linkError: linkError,
- stats: await stats,
+ stats: nil,
remoteWorkspaces: remoteWorkspaces
- )
+ ).carryingGauges(from: info)
if let cloudState {
// A successful read or an event install proves the retained graph is
// current. A failed or stale read keeps the graph for diagnosis but
@@ -404,6 +419,25 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider {
}
guard isCurrentRefresh(lifecycle: lifecycle, refresh: generation) else { return false }
reprojectRestoredPanes(generation: lifecycle)
+ handedOffFollowUps = true
+ let publishedPorts = currentPorts
+ let observation: CloudVMStateObservation = snapshotEstablishedCurrentGraph
+ ? .current
+ : .stale(reason: info.linkError ?? info.linkState.rawValue)
+ Task { [weak self, portScan] in
+ if let portScan, let refreshedPorts = await portScan.value,
+ let self, self.isCurrentRefresh(lifecycle: lifecycle, refresh: generation),
+ refreshedPorts != publishedPorts, let cloudState = self.cloudState {
+ self.publish(cloudState, ports: refreshedPorts, reconcileTitles: false, observation: observation)
+ }
+ }
+ Task { [weak self] in
+ if let stats = await statsRead.value,
+ let self, self.isCurrentRefresh(lifecycle: lifecycle, refresh: generation) {
+ self.info = self.info.applyingGauges(stats)
+ self.catalog.updateMachine(self.info, from: self)
+ }
+ }
return snapshotEstablishedCurrentGraph
}
@discardableResult
@@ -1546,3 +1580,27 @@ final class CmuxTuiSurfaceProvider: SurfaceProvider {
}
}
}
+
+extension SurfaceMachineInfo {
+ /// The same machine row with `previous`'s resource gauges, so a refresh that
+ /// publishes before its stats read lands does not blank the sidebar gauges.
+ func carryingGauges(from previous: SurfaceMachineInfo) -> SurfaceMachineInfo {
+ var info = self
+ info.memoryMb = previous.memoryMb
+ info.diskMb = previous.diskMb
+ info.cpuPercent = previous.cpuPercent
+ info.memoryUsedMb = previous.memoryUsedMb
+ info.diskUsedMb = previous.diskUsedMb
+ return info
+ }
+
+ func applyingGauges(_ stats: VMStats) -> SurfaceMachineInfo {
+ var info = self
+ info.memoryMb = stats.memoryTotalMb
+ info.diskMb = stats.diskTotalMb
+ info.cpuPercent = stats.cpuPercent
+ info.memoryUsedMb = stats.memoryUsedMb
+ info.diskUsedMb = stats.diskUsedMb
+ return info
+ }
+}
diff --git a/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift b/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift
new file mode 100644
index 000000000000..06042e101069
--- /dev/null
+++ b/Sources/Surfaces/SurfaceCatalog+AgentIcons.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+extension SurfaceCatalog {
+ /// Visits projections once per accepted catalog transaction, independent of
+ /// the number of resources in a full snapshot. Missing resources clear icons.
+ func syncCloudTerminalTabIcons(on machine: SurfaceMachineID, affected: Set? = nil) {
+ guard !machine.isLocal else { return }
+ for projection in projections where projection.resource.machine == machine {
+ if let affected, !affected.contains(projection.resource) { continue }
+ syncCloudTerminalTabIcon(projection)
+ }
+ }
+
+ func syncCloudTerminalTabIcon(_ projection: SurfaceProjection) {
+ guard !projection.resource.machine.isLocal, projection.resource.kind == .terminal,
+ let workspace = cloudWorkspaceRenameService.environment.workspace(projection.workspaceID),
+ workspace.panels[projection.panelID] is TerminalPanel,
+ let tabID = workspace.surfaceIdFromPanelId(projection.panelID),
+ let tab = workspace.bonsplitController.tab(tabID) else { return }
+ let asset = resources[projection.resource]?.terminalAgentIconAssetName
+ guard tab.iconAsset != asset else { return }
+ workspace.bonsplitController.updateTab(tabID, iconAsset: .some(asset))
+ }
+}
diff --git a/Sources/Surfaces/SurfaceCatalog.swift b/Sources/Surfaces/SurfaceCatalog.swift
index 0fb7ae8799a4..a468e0ee73d2 100644
--- a/Sources/Surfaces/SurfaceCatalog.swift
+++ b/Sources/Surfaces/SurfaceCatalog.swift
@@ -193,6 +193,7 @@ final class SurfaceCatalog {
machines[machine] = nil
for id in resourceIDsByMachine[machine] ?? [] { resources[id] = nil }
resourceIDsByMachine[machine] = nil
+ syncCloudTerminalTabIcons(on: machine)
pendingRestoredProjections.remove(machine: machine)
cloudWorkspaceProjectionCoordinator.cancel(machine: machine)
cloudProjectionIndexDirty = true
@@ -292,6 +293,7 @@ final class SurfaceCatalog {
}
if let info { machines[machine] = machineInfoPreservingCanonicalCloudState(info) }
resolvePendingRestoredProjections(on: machine)
+ syncCloudTerminalTabIcons(on: machine)
updateCloudDirectoryMetadata(on: machine)
reconcileDeviceNames(on: machine)
notifyChange()
@@ -305,6 +307,7 @@ final class SurfaceCatalog {
resources[resource.id] = resource
resourceIDsByMachine[resource.machine, default: []].insert(resource.id)
resolvePendingRestoredProjections(on: resource.machine)
+ syncCloudTerminalTabIcons(on: resource.machine, affected: [resource.id])
notifyChange()
}
@@ -316,6 +319,7 @@ final class SurfaceCatalog {
if resourceIDsByMachine[id.machine]?.isEmpty == true {
resourceIDsByMachine[id.machine] = nil
}
+ syncCloudTerminalTabIcons(on: id.machine, affected: [id])
notifyChange()
}
@@ -468,6 +472,7 @@ final class SurfaceCatalog {
machines[state.machine] = machineInfoPreservingCanonicalCloudState(info, state: state)
cloudWorkspaceCreationCoordinator.reconcile(state)
resolvePendingRestoredProjections(on: state.machine)
+ syncCloudTerminalTabIcons(on: state.machine, affected: changed)
updateCloudDirectoryMetadata(on: state.machine, affectedResourceIDs: freshnessChanged ? nil : affectedResourceIDs)
notifyChange()
return changed
@@ -510,6 +515,7 @@ final class SurfaceCatalog {
machines[state.machine] = machineInfoPreservingCanonicalCloudState(info, state: state)
cloudWorkspaceCreationCoordinator.reconcile(state)
resolvePendingRestoredProjections(on: state.machine)
+ syncCloudTerminalTabIcons(on: state.machine, affected: changed)
updateCloudDirectoryMetadata(on: state.machine)
notifyChange()
return changed
@@ -556,6 +562,7 @@ final class SurfaceCatalog {
rebuildResourceIndex(for: machine)
machines[machine] = machineInfoPreservingCanonicalCloudState(info)
resolvePendingRestoredProjections(on: machine)
+ syncCloudTerminalTabIcons(on: machine)
updateCloudDirectoryMetadata(on: machine)
notifyChange()
}
@@ -1104,6 +1111,7 @@ final class SurfaceCatalog {
insertSupersedingLocalPlaceholder(cloudPlacementCoordinator.projectionInCurrentWorkspace(projection))
reconcileCloudWorkspaceBinding(localWorkspaceID: projection.workspaceID)
reconcileCloudProjection(projection)
+ syncCloudTerminalTabIcon(projection)
notifyChange()
}
diff --git a/Sources/Surfaces/SurfaceCatalogModel.swift b/Sources/Surfaces/SurfaceCatalogModel.swift
index 0d784bb58027..1ce3269c3364 100644
--- a/Sources/Surfaces/SurfaceCatalogModel.swift
+++ b/Sources/Surfaces/SurfaceCatalogModel.swift
@@ -109,6 +109,8 @@ enum SurfaceLifecycle: String, Codable, Sendable {
struct SurfaceAgentBadge: Hashable, Codable, Sendable {
var state: String
var source: String?
+ /// The adapter identity, separate from report provenance (`hook`, `socket`, or `plugin`).
+ var agent: String? = nil
}
/// The daemon's monotonic position for one complete remote session state.
@@ -302,6 +304,7 @@ struct CloudVMAgentState: Hashable, Codable, Sendable {
var terminalID: String
var state: String
var source: String?
+ var agent: String? = nil
}
/// How a remote session can be synchronized.
diff --git a/Sources/Surfaces/SurfaceCatalogQueryService.swift b/Sources/Surfaces/SurfaceCatalogQueryService.swift
index d2b5f139d28a..ef2da325f02b 100644
--- a/Sources/Surfaces/SurfaceCatalogQueryService.swift
+++ b/Sources/Surfaces/SurfaceCatalogQueryService.swift
@@ -1,5 +1,19 @@
import Foundation
+/// How much work a catalog read may do before exporting.
+enum SurfaceCatalogReadMode: Equatable, Sendable {
+ /// Export what the catalog already holds. Never discovers, connects, or wakes.
+ case cached
+ /// Ensure one machine has a connected, installed graph, then export. A
+ /// machine that is already connected is served from the live catalog (its
+ /// change watcher keeps the graph current), so this costs nothing on reopen.
+ /// A machine that is missing or not yet linked is discovered and joins the
+ /// provider's current refresh pass instead of forcing a new one.
+ case linked
+ /// Discover if missing, then force a new provider pass (port rescan included).
+ case forced
+}
+
/// Reads the surface catalog and resolves providers for machines not yet discovered
/// by the periodic Cloud fleet refresh. Socket entrypoints share this query owner.
@MainActor
@@ -28,10 +42,24 @@ struct SurfaceCatalogQueryService {
}
func read(machine: SurfaceMachineID?, refresh: Bool) async -> SurfaceCatalogExport {
- if refresh {
- if let machine {
+ await read(machine: machine, mode: refresh ? .forced : .cached)
+ }
+
+ func read(machine: SurfaceMachineID?, mode: SurfaceCatalogReadMode) async -> SurfaceCatalogExport {
+ switch mode {
+ case .cached:
+ break
+ case .linked:
+ // Only a machine-scoped read can ask for a link. An unfiltered
+ // `.linked` read would connect every machine, which is `.forced`.
+ if let machine, !isLinked(machine) {
// A create can finish before the fleet poll sees the machine.
// Discover it before an empty catalog is treated as unavailable.
+ _ = await provider(for: machine)
+ await catalog.refresh(machine: machine, force: false)
+ }
+ case .forced:
+ if let machine {
_ = await provider(for: machine)
await catalog.refresh(machine: machine, force: true)
} else {
@@ -44,4 +72,8 @@ struct SurfaceCatalogQueryService {
export.projectionIdentities = projectionIdentities(export.catalog.projections)
return export
}
+
+ private func isLinked(_ machine: SurfaceMachineID) -> Bool {
+ catalog.provider(for: machine) != nil && catalog.machineInfo(for: machine)?.linkState == .connected
+ }
}
diff --git a/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift b/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift
index 9f7ea1e51c9b..8ce294a9c4e9 100644
--- a/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift
+++ b/Sources/Surfaces/SurfacePaneFactory+CloudManualMirror.swift
@@ -13,6 +13,7 @@ extension SurfacePaneFactory {
static func makeCloudManualMirrorPane(
at destination: SurfaceDestination,
focus: Bool,
+ iconAssetName: String? = nil,
onInput: @escaping @Sendable (TerminalManualInput) -> Void,
keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil,
onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void,
@@ -26,6 +27,7 @@ extension SurfacePaneFactory {
return try workspace.addCloudManualMirrorPane(
at: destination,
focus: focus,
+ iconAssetName: iconAssetName,
onInput: onInput,
keyNameResolver: keyNameResolver,
onResize: onResize,
diff --git a/Sources/Surfaces/SurfaceResource+AgentIcon.swift b/Sources/Surfaces/SurfaceResource+AgentIcon.swift
new file mode 100644
index 000000000000..cbeb35d08c98
--- /dev/null
+++ b/Sources/Surfaces/SurfaceResource+AgentIcon.swift
@@ -0,0 +1,26 @@
+import Foundation
+
+extension SurfaceResource {
+ /// One provider identity drives the tab strip and every Cloud tree placement.
+ /// Report provenance and user-controlled terminal titles are not provider IDs.
+ var terminalAgentIconAssetName: String? {
+ guard kind == .terminal, lifecycle != .exited,
+ let badge = agent, badge.state != "done" else { return nil }
+
+ let definitions = CmuxTaskManagerCodingAgentDefinition.builtIns
+ let identities = [badge.agent, badge.source]
+ .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .filter { !$0.isEmpty && !["hook", "socket", "detected", "plugin", "unknown"].contains($0) }
+ if let asset = identities.lazy.compactMap({ identity in
+ definitions.first { definition in
+ definition.id == identity
+ || definition.launchKinds.contains(identity)
+ || definition.directBasenames.contains(identity)
+ }?.assetName
+ }).first {
+ return asset
+ }
+
+ return nil
+ }
+}
diff --git a/Sources/Surfaces/SurfaceSocketCommands.swift b/Sources/Surfaces/SurfaceSocketCommands.swift
index 58b62a6aa4a1..935e59d1c5c4 100644
--- a/Sources/Surfaces/SurfaceSocketCommands.swift
+++ b/Sources/Surfaces/SurfaceSocketCommands.swift
@@ -28,10 +28,20 @@ extension TerminalController {
case "surface.catalog":
let machine = Self.surfaceMachineFilter(params["machine"])
if let machine, machine.cloudMachineID != nil, let error = cloudDisabledSocketError(id: id) { return error }
- let refresh = Self.surfaceBool(params["refresh"]) ?? false
+ // `refresh` forces a provider pass; `ensure_linked` only connects a
+ // machine that has no live graph yet (a just-created VM) and is free
+ // for one that is already linked. `refresh` wins when both are sent.
+ let mode: SurfaceCatalogReadMode
+ if Self.surfaceBool(params["refresh"]) == true {
+ mode = .forced
+ } else if Self.surfaceBool(params["ensure_linked"]) == true {
+ mode = .linked
+ } else {
+ mode = .cached
+ }
return v2VmCall(id: id, timeoutSeconds: 120) {
let query = await Self.surfaceCatalogQuery(catalog: .shared)
- let export = await query.read(machine: machine, refresh: refresh)
+ let export = await query.read(machine: machine, mode: mode)
return Self.surfaceCatalogPayload(export, machine: machine)
}
diff --git a/Sources/Surfaces/Workspace+CloudManualMirror.swift b/Sources/Surfaces/Workspace+CloudManualMirror.swift
index d7e1436faedb..3f9ed7d431c7 100644
--- a/Sources/Surfaces/Workspace+CloudManualMirror.swift
+++ b/Sources/Surfaces/Workspace+CloudManualMirror.swift
@@ -16,14 +16,13 @@ import GhosttyKit
@MainActor
extension Workspace {
/// A saved device terminal stays process-free until its provider reconnects:
- /// the pane is built on the same manual-mirror path as a live attachment,
- /// with no transport bound yet, and is never marked loading.
+ /// the pane is built on the same manual-mirror path as a live attachment.
func restoreDeviceDisplayPanel(_ snapshot: SessionPanelSnapshot, in pane: PaneID) -> UUID? {
guard let panel = makeRemoteTmuxPanePanel(onInput: { _ in }, keyNameResolver: nil) else { return nil }
Self.bindCloudManualMirrorCallbacks(
panel: panel, onResize: { _ in }, onRuntimeReady: {}, onFocus: {}, attachment: nil
)
- guard let panelID = try? insertCloudManualMirrorTab(panel, in: pane, focus: false, isLoading: false) else {
+ guard let panelID = try? insertCloudManualMirrorTab(panel, in: pane, focus: false, isLoading: false, iconAssetName: nil) else {
return nil
}
let status = DeviceTerminalAttachmentStatus()
@@ -56,6 +55,7 @@ extension Workspace {
func addCloudManualMirrorPane(
at destination: SurfaceDestination,
focus: Bool,
+ iconAssetName: String? = nil,
onInput: @escaping @Sendable (TerminalManualInput) -> Void,
keyNameResolver: (@MainActor @Sendable (ghostty_input_key_s) -> String?)? = nil,
onResize: @escaping @MainActor @Sendable (TerminalSurfaceRawSizingSample) -> Void,
@@ -86,7 +86,9 @@ extension Workspace {
try workspace.adoptCloudMachineLoadingPanel(loading, terminal: panel, focus: focus)
return (workspace.id, panel.id, panel.surface)
}
- let panelID = try workspace.insertCloudManualMirrorPanel(panel, at: destination, focus: focus, isLoading: false)
+ let panelID = try workspace.insertCloudManualMirrorPanel(
+ panel, at: destination, focus: focus, isLoading: false, iconAssetName: iconAssetName
+ )
return (workspace.id, panelID, panel.surface)
}
@@ -115,7 +117,8 @@ extension Workspace {
_ panel: TerminalPanel,
at destination: SurfaceDestination,
focus: Bool,
- isLoading: Bool
+ isLoading: Bool,
+ iconAssetName: String? = nil
) throws -> UUID {
switch destination {
case .workspace(_, let placement):
@@ -123,20 +126,22 @@ extension Workspace {
guard let pane else { throw SurfaceCatalogError.destinationNotFound("focused pane") }
switch placement {
case .tab:
- return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading)
+ return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName)
case .split:
- return try splitCloudManualMirrorPane(panel, target: pane, direction: .right, focus: focus, isLoading: isLoading)
+ return try splitCloudManualMirrorPane(panel, target: pane, direction: .right, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName)
}
case .tab(_, let paneID, let index):
guard let pane = Self.pane(paneID, in: self) else {
throw SurfaceCatalogError.destinationNotFound("pane (paneID)")
}
- return try insertCloudManualMirrorTab(panel, in: pane, focus: focus, isLoading: isLoading, index: index)
+ return try insertCloudManualMirrorTab(
+ panel, in: pane, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName, index: index
+ )
case .split(_, let paneID, let direction):
guard let pane = Self.pane(paneID, in: self) else {
throw SurfaceCatalogError.destinationNotFound("pane (paneID)")
}
- return try splitCloudManualMirrorPane(panel, target: pane, direction: direction, focus: focus, isLoading: isLoading)
+ return try splitCloudManualMirrorPane(panel, target: pane, direction: direction, focus: focus, isLoading: isLoading, iconAssetName: iconAssetName)
}
}
@@ -145,6 +150,7 @@ extension Workspace {
in pane: PaneID,
focus: Bool,
isLoading: Bool,
+ iconAssetName: String?,
index: Int? = nil
) throws -> UUID {
let previousPane = bonsplitController.focusedPaneId
@@ -154,6 +160,7 @@ extension Workspace {
guard let tab = bonsplitController.createTab(
title: Self.cloudManualMirrorTabTitle,
icon: panel.displayIcon,
+ iconAsset: iconAssetName,
kind: SurfaceKind.terminal.rawValue,
isDirty: panel.isDirty,
isLoading: false,
@@ -193,7 +200,8 @@ extension Workspace {
target: PaneID,
direction: SurfaceSplitDirection,
focus: Bool,
- isLoading: Bool
+ isLoading: Bool,
+ iconAssetName: String?
) throws -> UUID {
let previousPane = bonsplitController.focusedPaneId
let previousTab = previousPane.flatMap { bonsplitController.selectedTab(inPane: $0)?.id }
@@ -202,6 +210,7 @@ extension Workspace {
let tab = Bonsplit.Tab(
title: Self.cloudManualMirrorTabTitle,
icon: panel.displayIcon,
+ iconAsset: iconAssetName,
kind: SurfaceKind.terminal.rawValue,
isDirty: panel.isDirty,
isLoading: false,
@@ -236,6 +245,19 @@ extension Workspace {
return panel.id
}
+ /// Flags or clears the tab-strip spinner of a pane whose terminal is still arriving.
+ func setCloudManualMirrorTabLoading(panelID: UUID, _ isLoading: Bool) {
+ guard let tabID = surfaceIdFromPanelId(panelID) else { return }
+ bonsplitController.updateTab(tabID, isLoading: isLoading)
+ }
+
+ /// Updates a Cloud terminal tab after the daemon reports a provider identity change.
+ func updateCloudTerminalTabIcon(panelID: UUID, assetName: String?) {
+ guard let tabID = surfaceIdFromPanelId(panelID),
+ let tab = bonsplitController.tab(tabID), tab.iconAsset != assetName else { return }
+ bonsplitController.updateTab(tabID, iconAsset: .some(assetName))
+ }
+
/// The live workspace with `id` in any window, or nil once it was retired.
static func liveWorkspace(id: UUID) -> Workspace? {
AppDelegate.shared?.tabManagerFor(tabId: id)?.tabs.first { $0.id == id }
diff --git a/Sources/TerminalController+ControlTerminalBinding.swift b/Sources/TerminalController+ControlTerminalBinding.swift
index 08f77e51c7b4..981a677fd82b 100644
--- a/Sources/TerminalController+ControlTerminalBinding.swift
+++ b/Sources/TerminalController+ControlTerminalBinding.swift
@@ -35,10 +35,14 @@ struct ControlTerminalSocketTarget {
/// Sends a bracketed-paste payload through the canonical surface.
func sendText(_ text: String) -> Bool {
+ sendTextResult(text).accepted
+ }
+
+ func sendTextResult(_ text: String) -> TerminalSurface.TextSendResult {
if surface === panel.surface {
- return panel.sendText(text)
+ return panel.sendTextResult(text)
}
- return surface.sendText(text)
+ return surface.sendTextResult(text)
}
/// Sends a named key through the canonical surface, retaining the panel's
diff --git a/Sources/TerminalController+ControlWorkspaceStrings.swift b/Sources/TerminalController+ControlWorkspaceStrings.swift
index 4064429aaebb..81285ad83c2d 100644
--- a/Sources/TerminalController+ControlWorkspaceStrings.swift
+++ b/Sources/TerminalController+ControlWorkspaceStrings.swift
@@ -20,14 +20,26 @@ extension TerminalController {
localized: "socket.workspace.reorderMany.duplicateWorkspace",
defaultValue: "Duplicate workspace in order"
),
- reorderManyWorkspaceNotFound: String(
+ workspaceNotFound: String(
localized: "socket.workspace.reorderMany.workspaceNotFound",
defaultValue: "Workspace not found"
),
- reorderManyInvalidWorkspace: String(
+ invalidWorkspaceRef: String(
localized: "socket.workspace.reorderMany.invalidWorkspace",
defaultValue: "Invalid workspace id or ref"
),
+ reorderIndexNotAnInteger: String(
+ localized: "socket.workspace.reorder.indexNotAnInteger",
+ defaultValue: "index must be an integer"
+ ),
+ reorderMissingWorkspaceID: String(
+ localized: "socket.workspace.reorder.missingWorkspaceID",
+ defaultValue: "Missing or invalid workspace_id"
+ ),
+ reorderTargetRequired: String(
+ localized: "socket.workspace.reorder.targetRequired",
+ defaultValue: "Specify exactly one target: index, before_workspace_id, or after_workspace_id"
+ ),
reorderManyTabManagerUnavailable: String(
localized: "socket.workspace.reorderMany.tabManagerUnavailable",
defaultValue: "TabManager not available"
diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift
index 2f4c064cbaa5..e2279423ad3d 100644
--- a/Sources/TerminalController.swift
+++ b/Sources/TerminalController.swift
@@ -15695,8 +15695,16 @@ class TerminalController {
// surface): they run `resumeForExplicitInputIfNeeded()` first, waking a
// hibernated agent terminal the same way local typing does, so a mobile
// composer submit cannot write into a cold surface.
- guard terminalTarget.sendText(text) else {
+ let textResult = terminalTarget.sendTextResult(text)
+ switch textResult {
+ case .sent, .queued:
+ break
+ case .inputQueueFull:
+ return .err(code: "input_queue_full", message: Self.terminalInputQueueFullMessage, data: ["surface_id": surfaceId.uuidString])
+ case .surfaceUnavailable:
return .err(code: "surface_unavailable", message: Self.terminalSurfaceUnavailableMessage, data: ["surface_id": surfaceId.uuidString])
+ case .processExited:
+ return .err(code: "process_exited", message: Self.terminalProcessExitedMessage, data: ["surface_id": surfaceId.uuidString])
}
// The paste text is already accepted by the surface above. From here on a
@@ -15739,6 +15747,7 @@ class TerminalController {
var payload: [String: Any] = [
"workspace_id": resolved.workspace.id.uuidString,
"surface_id": terminalPanel.id.uuidString,
+ "delivery": textResult == .sent ? "delivered" : "queued",
"submitted": submitted,
]
if let submitError {
diff --git a/Sources/TerminalTabAgentIcon.swift b/Sources/TerminalTabAgentIcon.swift
new file mode 100644
index 000000000000..5d5a954be74b
--- /dev/null
+++ b/Sources/TerminalTabAgentIcon.swift
@@ -0,0 +1,47 @@
+import Bonsplit
+import Foundation
+
+/// Resolves the provider mark for a local terminal tab from the same agent
+/// definitions used by process and hook detection.
+struct TerminalTabAgentIconResolver {
+ func assetName(forStatusKey statusKey: String) -> String? {
+ let normalized = statusKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ guard !normalized.isEmpty else { return nil }
+ return CmuxTaskManagerCodingAgentDefinition.builtIns.first { definition in
+ definition.id == normalized
+ || definition.launchKinds.contains(normalized)
+ || definition.directBasenames.contains(normalized)
+ }?.assetName
+ }
+
+ func titleStatusKey(from title: String) -> String? {
+ let token = title.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)?.lowercased()
+ guard let token else { return nil }
+ return assetName(forStatusKey: token) == nil ? nil : token
+ }
+}
+
+extension Workspace {
+ /// Returns the current provider mark for one terminal panel, if known.
+ func terminalTabAgentIconAsset(forPanelId panelId: UUID) -> String? {
+ let resolver = TerminalTabAgentIconResolver()
+ let statusKeys = agentPIDKeysByPanelId[panelId, default: []]
+ .map(agentStatusKey(forAgentPIDKey:))
+ .sorted()
+ if let asset = statusKeys.compactMap(resolver.assetName(forStatusKey:)).first {
+ return asset
+ }
+ guard let restored = restoredAgentSnapshotsByPanelId[panelId] else { return nil }
+ return restored.registration?.iconAssetName ?? resolver.assetName(forStatusKey: restored.kind.rawValue)
+ }
+
+ /// Reconciles a terminal tab's provider mark after agent lifecycle state changes.
+ func syncTerminalTabAgentIconAsset(forPanelId panelId: UUID) {
+ guard panels[panelId] is TerminalPanel,
+ let tabID = surfaceIdFromPanelId(panelId),
+ let tab = bonsplitController.tab(tabID) else { return }
+ let asset = terminalTabAgentIconAsset(forPanelId: panelId)
+ guard tab.iconAsset != asset else { return }
+ bonsplitController.updateTab(tabID, iconAsset: .some(asset))
+ }
+}
diff --git a/Sources/Workspace+PanelLifecycle.swift b/Sources/Workspace+PanelLifecycle.swift
index e1b677f4cced..519369cb3f2b 100644
--- a/Sources/Workspace+PanelLifecycle.swift
+++ b/Sources/Workspace+PanelLifecycle.swift
@@ -197,6 +197,9 @@ extension Workspace {
}
}
if refreshPorts { refreshTrackedAgentPorts() }
+ for changedPanelID in Set([previous.panelId, panelId].compactMap { $0 }) {
+ syncTerminalTabAgentIconAsset(forPanelId: changedPanelID)
+ }
return didClearOtherStructuredAgentRuntime
}
@@ -343,12 +346,16 @@ extension Workspace {
if didChange, refreshPorts {
refreshTrackedAgentPorts()
}
+ if didChange, let changedPanelId = ownedPanelId ?? panelId {
+ syncTerminalTabAgentIconAsset(forPanelId: changedPanelId)
+ }
return didChange
}
/// Clears a panel's restored agent snapshot and resume metadata.
func clearRestoredAgentSnapshot(panelId: UUID) {
restoredAgentLifecycle.clearSessionRestore(panelId: panelId)
+ syncTerminalTabAgentIconAsset(forPanelId: panelId)
}
func refreshTrackedAgentPorts() {
diff --git a/Sources/Workspace+TitleOwnership.swift b/Sources/Workspace+TitleOwnership.swift
index c8da348a2790..95a51d66b360 100644
--- a/Sources/Workspace+TitleOwnership.swift
+++ b/Sources/Workspace+TitleOwnership.swift
@@ -167,6 +167,10 @@ extension Workspace {
}
}
+ if !isRemoteTmuxMirror {
+ syncTerminalTabAgentIconAsset(forPanelId: panelId)
+ }
+
let previousWorkspaceTitle = self.title
if applyFocusedPanelTitle(panelId: panelId) {
didMutate = true
diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift
index f1a12d07c773..e8b27dfee314 100644
--- a/Sources/Workspace.swift
+++ b/Sources/Workspace.swift
@@ -7625,7 +7625,7 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
"cmux_freestyle_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"",
"if [ -z \"$cmux_freestyle_cli\" ] || [ ! -x \"$cmux_freestyle_cli\" ]; then cmux_freestyle_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi",
"if [ -z \"$cmux_freestyle_cli\" ]; then printf '%s\\n' '[cmux] bundled CLI not found for Cloud VM SSH attach.' >&2; exit 127; fi",
- "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-86400}\"",
+ "CMUX_SSH_RECONNECT_LIMIT=\"${CMUX_SSH_RECONNECT_LIMIT:-\(SSHReconnectBudget().maximumLimit)}\"",
"CMUX_SSH_RECONNECT_DELAY_SECONDS=\"${CMUX_SSH_RECONNECT_DELAY_SECONDS:-2}\"",
"CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_LIMIT:-$CMUX_SSH_RECONNECT_LIMIT}\"",
"CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS=\"${CMUX_DEFAULT_FREESTYLE_ATTACH_RETRY_DELAY_SECONDS:-$CMUX_SSH_RECONNECT_DELAY_SECONDS}\"",
diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md
index 6c1aa614e04a..ced330a235ff 100644
--- a/THIRD_PARTY_LICENSES.md
+++ b/THIRD_PARTY_LICENSES.md
@@ -4,6 +4,18 @@ cmux includes the following third-party software:
---
+## Lobe Icons (selected agent marks)
+
+- **License:** MIT License
+- **Copyright:** Copyright (c) 2023 LobeHub
+- **Source:** https://github.com/lobehub/lobe-icons/tree/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons
+
+Selected Cursor, Gemini, Kiro, GitHub Copilot, CodeBuddy, Qoder, Kimi, and
+Ollama SVG marks are bundled under `Assets.xcassets/AgentIcons`. The complete
+license text is in `Assets.xcassets/AgentIcons/LOBE-LICENSE.txt`.
+
+---
+
## Ghostty
- **License:** MIT License
@@ -82,6 +94,39 @@ SOFTWARE.
---
+## herdr agent-detection plugin
+
+cmux includes a userland agent-detection plugin derived from herdr. Its
+manifests and adapted detector sources live under
+`cmux-tui/bindings/examples/rust-agent-screen-detection/`.
+
+- **Package license:** MIT AND Apache-2.0
+- **Herdr-derived material:** Apache License 2.0
+- **Source:** https://github.com/herdrdev/herdr
+- **Detector source reference:** commit `7b675f42af35508eab66ac42fe1598628597a893`
+- **Pi bundled-launcher correction:** commit `b1ff4582e9688f52ffb943cfa8bee4871ae122e4`
+- **Manifest snapshot:** commit `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`
+- **Included manifest fixes:** Claude MCP elicitation `f807b697353cfa00aa912c7cde4830e863001cf5`, Claude background-shell state `987b070fbfa187e85009b45cd7e208fc6175ff6a`, Codex weak-blocker scope `f457cff4f2648eee85d176f8a41861241d4e8428`, and Copilot background-agent activity `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`
+- **License text:** cmux-owned code is covered by
+ `cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT`; the
+ herdr-derived files use
+ `cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE`
+- **Latest agent-surface capability audit:** commit `987b070fbfa187e85009b45cd7e208fc6175ff6a`. The herdr repository tip checked on 2026-09-02 is `94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`; commits after the audit pin change client rendering, terminal reads, graphics ownership, Windows input and worktree handling, or sidebar focus, with no further `src/detect` or manifest changes. The audit includes the exact Pi bundled CLI path correction from `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` and the Claude background-shell state correction from `987b070fbfa187e85009b45cd7e208fc6175ff6a`, both adapted and tested in the userland package. The first-acquisition OSC retention fix from `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` is adapted in the userland tracker. The foreground group-leader CWD fix from `3a3792622e59c7f2dc20f9c0236167161e4a5035` is already covered by cmux's generic `foreground_cwd` resource. The shell-render refactor in `207be3c771d281baae6e5fa0fb74be9a056e97a2` and independent multi-client tab views in `6c0bb273d5d5405a00985621b17e36f8b4d64609` are application/client architecture and are not copied. The delayed-agent-prompt fix in `8633a398e653eee47b375c963996c78a8a14aa48` changes PTY input sequencing, and `5616196942cbe752cc0659b9bd0fb616b2a6ed5c` hardens malformed Windows process environments in portable-pty. These changes are outside detector behavior and are not copied. SDK endpoint-generation compatibility remains a standalone-release requirement; review the Windows changes before publishing a Windows package.
+
+Nineteen manifests are unchanged from the manifest snapshot. `claude.toml` is
+byte-identical to upstream commit `987b070fbfa187e85009b45cd7e208fc6175ff6a`.
+`grok.toml` is based on the snapshot file and contains one documented cmux
+precedence correction. `github-copilot.toml` is byte-identical to the snapshot
+and uses upstream version `2026.08.29.1`. The manifest engine, process discovery, state detector, and update
+logic are adapted for the cmux userland plugin contract. The source paths,
+commits, license, and adaptations are recorded in
+`cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md`.
+The SHA256SUMS file is a checked-in byte-provenance record verified before the
+bundled manifests are compiled. It detects accidental drift, but it is not a
+cryptographic release signature for remote updates.
+
+---
+
## Sparkle
- **License:** MIT License
diff --git a/cmux-tui/AGENTS.md b/cmux-tui/AGENTS.md
index 7918b07c83c9..5440ea5d68f3 100644
--- a/cmux-tui/AGENTS.md
+++ b/cmux-tui/AGENTS.md
@@ -9,7 +9,7 @@ Do not run `cargo`, `rustc`, or Zig on Lawrence's Mac. Do not use a local build
Use `--filter` during focused development. It accepts one Rust test-name substring and verifies that the filter selects at least one test on hosted Linux and macOS. The reserved `chatmux_relay` (or `chatmux-relay`) selector runs the complete `chatmux-relay` package because Cargo test names do not include package names. Use `--full` for the merge gate. Full mode runs the complete Linux and macOS suites, package builds, and a Windows-hosted binary execution check.
-The script rejects dirty or unpushed work, verifies the exact commit in every hosted job, waits for completion, prints failed logs, and downloads the macOS arm64 binary to `cmux-tui/target/hosted//cmux-tui`. Running that downloaded binary on the Mac is allowed.
+The script rejects dirty or unpushed work, verifies the exact commit in every hosted job, waits for completion, prints failed logs, and downloads the macOS arm64 TUI and userland agent detector to `cmux-tui/target/hosted//cmux-tui` and `cmux-tui/target/hosted//cmux-agent-screen-detection`. Running those downloaded binaries on the Mac is allowed.
`rust-toolchain.toml` is the single Rust toolchain source for hosted TUI tests, package builds, and live conformance. Change that file instead of adding a workflow-specific Rust version.
diff --git a/cmux-tui/ATTRIBUTIONS.md b/cmux-tui/ATTRIBUTIONS.md
new file mode 100644
index 000000000000..11a28d1b5778
--- /dev/null
+++ b/cmux-tui/ATTRIBUTIONS.md
@@ -0,0 +1,120 @@
+# Third-party attributions
+
+## herdr
+
+- Project: https://github.com/herdrdev/herdr
+- License: Apache-2.0 (upstream ships a LICENSE file and no NOTICE file; a
+ copy is included at
+ `bindings/examples/rust-agent-screen-detection/manifests/LICENSE`)
+- Detector source reference commit: `7b675f42af35508eab66ac42fe1598628597a893`
+- Pi bundled-launcher correction commit: `b1ff4582e9688f52ffb943cfa8bee4871ae122e4`
+- Manifest snapshot commit: `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`
+- First-acquisition OSC retention commit: `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605`
+- Included manifest fixes: Claude MCP elicitation `f807b697353cfa00aa912c7cde4830e863001cf5`,
+ Claude background-shell state `987b070fbfa187e85009b45cd7e208fc6175ff6a`,
+ Codex weak-blocker scope `f457cff4f2648eee85d176f8a41861241d4e8428`, and
+ Copilot background-agent activity `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`.
+
+Derived material and vendored material:
+
+- `bindings/examples/rust-agent-screen-detection/manifests/*.toml`: 19
+ manifests are unchanged from the manifest snapshot's
+ `src/detect/manifests/`; `claude.toml` is byte-identical to upstream commit
+ `987b070fbfa187e85009b45cd7e208fc6175ff6a`. `grok.toml` carries the one
+ documented cmux correction; `github-copilot.toml` is byte-identical to the
+ upstream snapshot. Never refresh these files from herdr's update endpoint.
+ Re-vendor the 19 files from the exact snapshot, take Claude from its stated
+ commit, and reapply the Grok correction when changing the pin.
+- `bindings/examples/rust-agent-screen-detection/src/manifest.rs`: the
+ manifest engine (rule grammar, region extraction, gate evaluation,
+ validation limits), ported from `src/detect/manifest.rs`.
+- `bindings/examples/rust-agent-screen-detection/src/{detect.rs,scanner.rs}`:
+ detection semantics (state model, edge-triggered transitions,
+ foreground-process identification, quiescence sampling) derived from
+ `src/detect/mod.rs`, `src/pane/agent_detection.rs`, and `src/pane.rs`.
+ These files are a userland plugin. Herdr's first-acquisition OSC retention
+ fix (`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605`) is adapted as a local
+ output-revision fence for replacement agents. Core only supervises the
+ process and folds its generic events.
+- `bindings/examples/rust-agent-screen-detection/src/process.rs`: bounded
+ foreground process-group discovery and wrapper handling derived from
+ herdr's platform and detector modules, with platform fallbacks, stricter
+ candidate filtering, attached runtime-mode parsing, positional-argument
+ boundaries, direct shell-script parsing, shell-word unescaping, runtime-specific
+ shell invocation-mode checks, Python boolean/exit/value option boundaries,
+ attached-versus-separate option handling, and bounded `/proc` streaming added
+ by manaflow. The Python distinctions are
+ a local correctness improvement over the inherited option list: `-S` is
+ boolean, documented help aliases (`-?`, `-VV`) terminate, and
+ help/version/hash options cannot expose following tokens as agent
+ executables. Unsupported attached long options fail closed before they can
+ consume a later runtime mode flag.
+- `crates/cmux-tui-core/src/terminal_metadata.rs`: OSC string framing adapted
+ from herdr's `src/pane/osc.rs`. Manaflow adds lead-specific UTF-8
+ continuation validation and malformed-sequence recovery before C1 framing.
+ Core retains only generic bounded OSC 9 progress metadata; it has no agent
+ or roster policy.
+- `bindings/examples/rust-agent-screen-detection/src/manifest_update.rs`:
+ explicit catalog and cache status concepts derived from herdr's update
+ surface. Network access, URL validation, atomic writes, and version policy
+ are a new manaflow implementation and never run during daemon startup.
+- `crates/cmux-tui/src/sidebar_projection.rs` (`agent_attention`) and the
+ agents-view rendering in `crates/cmux-tui/src/ui/{sidebar.rs,rail.rs}`:
+ the two-line row and header layout follow `src/app/agent_view.rs` and
+ herdr's agents-panel design. cmux currently orders rows by blocked,
+ working, then idle, with newest transitions first inside each bucket. The
+ cache invalidation against cmux's terminal topology and the stable
+ tree-order tie break are manaflow additions. The herdr idle-unseen seen bit
+ is intentionally not copied because it is client-owned presentation state;
+ the deliberate exclusion is listed in `spec/plugins.md`.
+- `bindings/examples/rust-agent-screen-detection/manifests/grok.toml`: the
+ local `2026.07.16.2.1` patch gives idle OSC progress precedence over a
+ generic custom title, keeps explicit braille-spinner activity stronger, and
+ excludes the blank braille code point from that activity rule.
+- `bindings/examples/rust-agent-screen-detection/manifests/claude.toml`: the
+ upstream `2026.08.31.1` file removes background-shell activity as a working
+ signal, so an idle prompt or a permission blocker stays authoritative.
+- The plugin's `manifests/SHA256SUMS` record is checked before bundled
+ compilation to catch accidental drift. It is not a cryptographic release
+ signature for remote updates.
+
+The capability audit was rerun against herdr's agent-surface revision
+`987b070fbfa187e85009b45cd7e208fc6175ff6a`. Comparing `src/detect` with the
+manifest snapshot found the exact Pi bundled CLI path correction from
+`b1ff4582e9688f52ffb943cfa8bee4871ae122e4` and the Claude background-shell
+manifest correction from `987b070fbfa187e85009b45cd7e208fc6175ff6a`.
+The userland package ports and tests both. The `process.rs` adaptation covers
+both direct and `dist/bundle/cli.js` entrypoints and rejects lookalike scripts.
+The first-acquisition OSC retention fix in
+`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` is adapted in the userland tracker
+with a local revision fence. The foreground group-leader CWD fix in
+`3a3792622e59c7f2dc20f9c0236167161e4a5035` is already covered by the generic
+`foreground_cwd` resource, so no herdr-specific CWD policy is copied.
+
+The shell-render refactor in `207be3c771d281baae6e5fa0fb74be9a056e97a2` and
+independent multi-client tab views in
+`6c0bb273d5d5405a00985621b17e36f8b4d64609` are application/client architecture,
+not detector behavior. The latest delayed-agent-prompt fix in
+`8633a398e653eee47b375c963996c78a8a14aa48` changes PTY input sequencing, and
+`5616196942cbe752cc0659b9bd0fb616b2a6ed5c` hardens malformed Windows process
+environments in portable-pty. The later generic terminal-read fix
+`45484aab84430ac2b18c7bbf44aba15f2b039677`, graphics ownership fix
+`e22cba35ef7b405758097a5f9436aae8fb4caaf0`, Windows input fix
+`2ae8b91ca5919c26df7ce779b0e9a5dd98b769ae`, and sidebar-focus fix
+`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0` are also outside detector
+behavior. These changes are not copied. If cmux needs atomic text-plus-Enter
+submission, that belongs in a separate generic terminal-input contract, not in
+a detector or an agent-specific core method. A standalone release must define
+and test SDK endpoint-generation compatibility before it promises binary
+upgrades across host versions. Review the Windows changes before publishing a
+Windows package.
+
+The herdr repository tip checked on 2026-09-02 is
+`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`. The commits after the
+agent-surface revision change client rendering, terminal reads, graphics,
+Windows input and worktree handling, or sidebar focus. They do not change
+`src/detect` or the manifests. The agent-surface revision is therefore the
+reproducible capability-audit pin.
+
+Files that port herdr logic carry a header comment naming the upstream
+file and the modifications.
diff --git a/cmux-tui/bindings/cpp/.cmux-resource-api.json b/cmux-tui/bindings/cpp/.cmux-resource-api.json
index 4c4481343150..0667d105f967 100644
--- a/cmux-tui/bindings/cpp/.cmux-resource-api.json
+++ b/cmux-tui/bindings/cpp/.cmux-resource-api.json
@@ -1,5 +1,5 @@
{
- "catalog_sha256": "beef8293ded489648261ccddfd31b3f796f9d7d10506f13f6c5d1577a3f4fbac",
+ "catalog_sha256": "08a8190787e1b38d0592b85856d791fb0fe098d58eddce85ee98ecf88cc5a1a7",
"operations": {
"agent.list": {
"class": "read"
diff --git a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
index 5b34ffaeeba0..2587367cbdb3 100644
--- a/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
+++ b/cmux-tui/bindings/cpp/.cmux-sdk-manifest.json
@@ -12,17 +12,17 @@
},
{
"path": "include/cmux/raw/generated/models.hpp",
- "sha256": "ceda21218b242a23ba9dd8486a1ee504f4cc2b2a9f4a01a98bbd8969043225f1",
- "size": 137142
+ "sha256": "77b2d50beb4d6a01eb725ae65f9d2fb47893f9fec4e1ab6a619a61192c025c79",
+ "size": 137234
},
{
"path": "src/raw/generated/protocol.cpp",
- "sha256": "56dff6004761f347f5f3f401dec65ede9f0107daef022aa63b1985364861ebea",
- "size": 884368
+ "sha256": "19f2e36e6174211519bc3abbf6171d6be1b6c033dfa85f442fbb45907d667b32",
+ "size": 885900
}
],
"format": 1,
- "ir_sha256": "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0",
+ "ir_sha256": "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619",
"language": "cpp",
"mux_protocol": 12,
"schema_version": 2
diff --git a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
index 9cb6796e9058..892ac7cc41b7 100644
--- a/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
+++ b/cmux-tui/bindings/cpp/include/cmux/raw/generated/models.hpp
@@ -14,7 +14,7 @@
namespace cmux::raw {
inline constexpr std::uint32_t kMuxProtocolVersion = 12U;
-inline constexpr std::string_view kProtocolIrSha256 = "7042c629f34d3606581d07b2d2c03b65116c2467810724163c54674865825cc0";
+inline constexpr std::string_view kProtocolIrSha256 = "133bac0154f8f94aa30e40c11ff7ed38b10dd4d82974aec87c02d404fcd12619";
struct AgentRecord;
enum class AgentReportSource;
@@ -331,6 +331,7 @@ enum class ClientAttachedEventTransport;
enum class GraphicsStatusEventKind;
enum class AgentSource {
+ plugin,
detected,
socket,
hook,
@@ -350,6 +351,7 @@ struct Id {
};
struct AgentChangedEvent {
+ Field agent{};
std::optional session{};
AgentSource source{};
AgentState state{};
@@ -1851,6 +1853,7 @@ struct ProcessInfoResult {
std::optional command{};
std::optional cwd{};
Field foreground_cwd{};
+ Field foreground_executable{};
std::optional pid{};
friend bool operator==(const ProcessInfoResult&, const ProcessInfoResult&) = default;
};
diff --git a/cmux-tui/bindings/cpp/include/cmux/resource.hpp b/cmux-tui/bindings/cpp/include/cmux/resource.hpp
index 623d5995a985..19ed71bc6691 100644
--- a/cmux-tui/bindings/cpp/include/cmux/resource.hpp
+++ b/cmux-tui/bindings/cpp/include/cmux/resource.hpp
@@ -50,6 +50,9 @@ enum class Operation {
session_creation_resolve,
session_events,
session_journal_subscribe,
+ session_journal_producer_list,
+ session_journal_producer_put,
+ session_journal_append,
session_ping,
session_shutdown,
session_reload_config,
@@ -533,6 +536,69 @@ struct SessionJournalRecord {
std::optional previous_resource_revision;
};
+// Generic journal producer contracts. Agent plugins use these records from
+// userland; the daemon does not need a plugin-specific core type.
+struct JournalEventSchema {
+ std::string kind;
+ std::uint32_t schema_version = 0;
+ JournalClass class_ = JournalClass::state;
+ JournalReplayPolicy replay = JournalReplayPolicy::required;
+ JournalSensitivity sensitivity = JournalSensitivity::sensitive;
+ Json payload_schema;
+};
+
+struct JournalProducerManifest {
+ std::string producer_id;
+ std::string namespace_;
+ std::uint32_t manifest_version = 0;
+ JournalSensitivity max_sensitivity = JournalSensitivity::sensitive;
+ std::vector permissions;
+ std::vector events;
+
+ [[nodiscard]] Result to_json() const;
+};
+
+struct JournalIngress {
+ std::string producer_id;
+ std::uint32_t manifest_version = 0;
+ std::string kind;
+ std::uint32_t schema_version = 0;
+ std::optional occurred_at_ms;
+ std::vector subjects;
+ std::optional sensitivity;
+ Json payload;
+ std::optional causation_id;
+ std::optional correlation_id;
+
+ [[nodiscard]] Result to_json() const;
+};
+
+struct JournalProducerPutResult {
+ std::string producer_id;
+ std::uint32_t manifest_version = 0;
+ std::string namespace_;
+ std::uint64_t sequence = 0;
+ std::string event_id;
+};
+
+struct JournalProducerListResult {
+ std::vector producers;
+};
+
+struct JournalAppendResult {
+ std::string producer_id;
+ std::uint64_t sequence = 0;
+ std::string event_id;
+};
+
+// Compatibility aliases from the first agent-plugin preview.
+using AgentPluginEventSchema = JournalEventSchema;
+using AgentPluginManifest = JournalProducerManifest;
+using AgentPluginSubject = JournalSubject;
+using AgentPluginIngress = JournalIngress;
+using AgentPluginListResult = JournalProducerListResult;
+using JournalEventSubject = JournalSubject;
+
struct ConfirmationRequiredDetails {
std::string confirmation_token;
std::uint64_t revision = 0;
@@ -889,6 +955,7 @@ enum class AgentSource {
hook,
socket,
detected,
+ plugin,
};
enum class AgentReportSource {
@@ -1165,6 +1232,49 @@ struct TerminalScreenResult {
std::uint16_t cursor_col = 0;
bool cursor_visible = false;
Json::Object extra;
+ // Keep new metadata after the legacy aggregate fields. Existing callers
+ // can continue to initialize the original seven fields positionally via
+ // the compatibility constructor below.
+ std::optional revision;
+ std::optional osc_progress;
+
+ TerminalScreenResult() = default;
+
+ TerminalScreenResult(
+ std::string text_value,
+ std::uint16_t cols_value,
+ std::uint16_t rows_value,
+ std::uint16_t cursor_row_value,
+ std::uint16_t cursor_col_value,
+ bool cursor_visible_value,
+ Json::Object extra_value = {})
+ : text(std::move(text_value)),
+ cols(cols_value),
+ rows(rows_value),
+ cursor_row(cursor_row_value),
+ cursor_col(cursor_col_value),
+ cursor_visible(cursor_visible_value),
+ extra(std::move(extra_value)) {}
+
+ TerminalScreenResult(
+ std::string text_value,
+ std::uint16_t cols_value,
+ std::uint16_t rows_value,
+ std::uint16_t cursor_row_value,
+ std::uint16_t cursor_col_value,
+ bool cursor_visible_value,
+ Json::Object extra_value,
+ std::optional revision_value,
+ std::optional osc_progress_value)
+ : text(std::move(text_value)),
+ cols(cols_value),
+ rows(rows_value),
+ cursor_row(cursor_row_value),
+ cursor_col(cursor_col_value),
+ cursor_visible(cursor_visible_value),
+ extra(std::move(extra_value)),
+ revision(std::move(revision_value)),
+ osc_progress(std::move(osc_progress_value)) {}
};
struct TerminalStateResult {
@@ -1205,6 +1315,8 @@ struct ProcessInfoResult {
// omits the field.
std::optional foreground_cwd;
std::vector children;
+ // Executable path or name of the PTY foreground process-group leader.
+ std::optional foreground_executable;
};
struct CellPixelsResult {
@@ -1333,6 +1445,10 @@ CMUX_DECLARE_TYPED_DECODER(TerminalDefaultsSnapshot);
CMUX_DECLARE_TYPED_DECODER(PairingResolutionResult);
CMUX_DECLARE_TYPED_DECODER(PaneNeighborResult);
CMUX_DECLARE_TYPED_DECODER(TerminalScreenResult);
+CMUX_DECLARE_TYPED_DECODER(JournalProducerManifest);
+CMUX_DECLARE_TYPED_DECODER(JournalProducerListResult);
+CMUX_DECLARE_TYPED_DECODER(JournalProducerPutResult);
+CMUX_DECLARE_TYPED_DECODER(JournalAppendResult);
CMUX_DECLARE_TYPED_DECODER(TerminalStateResult);
CMUX_DECLARE_TYPED_DECODER(TerminalHistoryResult);
CMUX_DECLARE_TYPED_DECODER(TerminalWaitResult);
@@ -1933,6 +2049,24 @@ class Session final : public ResourceHandle {
[[nodiscard]] Result journal(
SessionJournalOptions options = {},
CallOptions call = {}) const;
+ [[nodiscard]] Result journal_producers() const;
+ [[nodiscard]] Result>
+ list_journal_producers() const;
+ [[nodiscard]] Result>
+ put_journal_producer(
+ JournalProducerManifest manifest,
+ MutationOptions mutation = MutationOptions::unique()) const;
+ [[nodiscard]] Result>
+ put_journal_producer_manifest(
+ JournalProducerManifest manifest,
+ MutationOptions mutation = MutationOptions::unique()) const;
+ [[nodiscard]] Result> append_journal(
+ JournalIngress event,
+ MutationOptions mutation = MutationOptions::unique()) const;
+ [[nodiscard]] Result>
+ append_journal_event(
+ JournalIngress event,
+ MutationOptions mutation = MutationOptions::unique()) const;
[[nodiscard]] Result> shutdown(
MutationOptions options = MutationOptions::unique()) const;
[[nodiscard]] Result> reload_config(
diff --git a/cmux-tui/bindings/cpp/src/journal_validation_internal.hpp b/cmux-tui/bindings/cpp/src/journal_validation_internal.hpp
new file mode 100644
index 000000000000..b3a1dec835ba
--- /dev/null
+++ b/cmux-tui/bindings/cpp/src/journal_validation_internal.hpp
@@ -0,0 +1,80 @@
+#pragma once
+
+#include
+#include
+#include
+
+#include "cmux/resource.hpp"
+
+namespace cmux::journal_detail {
+
+[[nodiscard]] inline bool valid_journal_class(JournalClass value) noexcept {
+ switch (value) {
+ case JournalClass::state:
+ case JournalClass::observation:
+ case JournalClass::effect:
+ case JournalClass::checkpoint:
+ return true;
+ }
+ return false;
+}
+
+[[nodiscard]] inline bool valid_journal_replay(
+ JournalReplayPolicy value) noexcept {
+ switch (value) {
+ case JournalReplayPolicy::required:
+ case JournalReplayPolicy::advisory:
+ case JournalReplayPolicy::never:
+ return true;
+ }
+ return false;
+}
+
+[[nodiscard]] inline bool valid_journal_sensitivity(
+ JournalSensitivity value) noexcept {
+ switch (value) {
+ case JournalSensitivity::public_:
+ case JournalSensitivity::metadata:
+ case JournalSensitivity::sensitive:
+ case JournalSensitivity::secret:
+ return true;
+ }
+ return false;
+}
+
+[[nodiscard]] inline unsigned sensitivity_rank(JournalSensitivity value) noexcept {
+ switch (value) {
+ case JournalSensitivity::public_: return 0;
+ case JournalSensitivity::metadata: return 1;
+ case JournalSensitivity::sensitive: return 2;
+ case JournalSensitivity::secret: return 3;
+ }
+ return 3;
+}
+
+[[nodiscard]] inline bool valid_component(std::string_view value) noexcept {
+ if (value.empty() || value.size() > 64 ||
+ !((value.front() >= 'a' && value.front() <= 'z') ||
+ (value.front() >= '0' && value.front() <= '9'))) {
+ return false;
+ }
+ return std::all_of(value.begin(), value.end(), [](char byte) {
+ return (byte >= 'a' && byte <= 'z') ||
+ (byte >= '0' && byte <= '9') || byte == '_' || byte == '-';
+ });
+}
+
+[[nodiscard]] inline bool valid_kind(std::string_view value) noexcept {
+ if (value.empty() || value.size() > 128) return false;
+ std::size_t start = 0;
+ while (start < value.size()) {
+ const auto dot = value.find('.', start);
+ const auto end = dot == std::string_view::npos ? value.size() : dot;
+ if (!valid_component(value.substr(start, end - start))) return false;
+ if (dot == std::string_view::npos) return true;
+ start = dot + 1;
+ }
+ return false;
+}
+
+} // namespace cmux::journal_detail
diff --git a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp
index c84af4974cf7..730cde40adcf 100644
--- a/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp
+++ b/cmux-tui/bindings/cpp/src/raw/generated/protocol.cpp
@@ -103,6 +103,7 @@ Result Codec::decode(const Json& value) {
Result Codec::encode(const AgentSource& value) {
switch (value) {
+ case AgentSource::plugin: return Json(std::string("plugin"));
case AgentSource::detected: return Json(std::string("detected"));
case AgentSource::socket: return Json(std::string("socket"));
case AgentSource::hook: return Json(std::string("hook"));
@@ -111,6 +112,7 @@ Result Codec::encode(const AgentSource& value) {
}
Result Codec::decode(const Json& value) {
+ if (value == Json(std::string("plugin"))) return AgentSource::plugin;
if (value == Json(std::string("detected"))) return AgentSource::detected;
if (value == Json(std::string("socket"))) return AgentSource::socket;
if (value == Json(std::string("hook"))) return AgentSource::hook;
@@ -3033,6 +3035,11 @@ Result Codec::encode(const ProcessInfoResult& value) {
if (!encoded) return std::move(encoded).error();
object.emplace("foreground_cwd", std::move(encoded).value());
}
+ if (!value.foreground_executable.is_absent()) {
+ auto encoded = encode_value(value.foreground_executable);
+ if (!encoded) return std::move(encoded).error();
+ object.emplace("foreground_executable", std::move(encoded).value());
+ }
if (value.pid) {
auto encoded = encode_value(*value.pid);
if (!encoded) return std::move(encoded).error();
@@ -3083,6 +3090,16 @@ Result Codec::decode(const Json& value) {
result.foreground_cwd = Field(std::move(decoded).value());
}
}
+ const Json* field_foreground_executable = value.find("foreground_executable");
+ if (field_foreground_executable) {
+ if (field_foreground_executable->is_null()) {
+ result.foreground_executable = Field::null();
+ } else {
+ auto decoded = decode_value(*field_foreground_executable);
+ if (!decoded) return std::move(decoded).error();
+ result.foreground_executable = Field(std::move(decoded).value());
+ }
+ }
const Json* field_pid = value.find("pid");
if (!field_pid) {
return make_error(ErrorCode::decode, "missing required field 'pid'");
@@ -13773,6 +13790,11 @@ Result Codec::encode(const AgentChangedEvent& value) {
(void)value;
Json::Object object;
object.emplace("event", Json(std::string("agent-changed")));
+ if (!value.agent.is_absent()) {
+ auto encoded = encode_value(value.agent);
+ if (!encoded) return std::move(encoded).error();
+ object.emplace("agent", std::move(encoded).value());
+ }
if (value.session) {
auto encoded = encode_value(*value.session);
if (!encoded) return std::move(encoded).error();
@@ -13799,6 +13821,16 @@ Result Codec::decode(const Json& value) {
auto source = value.as_object();
if (!source) return std::move(source).error();
AgentChangedEvent result{};
+ const Json* field_agent = value.find("agent");
+ if (field_agent) {
+ if (field_agent->is_null()) {
+ result.agent = Field::null();
+ } else {
+ auto decoded = decode_value(*field_agent);
+ if (!decoded) return std::move(decoded).error();
+ result.agent = Field(std::move(decoded).value());
+ }
+ }
const Json* field_session = value.find("session");
if (!field_session) {
return make_error(ErrorCode::decode, "missing required field 'session'");
diff --git a/cmux-tui/bindings/cpp/src/resource.cpp b/cmux-tui/bindings/cpp/src/resource.cpp
index 2fb3fede9f5c..851b32969310 100644
--- a/cmux-tui/bindings/cpp/src/resource.cpp
+++ b/cmux-tui/bindings/cpp/src/resource.cpp
@@ -11,6 +11,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -23,6 +24,7 @@
#endif
#include "socket_path_internal.hpp"
+#include "journal_validation_internal.hpp"
#if defined(__APPLE__)
#include
@@ -88,6 +90,9 @@ struct OperationInfo {
X(session_creation_resolve, "session.creation.resolve", read) \
X(session_events, "session.events", stream_open) \
X(session_journal_subscribe, "session.journal.subscribe", stream_open) \
+ X(session_journal_producer_list, "session.journal.producer.list", read) \
+ X(session_journal_producer_put, "session.journal.producer.put", mutation) \
+ X(session_journal_append, "session.journal.append", mutation) \
X(session_ping, "session.ping", read) \
X(session_shutdown, "session.shutdown", mutation) \
X(session_reload_config, "session.reload_config", mutation) \
@@ -609,6 +614,160 @@ void inject_routing(
});
}
+[[nodiscard]] const char* journal_class_wire(JournalClass value) noexcept {
+ switch (value) {
+ case JournalClass::state: return "state";
+ case JournalClass::observation: return "observation";
+ case JournalClass::effect: return "effect";
+ case JournalClass::checkpoint: return "checkpoint";
+ }
+ return "state";
+}
+
+[[nodiscard]] const char* journal_replay_wire(JournalReplayPolicy value) noexcept {
+ switch (value) {
+ case JournalReplayPolicy::required: return "required";
+ case JournalReplayPolicy::advisory: return "advisory";
+ case JournalReplayPolicy::never: return "never";
+ }
+ return "required";
+}
+
+[[nodiscard]] const char* journal_sensitivity_wire(
+ JournalSensitivity value) noexcept {
+ switch (value) {
+ case JournalSensitivity::public_: return "public";
+ case JournalSensitivity::metadata: return "metadata";
+ case JournalSensitivity::sensitive: return "sensitive";
+ case JournalSensitivity::secret: return "secret";
+ }
+ return "sensitive";
+}
+
+[[nodiscard]] Result validate_journal_manifest(
+ const JournalProducerManifest& manifest) {
+ if (!journal_detail::valid_component(manifest.producer_id)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "producer_id must match [a-z0-9][a-z0-9_-]* and contain at most 64 bytes");
+ }
+ if (manifest.namespace_ != "plugin." + manifest.producer_id) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal producer namespace must be plugin.");
+ }
+ if (manifest.manifest_version == 0 || manifest.events.empty() ||
+ manifest.events.size() > 64 || manifest.permissions.empty() ||
+ manifest.permissions.size() > 32) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "manifest_version must be positive, permissions must contain 1 to 32 entries, and events must contain 1 to 64 entries");
+ }
+ if (!journal_detail::valid_journal_sensitivity(manifest.max_sensitivity)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal producer manifest has an invalid max_sensitivity");
+ }
+ if (manifest.max_sensitivity == JournalSensitivity::secret) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "secret journal payload storage is unavailable");
+ }
+ const auto required_permission = "journal.append." + manifest.namespace_;
+ bool has_valid_permission = false;
+ for (const auto& permission : manifest.permissions) {
+ if (permission.empty() || permission.size() > 128) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal producer permissions must contain 1 to 128 bytes");
+ }
+ if (permission == required_permission) has_valid_permission = true;
+ }
+ if (!has_valid_permission) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal producer manifest requires its journal append permission");
+ }
+ std::set> identities;
+ const auto prefix = manifest.namespace_ + ".";
+ for (const auto& event : manifest.events) {
+ if (!journal_detail::valid_kind(event.kind) ||
+ !event.kind.starts_with(prefix)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event kind must be a dotted lowercase name inside the producer namespace");
+ }
+ if (event.schema_version == 0) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event schema_version must be positive");
+ }
+ if (!journal_detail::valid_journal_class(event.class_) ||
+ !journal_detail::valid_journal_replay(event.replay) ||
+ !journal_detail::valid_journal_sensitivity(event.sensitivity)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event schema contains an invalid enum value");
+ }
+ if (event.sensitivity == JournalSensitivity::secret ||
+ journal_detail::sensitivity_rank(event.sensitivity) >
+ journal_detail::sensitivity_rank(manifest.max_sensitivity)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event sensitivity exceeds producer authority");
+ }
+ if (!identities.emplace(event.kind, event.schema_version).second) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal producer declares a duplicate event schema");
+ }
+ }
+ return {};
+}
+
+[[nodiscard]] Result validate_journal_ingress(const JournalIngress& event) {
+ if (!journal_detail::valid_component(event.producer_id) ||
+ event.manifest_version == 0 || event.schema_version == 0 ||
+ !journal_detail::valid_kind(event.kind) ||
+ !event.kind.starts_with("plugin." + event.producer_id + ".")) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event envelope is invalid");
+ }
+ if (event.subjects.size() > 64) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event subjects must contain at most 64 entries");
+ }
+ for (const auto& subject : event.subjects) {
+ if (!journal_detail::valid_component(subject.kind) || subject.id.empty() ||
+ subject.id.size() > 512) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event subject is invalid");
+ }
+ }
+ for (const auto& identifier : {event.causation_id, event.correlation_id}) {
+ if (identifier && (identifier->empty() || identifier->size() > 128)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal correlation identifiers must contain 1 to 128 bytes");
+ }
+ }
+ if (event.sensitivity &&
+ !journal_detail::valid_journal_sensitivity(*event.sensitivity)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal event sensitivity is invalid");
+ }
+ if (event.sensitivity == JournalSensitivity::secret) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "secret journal payload storage is unavailable");
+ }
+ return {};
+}
+
[[nodiscard]] Result put_correlation_key(
Json::Object& params,
const std::optional& correlation_key) {
@@ -1030,6 +1189,18 @@ Result SessionJournalOptions::to_params() const {
ErrorCode::invalid_argument,
"journal cursor and start are mutually exclusive");
}
+ if (start && *start != JournalStart::tail &&
+ *start != JournalStart::beginning) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal start is invalid");
+ }
+ if (filter.max_sensitivity &&
+ !journal_detail::valid_journal_sensitivity(*filter.max_sensitivity)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal max_sensitivity is invalid");
+ }
if (filter.max_sensitivity == JournalSensitivity::secret) {
return make_error(
ErrorCode::invalid_argument,
@@ -1056,6 +1227,11 @@ Result SessionJournalOptions::to_params() const {
if (!filter.classes.empty()) {
Json::Array values;
for (const auto value : filter.classes) {
+ if (!journal_detail::valid_journal_class(value)) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal class filter contains an invalid enum value");
+ }
switch (value) {
case JournalClass::state: values.emplace_back("state"); break;
case JournalClass::observation: values.emplace_back("observation"); break;
@@ -1119,6 +1295,80 @@ Result SessionJournalOptions::to_params() const {
return params;
}
+Result JournalProducerManifest::to_json() const {
+ auto valid = validate_journal_manifest(*this);
+ if (!valid) return std::move(valid).error();
+
+ Json::Array permissions;
+ permissions.reserve(this->permissions.size());
+ for (const auto& permission : this->permissions) {
+ permissions.emplace_back(permission);
+ }
+ Json::Array events;
+ events.reserve(this->events.size());
+ for (const auto& event : this->events) {
+ events.emplace_back(Json::Object{
+ {"kind", Json(event.kind)},
+ {"schema_version", Json(static_cast(event.schema_version))},
+ {"class", Json(journal_class_wire(event.class_))},
+ {"replay", Json(journal_replay_wire(event.replay))},
+ {"sensitivity", Json(journal_sensitivity_wire(event.sensitivity))},
+ {"payload_schema", event.payload_schema},
+ });
+ }
+ Json result(Json::Object{
+ {"producer_id", Json(producer_id)},
+ {"namespace", Json(namespace_)},
+ {"manifest_version", Json(static_cast(manifest_version))},
+ {"max_sensitivity", Json(journal_sensitivity_wire(max_sensitivity))},
+ {"permissions", Json(std::move(permissions))},
+ {"events", Json(std::move(events))},
+ });
+ auto encoded = result.encode();
+ if (!encoded) return std::move(encoded).error();
+ if (encoded.value().size() > 1024U * 1024U) {
+ return make_error(
+ ErrorCode::invalid_argument,
+ "journal producer manifest exceeds 1048576 bytes");
+ }
+ return result;
+}
+
+Result JournalIngress::to_json() const {
+ auto valid = validate_journal_ingress(*this);
+ if (!valid) return std::move(valid).error();
+
+ Json::Array subjects;
+ subjects.reserve(this->subjects.size());
+ for (const auto& subject : this->subjects) {
+ subjects.emplace_back(Json::Object{
+ {"kind", Json(subject.kind)},
+ {"id", Json(subject.id)},
+ });
+ }
+ Json::Object result{
+ {"producer_id", Json(producer_id)},
+ {"manifest_version", Json(static_cast(manifest_version))},
+ {"kind", Json(kind)},
+ {"schema_version", Json(static_cast(schema_version))},
+ {"payload", payload},
+ };
+ if (!this->subjects.empty()) {
+ result.emplace("subjects", Json(std::move(subjects)));
+ }
+ if (occurred_at_ms) {
+ result.emplace("occurred_at_ms", Json(std::to_string(*occurred_at_ms)));
+ }
+ if (sensitivity) {
+ result.emplace(
+ "sensitivity",
+ Json(journal_sensitivity_wire(*sensitivity)));
+ }
+ if (causation_id) result.emplace("causation_id", Json(*causation_id));
+ if (correlation_id) result.emplace("correlation_id", Json(*correlation_id));
+ return Json(std::move(result));
+}
+
Result TerminalAttachOptions::to_params() const {
if (cols.has_value() != rows.has_value()) {
return make_error(
@@ -2173,6 +2423,51 @@ Result Session::journal(
return SessionJournalStream(std::move(stream).value());
}
+Result Session::journal_producers() const {
+ return read(Operation::session_journal_producer_list);
+}
+
+Result> Session::list_journal_producers() const {
+ auto result = journal_producers();
+ if (!result) return std::move(result).error();
+ return std::move(result).value().producers;
+}
+
+Result> Session::put_journal_producer(
+ JournalProducerManifest manifest,
+ MutationOptions mutation) const {
+ auto encoded = manifest.to_json();
+ if (!encoded) return std::move(encoded).error();
+ return mutate(
+ Operation::session_journal_producer_put,
+ Json::Object{{"manifest", std::move(encoded).value()}},
+ std::move(mutation));
+}
+
+Result>
+Session::put_journal_producer_manifest(
+ JournalProducerManifest manifest,
+ MutationOptions mutation) const {
+ return put_journal_producer(std::move(manifest), std::move(mutation));
+}
+
+Result> Session::append_journal(
+ JournalIngress event,
+ MutationOptions mutation) const {
+ auto encoded = event.to_json();
+ if (!encoded) return std::move(encoded).error();
+ return mutate(
+ Operation::session_journal_append,
+ Json::Object{{"event", std::move(encoded).value()}},
+ std::move(mutation));
+}
+
+Result> Session::append_journal_event(
+ JournalIngress event,
+ MutationOptions mutation) const {
+ return append_journal(std::move(event), std::move(mutation));
+}
+
Result> Session::shutdown(MutationOptions options) const {
return mutate(Operation::session_shutdown, {}, std::move(options));
}
diff --git a/cmux-tui/bindings/cpp/src/resource_models.cpp b/cmux-tui/bindings/cpp/src/resource_models.cpp
index 0d041943e4cc..5c329b0db68b 100644
--- a/cmux-tui/bindings/cpp/src/resource_models.cpp
+++ b/cmux-tui/bindings/cpp/src/resource_models.cpp
@@ -4,10 +4,12 @@
#include
#include
#include
+#include
#include
#include
#include "cmux/base64.hpp"
+#include "journal_validation_internal.hpp"
namespace cmux {
namespace {
@@ -16,6 +18,10 @@ struct DecodeFailure {
Error error;
};
+constexpr std::size_t MAX_JOURNAL_PRODUCER_PERMISSIONS = 32;
+constexpr std::size_t MAX_JOURNAL_PRODUCER_EVENTS = 64;
+constexpr std::size_t MAX_JOURNAL_PRODUCERS = 1'024;
+
[[noreturn]] void fail(std::string message) {
throw DecodeFailure(make_error(ErrorCode::decode, std::move(message)));
}
@@ -172,6 +178,29 @@ std::vector array_value(
return result;
}
+template
+std::vector bounded_array_value(
+ const Json& value,
+ std::string_view context,
+ std::size_t maximum,
+ Parser&& parser) {
+ auto array = value.as_array();
+ if (!array) {
+ fail(std::string(context) + " must be an array");
+ }
+ if (array.value()->size() > maximum) {
+ fail(
+ std::string(context) + " contains more than " +
+ std::to_string(maximum) + " entries");
+ }
+ std::vector result;
+ result.reserve(array.value()->size());
+ for (const auto& item : *array.value()) {
+ result.push_back(parser(item));
+ }
+ return result;
+}
+
std::optional optional_string(
const Json::Object& object,
std::string_view name,
@@ -208,6 +237,17 @@ std::optional optional_nullable_string(
return string_value(found->second, context);
}
+std::optional optional_nullable_decimal(
+ const Json::Object& object,
+ std::string_view name,
+ std::string_view context) {
+ const auto found = object.find(name);
+ if (found == object.end() || found->second.is_null()) {
+ return std::nullopt;
+ }
+ return decimal_value(found->second, context);
+}
+
template
std::optional optional_id_value(
const Json::Object& object,
@@ -1320,6 +1360,7 @@ AgentSnapshot parse_agent(const Json& value) {
{"hook", AgentSource::hook},
{"socket", AgentSource::socket},
{"detected", AgentSource::detected},
+ {"plugin", AgentSource::plugin},
},
"agent source"),
decimal_value(
@@ -1330,6 +1371,235 @@ AgentSnapshot parse_agent(const Json& value) {
};
}
+JournalSubject parse_journal_subject(const Json& value) {
+ const auto& object = exact_object(
+ value,
+ {"kind", "id"},
+ {"kind", "id"},
+ "journal subject");
+ auto kind = bounded_string(
+ field(object, "kind", "journal subject"),
+ "journal subject kind",
+ 1,
+ 64);
+ if (!journal_detail::valid_component(kind)) {
+ fail("journal subject kind must be a lowercase component");
+ }
+ return {
+ std::move(kind),
+ bounded_string(field(object, "id", "journal subject"), "journal subject id", 1, 512),
+ };
+}
+
+JournalEventSchema parse_journal_event_schema(const Json& value) {
+ const auto& object = exact_object(
+ value,
+ {"kind", "schema_version", "class", "replay", "sensitivity", "payload_schema"},
+ {"kind", "schema_version", "class", "replay", "sensitivity", "payload_schema"},
+ "journal event schema");
+ auto kind = bounded_string(
+ field(object, "kind", "journal event schema"),
+ "journal event kind",
+ 1,
+ 128);
+ if (!journal_detail::valid_kind(kind)) {
+ fail("journal event kind must be a dotted lowercase name");
+ }
+ return {
+ std::move(kind),
+ static_cast(uint_value(
+ field(object, "schema_version", "journal event schema"),
+ std::numeric_limits::max(),
+ "journal event schema_version",
+ true)),
+ enum_value(
+ field(object, "class", "journal event schema"),
+ {
+ {"state", JournalClass::state},
+ {"observation", JournalClass::observation},
+ {"effect", JournalClass::effect},
+ {"checkpoint", JournalClass::checkpoint},
+ },
+ "journal event class"),
+ enum_value(
+ field(object, "replay", "journal event schema"),
+ {
+ {"required", JournalReplayPolicy::required},
+ {"advisory", JournalReplayPolicy::advisory},
+ {"never", JournalReplayPolicy::never},
+ },
+ "journal event replay"),
+ enum_value(
+ field(object, "sensitivity", "journal event schema"),
+ {
+ {"public", JournalSensitivity::public_},
+ {"metadata", JournalSensitivity::metadata},
+ {"sensitive", JournalSensitivity::sensitive},
+ {"secret", JournalSensitivity::secret},
+ },
+ "journal event sensitivity"),
+ field(object, "payload_schema", "journal event schema"),
+ };
+}
+
+JournalProducerManifest parse_journal_producer_manifest(const Json& value) {
+ const auto& object = exact_object(
+ value,
+ {"producer_id", "namespace", "manifest_version", "max_sensitivity", "permissions", "events"},
+ {"producer_id", "namespace", "manifest_version", "max_sensitivity", "permissions", "events"},
+ "journal producer manifest");
+ auto permissions = bounded_array_value(
+ field(object, "permissions", "journal producer manifest"),
+ "journal producer permissions",
+ MAX_JOURNAL_PRODUCER_PERMISSIONS,
+ [](const Json& item) {
+ return bounded_string(item, "journal producer permission", 1, 128);
+ });
+ auto events = bounded_array_value(
+ field(object, "events", "journal producer manifest"),
+ "journal producer events",
+ MAX_JOURNAL_PRODUCER_EVENTS,
+ parse_journal_event_schema);
+ auto producer_id = bounded_string(
+ field(object, "producer_id", "journal producer manifest"),
+ "journal producer id",
+ 1,
+ 64);
+ if (!journal_detail::valid_component(producer_id)) {
+ fail("journal producer id must match the lowercase component grammar");
+ }
+ auto namespace_ = bounded_string(
+ field(object, "namespace", "journal producer manifest"),
+ "journal producer namespace",
+ 1,
+ 128);
+ JournalProducerManifest manifest{
+ std::move(producer_id),
+ std::move(namespace_),
+ static_cast(uint_value(
+ field(object, "manifest_version", "journal producer manifest"),
+ std::numeric_limits::max(),
+ "journal producer manifest_version",
+ true)),
+ enum_value(
+ field(object, "max_sensitivity", "journal producer manifest"),
+ {
+ {"public", JournalSensitivity::public_},
+ {"metadata", JournalSensitivity::metadata},
+ {"sensitive", JournalSensitivity::sensitive},
+ {"secret", JournalSensitivity::secret},
+ },
+ "journal producer max_sensitivity"),
+ std::move(permissions),
+ std::move(events),
+ };
+ if (manifest.permissions.empty() ||
+ manifest.events.empty()) {
+ fail("journal producer manifest has too many or too few entries");
+ }
+ const auto required_permission = "journal.append." + manifest.namespace_;
+ if (std::find(
+ manifest.permissions.begin(),
+ manifest.permissions.end(),
+ required_permission) == manifest.permissions.end()) {
+ fail("journal producer manifest is missing its append permission");
+ }
+ if (manifest.namespace_ != "plugin." + manifest.producer_id ||
+ manifest.max_sensitivity == JournalSensitivity::secret) {
+ fail("journal producer manifest has an invalid namespace or sensitivity");
+ }
+ const auto prefix = manifest.namespace_ + ".";
+ std::set> identities;
+ for (const auto& event : manifest.events) {
+ if (!journal_detail::valid_kind(event.kind) ||
+ !event.kind.starts_with(prefix) ||
+ !identities.emplace(event.kind, event.schema_version).second ||
+ event.sensitivity == JournalSensitivity::secret ||
+ journal_detail::sensitivity_rank(event.sensitivity) >
+ journal_detail::sensitivity_rank(manifest.max_sensitivity)) {
+ fail("journal producer manifest contains an invalid event schema");
+ }
+ }
+ auto encoded = value.encode();
+ if (!encoded) {
+ fail("journal producer manifest cannot be encoded");
+ }
+ if (encoded.value().size() > 1024U * 1024U) {
+ fail("journal producer manifest exceeds 1048576 bytes");
+ }
+ return manifest;
+}
+
+JournalProducerListResult parse_journal_producer_list(const Json& value) {
+ const auto& object = exact_object(
+ value,
+ {"producers"},
+ {"producers"},
+ "journal producer list result");
+ auto producers = bounded_array_value(
+ field(object, "producers", "journal producer list result"),
+ "journal producer list",
+ MAX_JOURNAL_PRODUCERS,
+ parse_journal_producer_manifest);
+ return {std::move(producers)};
+}
+
+JournalProducerPutResult parse_journal_producer_put(const Json& value) {
+ const auto& object = exact_object(
+ value,
+ {"producer_id", "manifest_version", "namespace", "sequence", "event_id"},
+ {"producer_id", "manifest_version", "namespace", "sequence", "event_id"},
+ "journal producer put result");
+ auto producer_id = bounded_string(
+ field(object, "producer_id", "journal producer put"),
+ "journal producer id",
+ 1,
+ 64);
+ if (!journal_detail::valid_component(producer_id)) {
+ fail("journal producer id must match the lowercase component grammar");
+ }
+ auto namespace_ = bounded_string(
+ field(object, "namespace", "journal producer put"),
+ "journal producer namespace",
+ 1,
+ 128);
+ if (namespace_ != "plugin." + producer_id) {
+ fail("journal producer namespace must equal plugin.");
+ }
+ return {
+ std::move(producer_id),
+ static_cast(uint_value(
+ field(object, "manifest_version", "journal producer put"),
+ std::numeric_limits::max(),
+ "journal producer manifest_version",
+ true)),
+ std::move(namespace_),
+ decimal_value(field(object, "sequence", "journal producer put"), "journal producer sequence"),
+ bounded_string(field(object, "event_id", "journal producer put"), "journal producer event_id", 1, 128),
+ };
+}
+
+JournalAppendResult parse_journal_append(const Json& value) {
+ const auto& object = exact_object(
+ value,
+ {"producer_id", "sequence", "event_id"},
+ {"producer_id", "sequence", "event_id"},
+ "journal append result");
+ auto producer_id = bounded_string(
+ field(object, "producer_id", "journal append"),
+ "journal producer id",
+ 1,
+ 64);
+ if (!journal_detail::valid_component(producer_id)) {
+ fail("journal producer id must match the lowercase component grammar");
+ }
+ return {
+ std::move(producer_id),
+ decimal_value(field(object, "sequence", "journal append"), "journal sequence"),
+ bounded_string(field(object, "event_id", "journal append"), "journal event_id", 1, 128),
+ };
+}
+
PairingRequestSnapshot parse_pairing(const Json& value) {
const auto& object = exact_object(
value,
@@ -1848,6 +2118,8 @@ TerminalScreenResult parse_terminal_screen(const Json& value) {
value,
{
"text",
+ "revision",
+ "osc_progress",
"cols",
"rows",
"cursor_row",
@@ -1864,7 +2136,11 @@ TerminalScreenResult parse_terminal_screen(const Json& value) {
"cursor_visible",
},
"terminal screen result");
- return {
+ const auto revision = optional_nullable_decimal(
+ object,
+ "revision",
+ "terminal screen revision");
+ return TerminalScreenResult{
string_value(field(object, "text", "terminal screen"), "screen text"),
static_cast(uint_value(
field(object, "cols", "terminal screen"),
@@ -1888,6 +2164,54 @@ TerminalScreenResult parse_terminal_screen(const Json& value) {
field(object, "cursor_visible", "terminal screen"),
"screen cursor_visible"),
extra_value(object, "terminal screen"),
+ revision,
+ optional_nullable_string(object, "osc_progress", "terminal screen osc_progress"),
+ };
+}
+
+ProcessInfoResult parse_process_info(const Json& value) {
+ const auto& object = exact_object(
+ value,
+ {
+ "pid",
+ "executable",
+ "argv",
+ "cwd",
+ "foreground_cwd",
+ "foreground_executable",
+ "children",
+ },
+ {"pid", "argv", "children"},
+ "process info result");
+ auto argv = array_value(
+ field(object, "argv", "process info result"),
+ "process argv",
+ [](const Json& item) { return string_value(item, "process argv item"); });
+ auto children = array_value(
+ field(object, "children", "process info result"),
+ "process children",
+ [](const Json& item) {
+ return static_cast(uint_value(
+ item,
+ std::numeric_limits::max(),
+ "process child",
+ true));
+ });
+ return {
+ static_cast(uint_value(
+ field(object, "pid", "process info result"),
+ std::numeric_limits::max(),
+ "process pid",
+ true)),
+ optional_string(object, "executable", "process executable"),
+ std::move(argv),
+ optional_string(object, "cwd", "process cwd"),
+ optional_nullable_string(object, "foreground_cwd", "process foreground cwd"),
+ std::move(children),
+ optional_nullable_string(
+ object,
+ "foreground_executable",
+ "process foreground executable"),
};
}
@@ -1973,39 +2297,6 @@ TerminalCopyResult parse_terminal_copy(const Json& value) {
};
}
-ProcessInfoResult parse_process_info(const Json& value) {
- const auto& object = exact_object(
- value,
- {"pid", "executable", "argv", "cwd", "foreground_cwd", "children"},
- {"pid", "argv", "children"},
- "process info result");
- return {
- static_cast(uint_value(
- field(object, "pid", "process info"),
- std::numeric_limits::max(),
- "process pid")),
- optional_string(object, "executable", "process executable"),
- array_value(
- field(object, "argv", "process info"),
- "process argv",
- [](const Json& item) {
- return string_value(item, "process argv item");
- }),
- optional_string(object, "cwd", "process cwd"),
- optional_nullable_string(
- object, "foreground_cwd", "process foreground_cwd"),
- array_value(
- field(object, "children", "process info"),
- "process children",
- [](const Json& item) {
- return static_cast(uint_value(
- item,
- std::numeric_limits::max(),
- "process child pid"));
- }),
- };
-}
-
RendererGrant parse_renderer_grant(const Json& value) {
const auto& object = exact_object(
value,
@@ -2456,6 +2747,10 @@ CMUX_DEFINE_DECODER(TerminalDefaultsSnapshot, parse_terminal_defaults)
CMUX_DEFINE_DECODER(PairingResolutionResult, parse_pairing_resolution)
CMUX_DEFINE_DECODER(PaneNeighborResult, parse_pane_neighbor)
CMUX_DEFINE_DECODER(TerminalScreenResult, parse_terminal_screen)
+CMUX_DEFINE_DECODER(JournalProducerManifest, parse_journal_producer_manifest)
+CMUX_DEFINE_DECODER(JournalProducerListResult, parse_journal_producer_list)
+CMUX_DEFINE_DECODER(JournalProducerPutResult, parse_journal_producer_put)
+CMUX_DEFINE_DECODER(JournalAppendResult, parse_journal_append)
CMUX_DEFINE_DECODER(TerminalStateResult, parse_terminal_state)
CMUX_DEFINE_DECODER(TerminalHistoryResult, parse_terminal_history)
CMUX_DEFINE_DECODER(TerminalWaitResult, parse_terminal_wait)
@@ -2694,18 +2989,7 @@ Result decode_session_journal_record(
array_value(
field(object, "subjects", "session journal record"),
"journal subjects",
- [](const Json& item) {
- const auto& subject = exact_object(
- item, {"kind", "id"}, {"kind", "id"}, "journal subject");
- return JournalSubject{
- bounded_string(
- field(subject, "kind", "journal subject"),
- "journal subject kind", 1, 128),
- bounded_string(
- field(subject, "id", "journal subject"),
- "journal subject id", 1, 512),
- };
- }),
+ parse_journal_subject),
enum_value(
field(object, "sensitivity", "session journal record"),
{
diff --git a/cmux-tui/bindings/cpp/tests/test_resource.cpp b/cmux-tui/bindings/cpp/tests/test_resource.cpp
index c210f60311e1..f5c0f805d43f 100644
--- a/cmux-tui/bindings/cpp/tests/test_resource.cpp
+++ b/cmux-tui/bindings/cpp/tests/test_resource.cpp
@@ -548,6 +548,292 @@ TEST("session auxiliary APIs emit typed notification and agent routes") {
CHECK(!report_params->contains("agent"));
}
+TEST("generic journal producer contracts stay userland and wire-compatible") {
+ auto manifest_wire = cmux::Json::parse(R"({
+ "producer_id":"screen-detector",
+ "namespace":"plugin.screen-detector",
+ "manifest_version":1,
+ "max_sensitivity":"sensitive",
+ "permissions":["journal.append.plugin.screen-detector"],
+ "events":[{
+ "kind":"plugin.screen-detector.state.changed",
+ "schema_version":1,
+ "class":"state",
+ "replay":"required",
+ "sensitivity":"sensitive",
+ "payload_schema":{"type":"object"}
+ }]
+ })");
+ CHECK(manifest_wire);
+ auto manifest = cmux::detail::decode_value(
+ manifest_wire.value());
+ CHECK(manifest);
+ CHECK_EQ(manifest.value().namespace_, "plugin.screen-detector");
+ CHECK_EQ(manifest.value().events.front().class_, cmux::JournalClass::state);
+
+ auto encoded = manifest.value().to_json();
+ CHECK(encoded);
+ CHECK(encoded.value().find("namespace") != nullptr);
+ CHECK(encoded.value().find("namespace_") == nullptr);
+ const auto* encoded_events =
+ encoded.value().find("events")->as_array().value();
+ CHECK_EQ(
+ encoded_events->front().find("class")->as_string().value(),
+ std::string_view("state"));
+
+ auto list_wire = cmux::Json::parse(
+ R"({"producers":[{"producer_id":"screen-detector","namespace":"plugin.screen-detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":["journal.append.plugin.screen-detector"],"events":[{"kind":"plugin.screen-detector.state.changed","schema_version":1,"class":"state","replay":"required","sensitivity":"sensitive","payload_schema":{"type":"object"}}]}]})");
+ CHECK(list_wire);
+ auto list = cmux::detail::decode_value(
+ list_wire.value());
+ CHECK(list);
+ CHECK_EQ(list.value().producers.size(), 1U);
+
+ auto put_wire = cmux::Json::parse(
+ R"({"producer_id":"screen-detector","manifest_version":1,"namespace":"plugin.screen-detector","sequence":"7","event_id":"evt-7"})");
+ CHECK(put_wire);
+ auto put = cmux::detail::decode_value(
+ put_wire.value());
+ CHECK(put);
+ CHECK_EQ(put.value().sequence, 7U);
+
+ auto append_wire = cmux::Json::parse(
+ R"({"producer_id":"screen-detector","sequence":"8","event_id":"evt-8"})");
+ CHECK(append_wire);
+ auto appended = cmux::detail::decode_value(
+ append_wire.value());
+ CHECK(appended);
+ CHECK_EQ(appended.value().sequence, 8U);
+
+ auto agent_wire = cmux::Json::parse(
+ R"({"id":"agent_11111111111111111111111111111111","session_id":"session_22222222222222222222222222222222","terminal_id":"term_33333333333333333333333333333333","state":"working","source":"plugin","updated_at_ms":"9","source_session":null})");
+ CHECK(agent_wire);
+ auto agent = cmux::detail::decode_value(
+ agent_wire.value());
+ CHECK(agent);
+ CHECK_EQ(agent.value().source, cmux::AgentSource::plugin);
+
+ auto screen_wire = cmux::Json::parse(
+ R"({"text":"ready","revision":"12","osc_progress":"4;1;50","cols":80,"rows":24,"cursor_row":1,"cursor_col":2,"cursor_visible":true})");
+ CHECK(screen_wire);
+ auto screen = cmux::detail::decode_value(
+ screen_wire.value());
+ CHECK(screen);
+ CHECK_EQ(screen.value().revision, std::optional(12));
+ CHECK_EQ(screen.value().osc_progress, std::optional("4;1;50"));
+
+ auto unavailable_screen_wire = cmux::Json::parse(
+ R"({"text":"unavailable","revision":null,"osc_progress":null,"cols":80,"rows":24,"cursor_row":0,"cursor_col":0,"cursor_visible":true})");
+ CHECK(unavailable_screen_wire);
+ auto unavailable_screen = cmux::detail::decode_value(
+ unavailable_screen_wire.value());
+ CHECK(unavailable_screen);
+ CHECK(!unavailable_screen.value().revision);
+ CHECK(!unavailable_screen.value().osc_progress);
+
+ cmux::JournalIngress invalid_ingress{
+ "screen-detector",
+ 1,
+ "agent.state.changed",
+ 1,
+ std::nullopt,
+ {},
+ std::nullopt,
+ cmux::Json(cmux::Json::Object{}),
+ std::nullopt,
+ std::nullopt};
+ auto invalid_ingress_json = invalid_ingress.to_json();
+ CHECK(!invalid_ingress_json);
+ CHECK_EQ(
+ invalid_ingress_json.error().code,
+ cmux::ErrorCode::invalid_argument);
+
+ // The decoder applies the same grammar and size limits as the outgoing
+ // producer contract. A malformed server response must not enter the SDK.
+ auto malformed_manifest_wire = cmux::Json::parse(
+ R"({"producer_id":"screen!detector","namespace":"plugin.screen!detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":["journal.append.plugin.screen!detector"],"events":[{"kind":"plugin.screen!detector.state.changed","schema_version":1,"class":"state","replay":"required","sensitivity":"sensitive","payload_schema":{}}]})");
+ CHECK(malformed_manifest_wire);
+ auto malformed_manifest =
+ cmux::detail::decode_value(
+ malformed_manifest_wire.value());
+ CHECK(!malformed_manifest);
+ CHECK_EQ(malformed_manifest.error().code, cmux::ErrorCode::decode);
+
+ auto malformed_put_wire = cmux::Json::parse(
+ R"({"producer_id":"screen!detector","manifest_version":1,"namespace":"plugin.screen!detector","sequence":"1","event_id":"event-1"})");
+ CHECK(malformed_put_wire);
+ auto malformed_put = cmux::detail::decode_value(
+ malformed_put_wire.value());
+ CHECK(!malformed_put);
+ CHECK_EQ(malformed_put.error().code, cmux::ErrorCode::decode);
+
+ auto malformed_append_wire = cmux::Json::parse(
+ R"({"producer_id":"screen!detector","sequence":"1","event_id":"event-1"})");
+ CHECK(malformed_append_wire);
+ auto malformed_append = cmux::detail::decode_value(
+ malformed_append_wire.value());
+ CHECK(!malformed_append);
+ CHECK_EQ(malformed_append.error().code, cmux::ErrorCode::decode);
+
+ cmux::TerminalScreenResult legacy_screen{
+ "legacy", 80, 24, 0, 0, true, {}};
+ CHECK_EQ(legacy_screen.cols, 80);
+ CHECK(!legacy_screen.revision);
+}
+
+TEST("journal subject decoder enforces lowercase component grammar") {
+ auto record_wire = cmux::Json::parse(R"({
+ "sequence":"1",
+ "event_id":"event-1",
+ "schema_version":1,
+ "kind":"plugin.screen-detector.agent.state.changed",
+ "class":"state",
+ "replay":"required",
+ "occurred_at_ms":"1",
+ "committed_at_ms":"2",
+ "producer":{"kind":"plugin","id":"screen-detector"},
+ "authority":null,
+ "causation_id":null,
+ "correlation_id":null,
+ "causation_depth":0,
+ "subjects":[{"kind":"Agent","id":"agent-1"}],
+ "sensitivity":"metadata",
+ "payload":{},
+ "resource_revision":null,
+ "previous_resource_revision":null
+ })");
+ CHECK(record_wire);
+
+ auto decoded = cmux::detail::decode_session_journal_record(
+ record_wire.value(), cmux::Cursor{"g", 1});
+ CHECK(!decoded);
+ CHECK_EQ(decoded.error().code, cmux::ErrorCode::decode);
+}
+
+TEST("journal producer decoders reject oversized arrays before item parsing") {
+ const auto repeated = [](std::string_view item, std::size_t count) {
+ std::string result = "[";
+ for (std::size_t index = 0; index < count; ++index) {
+ if (index != 0) result += ',';
+ result += item;
+ }
+ result += ']';
+ return result;
+ };
+ const std::string event =
+ R"({"kind":"plugin.screen-detector.state.changed","schema_version":1,"class":"state","replay":"required","sensitivity":"sensitive","payload_schema":{}})";
+ const std::string manifest =
+ R"({"producer_id":"screen-detector","namespace":"plugin.screen-detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":["journal.append.plugin.screen-detector"],"events":)";
+
+ // Put an invalid item first. The size guard must win before the decoder
+ // attempts to parse that item.
+ auto oversized_permissions = cmux::Json::parse(
+ R"({"producer_id":"screen-detector","namespace":"plugin.screen-detector","manifest_version":1,"max_sensitivity":"sensitive","permissions":[1,"journal.append.plugin.screen-detector"] ,"events":[]})");
+ CHECK(oversized_permissions);
+ auto permissions = oversized_permissions.value().find("permissions");
+ CHECK(permissions != nullptr);
+ auto permission_array = permissions->as_array();
+ CHECK(permission_array);
+ permission_array.value()->insert(
+ permission_array.value()->end(), 31, cmux::Json("journal.append.plugin.screen-detector"));
+ auto decoded_permissions = cmux::detail::decode_value(
+ oversized_permissions.value());
+ CHECK(!decoded_permissions);
+ CHECK(
+ decoded_permissions.error().message.find("more than 32") !=
+ std::string::npos);
+
+ const auto event_array = repeated(event, 64);
+ auto oversized_events = cmux::Json::parse(
+ manifest + "[1," + event_array.substr(1) + "}");
+ CHECK(oversized_events);
+ auto decoded_events = cmux::detail::decode_value(
+ oversized_events.value());
+ CHECK(!decoded_events);
+ CHECK(
+ decoded_events.error().message.find("more than 64") !=
+ std::string::npos);
+
+ const auto producer_array = repeated(manifest + event_array + "}", 1024);
+ auto oversized_producers = cmux::Json::parse(
+ "{\"producers\":[1," + producer_array.substr(1) + "}");
+ CHECK(oversized_producers);
+ auto decoded_producers = cmux::detail::decode_value(
+ oversized_producers.value());
+ CHECK(!decoded_producers);
+ CHECK(
+ decoded_producers.error().message.find("more than 1024") !=
+ std::string::npos);
+}
+
+TEST("journal encoders reject out-of-range enum values") {
+ const auto manifest = [] {
+ cmux::JournalProducerManifest value;
+ value.producer_id = "screen-detector";
+ value.namespace_ = "plugin.screen-detector";
+ value.manifest_version = 1;
+ value.max_sensitivity = cmux::JournalSensitivity::sensitive;
+ value.permissions = {"journal.append.plugin.screen-detector"};
+ value.events.push_back(cmux::JournalEventSchema{
+ "plugin.screen-detector.state.changed",
+ 1,
+ cmux::JournalClass::state,
+ cmux::JournalReplayPolicy::required,
+ cmux::JournalSensitivity::sensitive,
+ cmux::Json(cmux::Json::Object{}),
+ });
+ return value;
+ }();
+
+ auto invalid_class = manifest;
+ invalid_class.events.front().class_ =
+ static_cast(99);
+ CHECK(!invalid_class.to_json());
+
+ auto invalid_replay = manifest;
+ invalid_replay.events.front().replay =
+ static_cast(99);
+ CHECK(!invalid_replay.to_json());
+
+ auto invalid_event_sensitivity = manifest;
+ invalid_event_sensitivity.events.front().sensitivity =
+ static_cast(99);
+ CHECK(!invalid_event_sensitivity.to_json());
+
+ auto invalid_manifest_sensitivity = manifest;
+ invalid_manifest_sensitivity.max_sensitivity =
+ static_cast(99);
+ CHECK(!invalid_manifest_sensitivity.to_json());
+
+ cmux::JournalIngress ingress{
+ "screen-detector",
+ 1,
+ "plugin.screen-detector.state.changed",
+ 1,
+ std::nullopt,
+ {},
+ static_cast(99),
+ cmux::Json(cmux::Json::Object{}),
+ std::nullopt,
+ std::nullopt,
+ };
+ CHECK(!ingress.to_json());
+
+ cmux::SessionJournalOptions invalid_filter;
+ invalid_filter.filter.classes.push_back(
+ static_cast(99));
+ CHECK(!invalid_filter.to_params());
+
+ invalid_filter = {};
+ invalid_filter.filter.max_sensitivity =
+ static_cast(99);
+ CHECK(!invalid_filter.to_params());
+
+ invalid_filter = {};
+ invalid_filter.start = static_cast(99);
+ CHECK(!invalid_filter.to_params());
+}
+
TEST("session auxiliary options reject invalid values before I/O") {
auto state = std::make_shared();
auto client = client_for(state);
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md b/cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md
new file mode 100644
index 000000000000..2f657445f2d0
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/ATTRIBUTIONS.md
@@ -0,0 +1,122 @@
+# Attributions
+
+The files under `manifests/` are derived from the herdr project:
+
+* Project: https://github.com/herdrdev/herdr
+* Detector source reference revision: `7b675f42af35508eab66ac42fe1598628597a893`
+* Pi bundled-launcher correction: `b1ff4582e9688f52ffb943cfa8bee4871ae122e4`
+* Manifest snapshot revision: `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`
+* First-acquisition OSC retention: `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605`
+* Included manifest fixes: Claude MCP elicitation `f807b697353cfa00aa912c7cde4830e863001cf5`,
+ Claude background-shell state `987b070fbfa187e85009b45cd7e208fc6175ff6a`,
+ Codex weak-blocker scope `f457cff4f2648eee85d176f8a41861241d4e8428`, and
+ Copilot background-agent activity `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`.
+* License: Apache-2.0, reproduced in `manifests/LICENSE`
+* The bundled manifests are refreshed from Herdr `master` at the checked
+ revision above. This includes the current Letta, Claude, Codex, Kiro, Cline,
+ Pi, and Grok rules. `github-copilot.toml` remains byte-identical to the
+ upstream snapshot at commit `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`.
+* The latest upstream Grok manifest includes the custom-title and spinner
+ precedence fix, so cmux no longer carries a divergent local Grok patch.
+* Changes: cmux pins the files locally and validates them with its own
+ bounded manifest engine. It does not use herdr's network update path.
+
+The checked-in manifests/SHA256SUMS record is verified before bundled
+compilation. It detects accidental drift, not a cryptographic release
+signature for remote updates.
+
+The attribution and capability audit was rerun against herdr's agent-surface
+revision `987b070fbfa187e85009b45cd7e208fc6175ff6a` after the pinned snapshot.
+The package adapts and tests the exact Pi bundled CLI path correction in
+`b1ff4582e9688f52ffb943cfa8bee4871ae122e4`. It also vendors and tests the
+Claude background-shell state correction in
+`987b070fbfa187e85009b45cd7e208fc6175ff6a`. The audit found the
+first-acquisition OSC retention fix in
+`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605`; `src/detect.rs` ports that policy
+with a local revision fence because the generic host API cannot clear OSC
+state. It also found foreground group-leader CWD selection in
+`3a3792622e59c7f2dc20f9c0236167161e4a5035`; cmux's generic
+`foreground_cwd` resource already resolves the group leader, so no
+herdr-specific CWD code is copied. Later upstream commits
+`207be3c771d281baae6e5fa0fb74be9a056e97a2`,
+`5158adab10b6dcfea9370782043392f80fa0643c`,
+`5616196942cbe752cc0659b9bd0fb616b2a6ed5c`,
+`da8c7b05f9ef7898cfb7494989df8a533b947bb9`, `99c23cd1ea7468bd3661f6483c7105396503b417`,
+`0032c3b42751b6da9c5b1a91546b3c1a425d67f1`,
+`18e69891dca486d669a584facd80644bb51f54a2`,
+`45484aab84430ac2b18c7bbf44aba15f2b039677`,
+`e22cba35ef7b405758097a5f9436aae8fb4caaf0`,
+`2ae8b91ca5919c26df7ce779b0e9a5dd98b769ae`, and
+`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0` change Windows launch, process
+environment, process-job, input handling, recent terminal reads, graphics
+ownership, or the application/client shell rendering architecture. The
+post-audit multi-client tab-view change
+`6c0bb273d5d5405a00985621b17e36f8b4d64609` and the reliable delayed-prompt
+change `8633a398e653eee47b375c963996c78a8a14aa48` change host/client and PTY
+input behavior, not this detector. These changes are not detector logic and
+are not copied. This package has no Windows SDK transport, native process
+backend, launch path, or input path, so those files are not copied. A
+standalone release must define and test SDK endpoint-generation compatibility
+before it promises upgrades across host versions. Recheck these upstream
+areas before publishing a Windows package.
+
+The herdr repository tip checked on 2026-09-02 is
+`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`. The commits after the
+agent-surface revision change client rendering, terminal reads, graphics,
+Windows input and worktree handling, or sidebar focus. They do not change
+`src/detect` or the manifests. The agent-surface revision is the reproducible
+capability-audit pin.
+
+The original cmux portions of this package are licensed under MIT. The full
+text is in `LICENSE-MIT`. The Apache-2.0 text for the derived herdr material is
+in `manifests/LICENSE`.
+
+The detector engine in `src/manifest.rs` is adapted from herdr's
+`src/detect/manifest.rs` semantics. It keeps the attribution above and adds
+bounded recursion, case-normalized process aliases, and a public plugin
+boundary. Its Claude background-shell regression fixtures are adapted from
+herdr's `src/detect/manifest/tests.rs` at
+`987b070fbfa187e85009b45cd7e208fc6175ff6a`.
+
+The package does not copy herdr's application, API server, sound assets, or
+other multiplexer code. Only the listed detector files and manifests contain
+derived herdr material.
+
+`src/process.rs` adapts herdr's `src/platform/{linux,macos}.rs` and
+`src/detect/mod.rs` foreground process-group and wrapper discovery. It adds
+bounded traversal and `/proc` streaming, safer path candidates, attached
+runtime-mode parsing, positional-argument boundaries, direct shell-script and
+shell-word parsing, runtime-specific shell invocation-mode checks, Python
+boolean/exit/value option boundaries, attached-versus-separate option handling,
+and an explicit Linux child-group fallback. The Python option distinctions are
+a local correctness improvement:
+`-S` does not consume the script, documented help aliases (`-?`, `-VV`)
+terminate, and help/version/hash options cannot expose following tokens as
+agent executables. Unsupported attached long options fail closed before they
+can consume a later runtime mode flag. Its strict Pi package-entrypoint check
+includes herdr's Windows fix
+from commit `b1ff4582e9688f52ffb943cfa8bee4871ae122e4`; the check is adapted to
+the replaceable manifest catalog. The reference package targets macOS and
+Linux because its Rust SDK transport is Unix-only. A Windows publication needs
+a Windows-capable SDK transport and process backend; it must not claim a
+public-process fallback.
+
+Local hardening also validates the complete numeric Muse binary version,
+rejects empty matchers before they can match every screen, and excludes the
+Unicode BRAILLE PATTERN BLANK from Grok's spinner rule. These are cmux-owned
+changes, not copied herdr material.
+
+`src/detect.rs` adapts herdr's `src/detect/mod.rs` and
+`src/pane/agent_detection.rs` debounce, identity-edge, miss-confirmation, and
+flowing-output signals. The one-second max-evaluation pacer, deterministic
+activity-expiry debt, and same-name process-group replacement edge are
+manaflow changes. Herdr's first-acquisition OSC retention fix from
+`82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` is adapted as a local
+output-revision fence for replacement agents; it keeps that generic host
+metadata from being attributed across an agent identity edge while preserving
+evidence emitted before the first process probe.
+
+`src/manifest_update.rs` follows herdr's `src/detect/manifest_update.rs`
+versioned update and status concepts.
+Its explicit-only network policy, HTTPS checks, response bounds, independent
+per-agent failures, and atomic cache writes are manaflow changes.
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.lock b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.lock
new file mode 100644
index 000000000000..70522490839f
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.lock
@@ -0,0 +1,365 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cmux-agent-screen-detection"
+version = "0.1.0"
+dependencies = [
+ "cmux-sdk",
+ "libc",
+ "regex",
+ "serde",
+ "serde_json",
+ "sha2",
+ "toml",
+]
+
+[[package]]
+name = "cmux-sdk"
+version = "1.0.0"
+dependencies = [
+ "base64",
+ "getrandom",
+ "libc",
+ "serde",
+ "serde_json",
+ "sha2",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "indexmap"
+version = "2.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml
new file mode 100644
index 000000000000..36b5dddc589d
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "cmux-agent-screen-detection"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.88"
+# The package contains manaflow code under MIT and detector material adapted
+# from herdr under Apache-2.0. Keep both obligations visible to packagers;
+# license texts are LICENSE-MIT and manifests/LICENSE.
+license = "MIT AND Apache-2.0"
+publish = false
+
+[dependencies]
+cmux-sdk = { version = "=1.0.0", path = "../../rust" }
+libc = "0.2"
+regex = "1"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+sha2 = "0.10"
+toml = "0.8"
+
+# This reference package is intentionally independent from the cmux-tui
+# workspace. The version is the public SDK contract. The path resolves the
+# unreleased SDK in this source tree; a standalone checkout must keep a matching
+# SDK checkout at this path or remove `path` after the SDK release.
+[workspace]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT b/cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT
new file mode 100644
index 000000000000..607fd67729a7
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/LICENSE-MIT
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024-present Manaflow, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/README.md b/cmux-tui/bindings/examples/rust-agent-screen-detection/README.md
new file mode 100644
index 000000000000..7a9ac4414fc5
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/README.md
@@ -0,0 +1,154 @@
+# cmux agent screen-detection plugin
+
+This is a userland reference plugin. cmux core starts and supervises the
+process, but this package owns process identification, terminal sampling,
+screen rules, pacing, and the herdr-derived manifests.
+
+Publish this directory as the root of its own Git repository, then install that
+repository with:
+
+```text
+cmux agent plugin install
+cmux agent plugin use agent-screen-detection
+```
+
+This reference package is shipped beside the cmux-tui artifact by the build
+workflow. An external package can publish the same manifest and run contract
+without changing the daemon.
+
+The source tree is kept in the cmux repository as a reference package. The
+plugin manager expects `cmux-plugin.toml` at the root of the repository that it
+clones, so the parent cmux repository is not a valid install URL for this
+example.
+
+The plugin uses the public Rust SDK. This source-tree reference pins the
+matching `cmux-sdk` release as its contract and uses a path dependency while
+that SDK is unreleased in this checkout. A standalone plugin repository must
+either keep a matching SDK checkout at the same relative path or remove the
+`path` field after the SDK release is available. Its build command uses Cargo's
+`--locked` mode, so installation does not rewrite the checked-in dependency
+graph. It registers a namespaced journal
+producer, reads terminal process metadata and viewport text, and appends
+`cmux.agent-plugin.v1` events. A different implementation can use Python,
+another language, or a different ruleset without a cmux core change.
+
+The package also provides a read-only live diagnostic that follows the same
+identity and manifest path as the scanner:
+
+```text
+CMUX_TUI_SOCKET=/tmp/cmux-debug-demo.sock \
+CMUX_TUI_SESSION_ID=demo \
+./cmux-agent-screen-detection explain --live term_0123456789abcdef0123456789abcdef
+```
+
+The target is an exact terminal ID or an exact terminal title. Duplicate titles
+are rejected and the error lists the IDs to use. The command returns the
+matched rule, evaluated evidence, process identity source, screen revision,
+and manifest provenance. It never writes to the journal or terminal. Its
+one-shot OSC metadata freshness is reported as `one_shot_unknown`; the
+continuous scanner applies the stronger revision fence between process edges.
+
+The supervisor must provide a `CMUX_PLUGIN_ID` that matches
+`[a-z0-9][a-z0-9_-]*`, is at most 64 bytes, and is not `cmux_agent`. The
+executable exits before connecting when that namespace is absent or invalid; it
+never invents a shared producer ID. The manager generates and persists this
+value for the installed package, while a hand-written configuration must set
+`agents.plugin.id` explicitly.
+
+Process identity uses executable and wrapper arguments before reading
+`CMUX_AGENT` or `HERDR_AGENT` from the host process environment. The hint is a
+fallback for wrappers that hide their executable, which keeps normal scans
+cheap and avoids treating a globally inherited hint as stronger than visible
+process evidence. Runtime parsing handles attached eval and module flags and
+stops at the first positional script. Shell parsing handles direct script
+arguments and escaped command words. Command flags follow the grammar of the
+specific shell, including fish's separate and inline `--command` forms, while
+value-taking, no-exec, exit-only, and unknown shell modes fail closed. For
+runtimes that document an attached form, the option value stays with its
+option, so it cannot hide the following script. Unsupported spellings fail
+closed. A package-shaped path inside eval text cannot claim an agent identity.
+
+When cmux supervises the process, the scanner copies
+`CMUX_PLUGIN_GENERATION` into each event. This lets the core retire an old
+process generation without removing observations from a replacement process.
+
+The manifests are derived from herdr at manifest snapshot commit
+`2290257acb2085ce6842ba5c7e3ca50c3ba64f02` under Apache-2.0. The adapted
+detector engine follows source reference commit
+`7b675f42af35508eab66ac42fe1598628597a893`. The Claude manifest includes the
+upstream background-shell correction from
+`987b070fbfa187e85009b45cd7e208fc6175ff6a`. The Copilot manifest includes
+the upstream background-agent rule at version `2026.08.29.1` from the pinned
+snapshot. See
+`manifests/LICENSE`, `manifests/README.md`, and `ATTRIBUTIONS.md`. The
+Manaflow portions use MIT; the package includes that text in `LICENSE-MIT`.
+The checked-in `manifests/SHA256SUMS` record is verified before the bundled
+rules compile. It catches accidental edits to vendored bytes. It is not a
+release signature, so an explicit remote update still needs signed catalog
+verification before remote content is trusted.
+
+The host gives each plugin generation an owned process boundary. Keep any
+helper processes in the inherited Unix process group, or they may outlive the
+plugin if they call `setsid`.
+
+The generation fence protects journal state when a stopped process writes late.
+It does not remove the normal Unix process-group identifier reuse race, so the
+host treats process identity as authoritative only when the platform reports a
+current foreground group. The scanner commits an edge only after journal
+admission. A transport result with an uncertain outcome keeps the exact event
+envelope and idempotency key, then retries it with bounded backoff; a definite
+admission failure rolls the in-memory edge back so a later scan can try again.
+A userland plugin must use its own generation and idempotency keys for every
+event.
+
+The reference package currently targets macOS and Linux. Its Rust SDK
+transport is Unix-only, and its native process backends cover macOS and Linux.
+A Windows publication needs a Windows-capable SDK transport and process
+backend. Do not list Windows in `cmux-plugin.toml` until those pieces exist.
+
+Manifest loading is bounded before parsing: a set can contain at most 256
+active manifests, a cache or override directory can contain at most 512
+entries, and each manifest is limited to 256 KiB. Rule and matcher limits are
+also enforced by the manifest validator.
+
+The selected plugin configuration is limited to 4 MiB and registry metadata to
+16 KiB before JSON parsing. On Linux, process files are streamed through a
+128 KiB limit before parsing; an oversized file fails closed and
+leaves name-based detection available when possible.
+
+The plugin manager stages the artifact and selected configuration with a local
+rollback guard. They are separate filesystem transactions, so a power loss
+between the two writes can leave a mismatched old/new pair. Startup validation
+and a later explicit update repair that state.
+
+The manager bounds one installed-plugin root to 256 filesystem entries. This
+includes hidden transaction files and registry metadata, so stale install debris
+cannot turn a list or selector operation into an unbounded scan. Remove stale
+entries before retrying an operation that reports this limit.
+
+Git install and update sources are passed to `git` as process arguments. The
+manager rejects HTTP and HTTPS user information, query strings, and fragments
+to keep passwords and tokens out of process listings. Use a Git credential
+helper or an SSH key for private repositories. SSH user names, SCP-like sources,
+and local paths remain supported. Git metadata output is capped at 16 KiB before
+the manager parses it; overflow is treated as unavailable.
+
+The daemon keeps OSC title and progress as generic terminal metadata and may
+retain them across a process change. The scanner records the output revision at
+each identity edge and ignores those fields until a later revision proves that
+the new process produced output. Older daemons that never expose revisions use
+the startup-grace compatibility path. If a host has supplied a generation
+anchor and later omits its revision, the scanner fails closed until a newer
+revision is available. A local screen hash may schedule a read when the host
+does not expose a revision, but it is never used as a generation fence. Exit
+fencing uses only the host revision supplied for that exit; an exit without an
+anchor keeps the old-host compatibility path rather than comparing unrelated
+tokens.
+
+On Linux, hosts that do not expose a controlling-terminal foreground group can
+opt in to herdr-compatible child-group inference with
+`CMUX_AGENT_PROCESS_DETECTION=child-groups` (the legacy
+`HERDR_PROCESS_DETECTION=child-groups` name is also accepted). The mode picks
+the newest direct child process group and is disabled by default because the
+kernel cannot prove which child is foreground in that situation. The scanner
+fails closed after 64 direct-child probes.
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/cmux-plugin.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/cmux-plugin.toml
new file mode 100644
index 000000000000..5f9ceac7c5f8
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/cmux-plugin.toml
@@ -0,0 +1,12 @@
+[plugin]
+name = "agent-screen-detection"
+kind = "agent"
+version = "0.1.0"
+description = "Screen-based lifecycle detection for terminal coding agents"
+platforms = ["macos", "linux"]
+
+[run]
+command = ["target/release/cmux-agent-screen-detection"]
+
+[build]
+command = ["cargo", "build", "--release", "--locked"]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE
new file mode 100644
index 000000000000..261eeb9e9f8b
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/README.md b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/README.md
new file mode 100644
index 000000000000..21a2ce8097d4
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/README.md
@@ -0,0 +1,35 @@
+# herdr agent-detection manifests
+
+Nineteen of these TOML files are unchanged from
+https://github.com/herdrdev/herdr (Apache-2.0, see LICENSE), at manifest
+snapshot commit `2290257acb2085ce6842ba5c7e3ca50c3ba64f02`, path
+`src/detect/manifests/`. `claude.toml` is byte-identical to upstream commit
+`987b070fbfa187e85009b45cd7e208fc6175ff6a`, which stops a background shell
+from masking an idle prompt or a permission blocker. `grok.toml` is based on
+the snapshot file and carries one cmux correction: idle OSC progress wins over
+a generic custom title, while an explicit spinner still wins over retained idle metadata.
+The local patch version is `2026.07.16.2.1`. `github-copilot.toml` is
+byte-identical to the upstream snapshot, including its background-agent
+waiting row, at version `2026.08.29.1`. The cmux package adapts their
+semantics in the separately attributed Rust engine. Do not fetch herdr's
+manifest update endpoint. Refresh the 19 unchanged files from the exact
+snapshot commit, take Claude from its stated upstream correction commit, and
+reapply the Grok local correction when changing this pin.
+
+SHA256SUMS records the bytes embedded by the plugin. The provenance test
+checks this record before the bundled set is compiled, so an accidental edit
+cannot silently change a vendored rule. The record is not a release signature:
+remote updates still need authenticated, signed catalog data before they can be
+treated as trusted.
+
+The capability audit was rerun against herdr's agent-surface revision
+`987b070fbfa187e85009b45cd7e208fc6175ff6a`. It found the exact Pi bundled CLI
+path correction from `b1ff4582e9688f52ffb943cfa8bee4871ae122e4` and the Claude
+background-shell manifest correction from `987b070fbfa187e85009b45cd7e208fc6175ff6a`.
+These two corrections are ported and tested. The multi-client tab-view, delayed-prompt,
+recent-read, graphics, Windows input, and sidebar-focus commits are host/client
+work outside this manifest package. The package does not claim parity with
+that transport or input work. The repository tip checked on 2026-09-02 is
+`94f6d9c0d9bb9cf9ffae99d8bbfb09e9bf2fc9e0`; commits after the agent-surface
+revision do not change `src/detect` or the manifests. The agent-surface
+revision is the reproducible capability-audit pin.
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/SHA256SUMS b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/SHA256SUMS
new file mode 100644
index 000000000000..539ff9856d6c
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/SHA256SUMS
@@ -0,0 +1,22 @@
+b5806b0dd21e2f5e752d0eac7f084d5ce2e3f275638cfd1234c42cc88152dc8c amp.toml
+11300b853130d037eb2c57d9c4b897893cc1f0a876e12eb54ff1b216177db9d7 antigravity.toml
+038d0aa23fee3f9b39cb3c9ca117d0f95b0b3a5873cf0f38284ccbac279c9664 claude.toml
+75fe33ec735c59638da8d62e16bddd257d9959e692edb83b1f11b7f28057866a cline.toml
+bbac3e4a3d65d8d8440bd8436bed217bbbba077b22843786ada633661f4e0866 codex.toml
+753b1f7f632d42fa21139c2767ecbb5e1078748aba2e59407ac4932d3ce36ad7 cursor.toml
+250c9cea1d60bdb965dc6056f3066b785e941d60242756fbaca73b57ca6b0f85 devin.toml
+d37e7c464177c0e8f3edce8d4fabc4bcc7a1874edf2c4c87a2f927888cf69ce9 droid.toml
+d7013b5e772852ecc595febf964f00b4f9edcbc6047a2a5421613a154d92520d gemini.toml
+b70c652584326a1a98475a5fcef16207dee9f23b65f8e78300f4fc2bb578cb11 github-copilot.toml
+0f31b111144900b02f303577d27587f72d58d8c505185a682bd7887f822316ee grok.toml
+533d21b65dea3a0c60c25d0475c9c900d28a5712b6392669a7788f75de2b6e85 hermes.toml
+70f0ba4e58bc141fe69d7024013f973cd16e8616393079318914d70afeefef3b kilo.toml
+ede08c0d2d5024f7606dc0a1b2f7a9c6a0ebb99f3b6c58ca6757049856d06e05 kimi.toml
+c8990f3c9d4810995be97e8df29836411e37d90f6ac9d9303f505787b504806b kiro.toml
+205b8c135584c86f9f529aa2d061109b5129085cc5c3124c6dbfe0e78fcdba43 letta.toml
+3b392170ee3082051266509f575a4640bde8d693b2f37ecf52157a641bc75b28 maki.toml
+b69c4d87fa9c19e3e6453b706fbe39c98a8b33ffbaa48e8cd5ae6751e9615074 muse.toml
+faa82aed2d76ad856528caae04232894594b74cc903aed3e522b3e725c5f2c95 opencode.toml
+57469b82e4239bf93559ed5d400c9d9f86a9e64d5def728b7d4bb398be7659ca pi.toml
+2089f70fc78c6576fd7128a00f4e7eedb85be81bde9ed73301b3b88000048961 qodercli.toml
+b27aa456af228e8a4ceac74f0dd431c33b21473b69314fc672a7fba474e2a7fe qwen.toml
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/amp.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/amp.toml
new file mode 100644
index 000000000000..1fc5dfa6bbab
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/amp.toml
@@ -0,0 +1,65 @@
+id = "amp"
+version = "2026.07.09.1"
+min_engine_version = 2
+updated_at = "2026-07-09T00:00:00Z"
+aliases = ["amp-local"]
+
+[[rules]]
+id = "osc_title_plugin_confirmation_blocked"
+state = "blocked"
+priority = 1100
+region = "osc_title"
+visible_blocker = true
+contains = ["Plugin confirmation needed"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1050
+region = "osc_title"
+visible_working = true
+regex = ['^[\x{2800}-\x{28FF}] ']
+
+[[rules]]
+id = "approval_footer"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["waiting for approval"] },
+ { contains = ["invoke tool"] },
+ { contains = ["run this command?"] },
+ { contains = ["allow editing file:"] },
+ { contains = ["allow creating file:"] },
+ { contains = ["confirm tool call"] },
+ { contains = ["approve"], any = [{ contains = ["allow all for this session"] }, { contains = ["allow all for every session"] }, { contains = ["allow file for every session"] }, { contains = ["deny with feedback"] }] },
+]
+
+[[rules]]
+id = "status_footer_working"
+state = "working"
+priority = 200
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = ['(?i)^\s*╰\s+\S+\s+(thinking|streaming|running tools|waiting)\s+─']
+
+[[rules]]
+id = "esc_cancel_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc to cancel"]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 50
+region = "osc_title"
+visible_idle = true
+contains = [" - amp - "]
+not = [
+ { regex = ['^[\x{2800}-\x{28FF}] '] },
+ { contains = ["Plugin confirmation needed"] },
+]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/antigravity.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/antigravity.toml
new file mode 100644
index 000000000000..8b9bcfcf4aa5
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/antigravity.toml
@@ -0,0 +1,33 @@
+id = "agy"
+version = "2026.06.24.1"
+min_engine_version = 1
+updated_at = "2026-06-24T00:00:00Z"
+aliases = ["antigravity", "antigravity-cli"]
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+contains = ["requesting permission for:"]
+any = [
+ { contains = ["do you want to proceed?"] },
+ { contains = ["tab amend", "edit command"] },
+]
+
+[[rules]]
+id = "spinner_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*[\u2800-\u28FF]+\s+\p{Alphabetic}+\w*ing\b']
+
+[[rules]]
+id = "background_tasks_working"
+state = "working"
+priority = 90
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = ['(?i)·\s*[1-9][0-9]*\s+task']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/claude.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/claude.toml
new file mode 100644
index 000000000000..0c7bdcf252c2
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/claude.toml
@@ -0,0 +1,230 @@
+id = "claude"
+version = "2026.09.11.1"
+min_engine_version = 2
+updated_at = "2026-09-11T00:00:00Z"
+aliases = ["claude-code"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1100
+region = "osc_title"
+visible_working = true
+# Braille covers <= 2.1.227; half-circles are the 2.1.228 busy spinner.
+regex = ['^[\x{2800}-\x{28FF}\x{25D0}-\x{25D3}] ']
+
+[[rules]]
+id = "live_turn_working"
+state = "working"
+priority = 970
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+any = [
+ { line_regex = ['^\s*[⏸⏵].*esc to interrupt(?:\s|·|$)'] },
+ { line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2733}\x{2736}\x{273B}\x{273D}]\s+\S.*…(?:\s+\(\d+[smh](?:\s|·)|\s*$)'] },
+]
+
+[[rules]]
+id = "background_agents_working"
+state = "working"
+priority = 965
+region = "last_non_empty_above_prompt_box"
+visible_working = true
+line_regex = ['^\s*[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}]\s+Waiting for [1-9]\d* background agents? to finish\s*$']
+
+[[rules]]
+id = "background_mcp_task_working"
+state = "working"
+priority = 965
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+# Claude renders activity summaries at column zero; wrapped continuations are indented.
+# Keeping that shape prevents user prompt text from impersonating this signal.
+regex = ['(?m)^[\x{002A}\x{00B7}\x{2722}\x{2736}\x{273B}\x{273D}][ \t]+\S[^\n]*?(?:\n[ \t]+[^\n]*?){0,3}·(?:[ \t]+|\n[ \t]*)[1-9]\d*(?:[ \t]+|\n[ \t]*)MCP(?:[ \t]+|\n[ \t]*)tasks?(?:[ \t]+|\n[ \t]*)still(?:[ \t]+|\n[ \t]*)running[ \t]*$']
+not = [
+ { contains = ["do you want to proceed?"] },
+ { contains = ["esc to cancel"] },
+ { contains = ["waiting for permission"] },
+ { contains = ["do you want to allow this connection?"] },
+ { contains = ["tab to amend"] },
+ { contains = ["ctrl+e to explain"] },
+]
+
+[[rules]]
+id = "btw_overlay_working"
+state = "working"
+priority = 975
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = [
+ '^\s*/btw(?:\s|$)',
+ '(?i)esc to close\s*$',
+]
+
+[[rules]]
+id = "transcript_viewer"
+state = "unknown"
+priority = 1000
+region = "bottom_non_empty_lines(3)"
+skip_state_update = true
+contains = ["showing detailed transcript"]
+any = [
+ { contains = ["ctrl+o", "to toggle"] },
+ { contains = ["ctrl+e", "show all"] },
+ { contains = ["ctrl+e", "collapse"] },
+ { contains = ["↑↓ scroll"] },
+ { contains = ["? for shortcuts"] },
+]
+
+[[rules]]
+id = "live_blocked_form"
+state = "blocked"
+priority = 980
+region = "after_last_horizontal_rule"
+visible_blocker = true
+contains = ["esc to cancel"]
+any = [
+ { contains = ["enter to confirm"] },
+ { contains = ["enter to select"], any = [
+ { contains = ["tab/arrow keys to navigate"] },
+ { contains = ["arrow keys to navigate"] },
+ { contains = ["arrows to navigate"] },
+ { contains = ["↑/↓ to navigate"] },
+ { contains = ["↑↓ to navigate"] },
+ ] },
+]
+
+[[rules]]
+id = "dynamic_workflow_prompt"
+state = "blocked"
+priority = 980
+region = "whole_recent"
+visible_blocker = true
+contains = ["run a dynamic workflow?", "esc to cancel"]
+
+[[rules]]
+id = "mcp_elicitation_prompt"
+state = "blocked"
+priority = 980
+region = "whole_recent"
+visible_blocker = true
+# MCP elicitation dialogs (elicitation/create) show Accept/Decline controls
+# with an "Esc to cancel" footer but no Enter hint, so live_blocked_form
+# cannot see them (issue #3283). Gate on the invariant header line, the
+# Accept/Decline control line, and the cancel footer.
+contains = ["esc to cancel"]
+line_regex = ['(?i)^\s*MCP server ["\x{201C}].+["\x{201D}] requests your input\s*$']
+all = [
+ { any = [
+ { line_regex = ['^\s*\x{276F}?\s*Accept\b'] },
+ { line_regex = ['^\s*\x{276F}?\s*Decline\b'] },
+ ] },
+]
+
+[[rules]]
+id = "live_prompt_box"
+state = "idle"
+priority = 950
+region = "prompt_box_body"
+visible_idle = true
+line_regex = ['^\s*❯']
+not = [
+ { contains = ["enter to select"] },
+ { contains = ["esc to cancel"] },
+ { contains = ["tab/arrow keys"] },
+ { contains = ["arrow keys to navigate"] },
+ { contains = ["↑/↓ to navigate"] },
+]
+
+[[rules]]
+id = "model_picker_menu"
+state = "unknown"
+priority = 900
+region = "whole_recent"
+skip_state_update = true
+contains = ["select model", "enter to set as default", "esc to cancel"]
+not = [
+ { contains = ["do you want to proceed?"] },
+ { contains = ["enter to select"] },
+]
+
+[[rules]]
+id = "bash_permission_prompt"
+state = "blocked"
+priority = 850
+region = "whole_recent"
+visible_blocker = true
+contains = ["do you want to proceed?"]
+any = [
+ { contains = ["bash command"] },
+ { contains = ["bash("] },
+ { contains = ["contains expansion"] },
+ { contains = ["tab to amend"] },
+ { contains = ["ctrl+e to explain"] },
+]
+# Claude marks the selected option with "❯", so every option branch has to allow
+# that prefix. Numbered branches that omit it only match options the cursor has
+# moved away from, which left the resting layout below unmatched here (#2650):
+# ❯ 1. Yes / 2. Yes, and don't ask again for: / 3. No
+# Cover both the two-option and "don't ask again" three-option shapes so this
+# rule, not the narrower generic_permission_prompt, claims Bash approvals.
+all = [
+ { any = [
+ { line_regex = ['(?i)^\s*❯?\s*yes\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*2\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*2\.\s*no\b'] },
+ { line_regex = ['(?i)^\s*❯?\s*3\.\s*no\b'] },
+ ] },
+]
+
+[[rules]]
+id = "generic_permission_prompt"
+state = "blocked"
+priority = 840
+region = "after_last_horizontal_rule"
+visible_blocker = true
+contains = ["do you want to proceed?", "esc to cancel"]
+all = [
+ { any = [
+ { line_regex = ['(?i)^\s*❯?\s*1\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*2\.\s*yes\b'] },
+ { line_regex = ['(?i)^\s*2\.\s*no\b'] },
+ { line_regex = ['(?i)^\s*3\.\s*no\b'] },
+ ] },
+]
+
+[[rules]]
+id = "legacy_no_prompt_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+any = [
+ { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+ { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+ { contains = ["waiting for permission"] },
+ { contains = ["do you want to allow this connection?"] },
+ { contains = ["tab to amend"] },
+ { contains = ["ctrl+e to explain"] },
+ { contains = ["do you want to proceed?", "esc to cancel"] },
+ { contains = ["review your answers"] },
+ { contains = ["skip interview and plan immediately"] },
+]
+not = [
+ { regex = ['(?m)^\s*❯\s*$'] },
+]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 250
+region = "osc_title"
+visible_idle = true
+regex = ['^\x{2733} ']
+
+[[rules]]
+id = "osc_progress_idle"
+state = "idle"
+priority = 250
+region = "osc_progress"
+regex = ['^4;0']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cline.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cline.toml
new file mode 100644
index 000000000000..2d3140875e40
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cline.toml
@@ -0,0 +1,63 @@
+id = "cline"
+version = "2026.09.11.1"
+min_engine_version = 1
+updated_at = "2026-09-11T00:00:00Z"
+
+[[rules]]
+id = "tool_permission"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["let cline use this tool"] },
+ { contains = ["[act mode]", "execute command?", "yes"] },
+ { contains = ["[act mode]", "use this tool?", "yes"] },
+ { contains = ["[plan mode]", "execute command?", "yes"] },
+ { contains = ["[plan mode]", "use this tool?", "yes"] },
+]
+
+[[rules]]
+id = "inline_tool_permission"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(16)"
+visible_blocker = true
+contains = ["Cline needs permission", "Approve tool call?", "[y] Approve", "[n] Deny"]
+
+[[rules]]
+id = "inline_question"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(24)"
+visible_blocker = true
+line_regex = ['^\s*Cline is asking a question\s*$', '^\s*>\s+\S']
+contains = ["(Tab)", "Shift+Tab"]
+
+[[rules]]
+id = "active_turn"
+state = "working"
+priority = 200
+region = "bottom_non_empty_lines(20)"
+visible_working = true
+any = [
+ { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+\S'] },
+ { contains = ["Thinking... (esc to cancel)"] },
+]
+
+[[rules]]
+id = "composer_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(12)"
+visible_idle = true
+line_regex = ['^\s*❯(?:\s.*)?$']
+contains = ["─", "(Tab)", "Shift+Tab"]
+
+[[rules]]
+id = "default_cline_working"
+state = "working"
+priority = -10
+region = "whole_recent"
+visible_working = true
+regex = ['(?s).+']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/codex.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/codex.toml
new file mode 100644
index 000000000000..8ceef3146de5
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/codex.toml
@@ -0,0 +1,105 @@
+id = "codex"
+version = "2026.09.15.1"
+min_engine_version = 3
+updated_at = "2026-09-15T00:00:00Z"
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1100
+region = "osc_title"
+visible_blocker = true
+contains = ["Action Required"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1050
+region = "osc_title"
+visible_working = true
+regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)']
+
+[[rules]]
+id = "transcript_viewer"
+state = "unknown"
+priority = 1000
+region = "after_last_prompt_marker"
+skip_state_update = true
+contains = ["↑/↓ to scroll", "pgup/pgdn to", "home/end to jump", "q to quit"]
+any = [
+ { contains = ["esc to edit prev"] },
+ { contains = ["esc/← to edit prev"] },
+]
+
+[[rules]]
+id = "trust_directory"
+state = "blocked"
+priority = 950
+region = "top_non_empty_lines(20)"
+visible_blocker = true
+all = [
+ { regex = ['\A> You are in [^\r\n]+(?:\r?\n|$)'] },
+ { regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] },
+]
+
+[[rules]]
+id = "startup_update"
+state = "blocked"
+priority = 950
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+contains = ["Update available!", "Update now"]
+regex = ['Skip\s+until\s+next\s+version', 'Press enter to continue\s*\z']
+
+[[rules]]
+id = "live_strong_blocker"
+state = "blocked"
+priority = 900
+region = "after_last_prompt_marker"
+visible_blocker = true
+any = [
+ { contains = ["press enter to confirm or esc to cancel"] },
+ { contains = ["enter to submit answer"] },
+ { contains = ["enter to submit all"] },
+ { contains = ["allow command?"] },
+]
+
+[[rules]]
+id = "weak_blocker"
+state = "blocked"
+priority = 600
+region = "whole_recent_without_current_prompt_marker"
+# Sparkles can replace the space after ›. A later response marker makes that prompt stale.
+not = [{ regex = ['(?m)^›[⠁⠂⠄⠈⠐⠠⡀⢀][^\n]*(?:\n(?:[^•■✗✓\n][^\n]*)?)*\z'] }]
+any = [
+ { contains = ["[y/n]"] },
+ { contains = ["yes (y)"] },
+ { contains = ["do you want to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+ { contains = ["would you like to"], any = [{ contains = ["yes"] }, { contains = ["❯"] }] },
+]
+
+[[rules]]
+id = "screen_working_fallback"
+state = "working"
+priority = 500
+region = "before_current_prompt_marker"
+visible_working = true
+# Support animated and reduced-motion status, including dynamic activity labels.
+# The interrupt hint can be remapped, unbound, or hidden, and queued inputs can
+# sit below the status. Require the live timer suffix with no later response.
+any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }]
+regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
+# A failed reconnect keeps its final elapsed timer but is no longer working.
+not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 100
+region = "osc_title"
+visible_idle = true
+regex = ['\S']
+not = [
+ { regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] },
+ { contains = ["Action Required"] },
+]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cursor.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cursor.toml
new file mode 100644
index 000000000000..ee03e6db9d75
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/cursor.toml
@@ -0,0 +1,57 @@
+id = "cursor"
+version = "2026.08.03.1"
+min_engine_version = 1
+updated_at = "2026-08-03T01:08:04Z"
+aliases = ["cursor-agent"]
+
+[[rules]]
+id = "write_file_approval"
+state = "blocked"
+priority = 320
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["write to this file?", "proceed (y)"]
+any = [
+ { contains = ["reject & propose changes"] },
+ { contains = ["esc or n or p"] },
+ { contains = ["add write("] },
+]
+
+[[rules]]
+id = "approval_prompt"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["waiting for approval", "run this command?"], any = [{ contains = ["run (once) (y)"] }, { contains = ["skip (esc or n)"] }] },
+ { contains = ["(y) (enter)"] },
+ { line_regex = ['(?i)^\s*allow .*\(y\)'] },
+ { contains = ["keep (n)"] },
+ { contains = ["skip (esc or n)"] },
+ { line_regex = ['(?i)^\s*(?:→\s*)?run .*\(y\)'] },
+]
+
+[[rules]]
+id = "stop_hint_working"
+state = "working"
+priority = 100
+region = "bottom_non_empty_lines(6)"
+visible_working = true
+contains = ["ctrl+c to stop"]
+
+[[rules]]
+id = "background_task_status_working"
+state = "working"
+priority = 95
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+line_regex = ['(?i)\b[1-9][0-9]*\s+background\s+tasks?\b']
+
+[[rules]]
+id = "spinner_working"
+state = "working"
+priority = 90
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(⬡|⬢|[\u2800-\u28FF]+)\s+\p{Alphabetic}+\w*ing\b']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/devin.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/devin.toml
new file mode 100644
index 000000000000..c9564f7cc134
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/devin.toml
@@ -0,0 +1,86 @@
+id = "devin"
+version = "2026.06.15.1"
+min_engine_version = 1
+updated_at = "2026-06-15T00:00:00Z"
+aliases = ["devin-cli", "devin cli"]
+
+[[rules]]
+id = "workspace_trust_prompt"
+state = "blocked"
+priority = 300
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = [
+ "do you trust the authors of this directory?",
+ "with untrusted content.",
+ "yes, trust ",
+]
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 290
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["approve once", "select", "confirm", "esc cancel"]
+
+[[rules]]
+id = "running_tools_footer"
+state = "working"
+priority = 200
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["running tools", "esc to interrupt"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "guide_while_working"
+state = "working"
+priority = 190
+region = "bottom_non_empty_lines(6)"
+visible_working = true
+contains = ["guide devin while it works"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "tool_reading_timeout"
+state = "working"
+priority = 180
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["reading shell ", "timeout:"]
+not = [
+ { contains = ["approve once", "esc cancel"] },
+]
+
+[[rules]]
+id = "welcome_prompt_footer"
+state = "idle"
+priority = 120
+region = "bottom_non_empty_lines(8)"
+visible_idle = true
+contains = ["ask devin to build", "features, fix bugs", "your code"]
+line_regex = ['^\s*❭ Ask Devin to build']
+not = [
+ { contains = ["approve once", "esc cancel"] },
+ { contains = ["running tools", "esc to interrupt"] },
+ { contains = ["guide devin while it works"] },
+]
+
+[[rules]]
+id = "live_prompt_footer"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(6)"
+visible_idle = true
+contains = ["context:"]
+line_regex = ['^\s*❭']
+not = [
+ { contains = ["approve once", "esc cancel"] },
+ { contains = ["running tools", "esc to interrupt"] },
+ { contains = ["guide devin while it works"] },
+]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/droid.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/droid.toml
new file mode 100644
index 000000000000..c41d71b43bfd
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/droid.toml
@@ -0,0 +1,48 @@
+id = "droid"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+
+[[rules]]
+id = "execute_selection_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+contains = ["enter to select", "esc to cancel"]
+any = [
+ { contains = ["↑↓ to navigate"] },
+ { contains = ["use ↑↓ to navigate"] },
+]
+all = [
+ { any = [{ contains = ["> yes, allow"] }, { contains = ["> no, cancel"] }] },
+]
+
+[[rules]]
+id = "selection_menu_blocker"
+state = "blocked"
+priority = 290
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["enter select", "esc cancel"]
+any = [
+ { contains = ["↑/↓ navigate"] },
+ { contains = ["↑↓ navigate"] },
+]
+
+[[rules]]
+id = "spinner_stop_working"
+state = "working"
+priority = 110
+region = "whole_recent"
+visible_working = true
+contains = ["esc to stop"]
+line_regex = ['^\s*[\u2800-\u28FF]']
+
+[[rules]]
+id = "stop_hint_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc to stop"]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/gemini.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/gemini.toml
new file mode 100644
index 000000000000..9d7a28e112d6
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/gemini.toml
@@ -0,0 +1,25 @@
+id = "gemini"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+
+[[rules]]
+id = "apply_or_allow_change"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["│ Apply this change"] },
+ { contains = ["│ Allow execution"] },
+ { all = [{ contains = ["yes"] }, { any = [{ contains = ["waiting for user confirmation"] }, { contains = ["│ Do you want to proceed"] }, { contains = ["do you want to proceed?"] }] }] },
+ { line_regex = ['(?i)^\s*❯.*(yes|allow)'] },
+]
+
+[[rules]]
+id = "esc_cancel_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc to cancel"]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/github-copilot.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/github-copilot.toml
new file mode 100644
index 000000000000..57fafdd874ed
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/github-copilot.toml
@@ -0,0 +1,45 @@
+id = "copilot"
+version = "2026.08.29.1"
+min_engine_version = 1
+updated_at = "2026-08-29T00:00:00Z"
+aliases = ["github-copilot", "ghcs"]
+
+[[rules]]
+id = "selection_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+all = [
+ { any = [
+ { contains = ["esc to cancel"] },
+ { contains = ["esc cancel"] },
+ ] },
+ { any = [
+ { contains = ["enter to select"] },
+ { contains = ["enter to confirm"] },
+ { contains = ["enter to submit"] },
+ { contains = ["enter accept"] },
+ ] },
+]
+
+[[rules]]
+id = "background_agents_working"
+state = "working"
+priority = 110
+region = "bottom_non_empty_lines(6)"
+visible_working = true
+line_regex = ['^\s*◎\s+Waiting for background agents(?:\s|·|$)']
+
+[[rules]]
+id = "working_cancel_hint"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+any = [
+ { contains = ["esc to cancel"] },
+ { contains = ["esc cancel"] },
+ { contains = ["esc again to cancel"] },
+ { contains = ["esc interrupt"] }
+]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/grok.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/grok.toml
new file mode 100644
index 000000000000..4abcefd6fb64
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/grok.toml
@@ -0,0 +1,168 @@
+id = "grok"
+version = "2026.09.18.1"
+min_engine_version = 2
+updated_at = "2026-09-18T00:00:00Z"
+aliases = ["grok-build"]
+
+# Evidence: Grok Build 0.2.101 source and 1.0.34 live pane reads.
+#
+# Grok emits OSC 0 titles by default. Idle is "grok" or
+# " - grok". During a turn, the configured title gains a braille
+# spinner and activity text. Permission prompts add "⚠ Action Required";
+# that prefix blinks while the terminal is unfocused, so visible blocker
+# rules outrank the title spinner rule.
+#
+# Grok also emits OSC 9;4 progress on supported terminals. Herdr retains the
+# payload after "9;": "4;1;-1" while busy and "4;0;0" when idle.
+#
+# Working turns render one live status line directly above the prompt box:
+# "⠧ Waiting on subagent… 2.8s 13s ⇣29.7k [stop]"
+# "⠴ Explore /tmp/… + 1 more… 5.6s 19s ⇣29.7k [stop]"
+# with a braille spinner and a trailing [stop] chip, plus an Esc:cancel
+# footer hint. Permission prompts and ask-user-question dialogs replace the
+# spinner with "◆" and draw a "┃"-guttered option list:
+# "┃ 2 (○) Yes, proceed"
+# "┃ z (○) Type your answer here"
+# with footer hints "1/3:select │ Ctrl+o:yolo │ Ctrl+c:cancel" (permission)
+# or "Esc:unselect │ Tab:scrollback │ Shift+x:dismiss" (question dialog).
+# Idle footers end with "Ctrl+.:shortcuts" and never contain "Esc:cancel".
+# The startup splash draws its logo with braille characters, so working
+# rules must anchor on the [stop] chip, not on a bare spinner glyph.
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1300
+region = "osc_title"
+visible_blocker = true
+contains = ["Action Required"]
+
+[[rules]]
+id = "option_dialog_blocked"
+state = "blocked"
+priority = 1200
+region = "whole_recent"
+visible_blocker = true
+line_regex = ['^\s*┃\s+[0-9a-z]+\s+\([●○]\)\s']
+
+[[rules]]
+id = "permission_hints_blocked"
+state = "blocked"
+priority = 1190
+region = "bottom_non_empty_lines(2)"
+visible_blocker = true
+contains = [":select", "ctrl+o:yolo", "ctrl+c:cancel"]
+
+[[rules]]
+id = "question_dialog_hints_blocked"
+state = "blocked"
+priority = 1185
+region = "bottom_non_empty_lines(2)"
+visible_blocker = true
+contains = ["tab:scrollback", "shift+x:dismiss"]
+
+# Pre-0.2.x permission UI kept for older Grok Build releases.
+[[rules]]
+id = "permission_scope_selector"
+state = "blocked"
+priority = 1180
+region = "whole_recent"
+visible_blocker = true
+contains = ["yes, proceed", "no, reject"]
+any = [
+ { contains = ["use ← → to choose permission whitelist scope"] },
+ { contains = ["←/→:scope"] },
+]
+
+# Grok 1.0.34 moves background counts above the composer and clears OSC
+# progress between turns even while these commands are still running.
+[[rules]]
+id = "background_status_working"
+state = "working"
+priority = 1165
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+line_regex = ['^\s*[○◎◉]\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?)(?:\s+·\s+[1-9][0-9]*\s+(?:commands?|monitors?|loops?|subagents?))*\s+still running(?:\s+·\s+send a message to interrupt)?\s*$']
+
+[[rules]]
+id = "osc_progress_working"
+state = "working"
+priority = 1150
+region = "osc_progress"
+visible_working = true
+regex = ['^4;1;-1$']
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 1100
+region = "osc_title"
+visible_idle = true
+regex = ['(?:^| - )grok$']
+not = [
+ { regex = ['[\x{2800}-\x{28FF}]'] },
+]
+
+# Title items are configurable; omitting "grok" does not imply activity.
+# Require a spinner, with OSC progress covering titles that omit it.
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1000
+region = "osc_title"
+visible_working = true
+regex = ['(?:^|\s)[\x{2801}-\x{28FF}](?:\s|$)']
+
+[[rules]]
+id = "osc_progress_idle"
+state = "idle"
+priority = 950
+region = "osc_progress"
+visible_idle = true
+regex = ['^4;0;0$']
+
+# Visible activity outranks idle OSC signals: disabling title updates can
+# leave the startup "grok" title unchanged throughout a turn.
+[[rules]]
+id = "spinner_status_working"
+state = "working"
+priority = 1160
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*[\x{2801}-\x{28FF}]\s.*\[stop\]\s*$']
+
+[[rules]]
+id = "esc_cancel_hints_working"
+state = "working"
+priority = 1155
+region = "bottom_non_empty_lines(2)"
+visible_working = true
+contains = ["ctrl+.:shortcuts"]
+any = [
+ { contains = ["esc:cancel"] },
+ { contains = ["ctrl+c:cancel"] },
+]
+
+# Pre-0.2.x working chrome kept for older Grok Build releases.
+[[rules]]
+id = "waiting_tool_working"
+state = "working"
+priority = 1140
+region = "whole_recent"
+visible_working = true
+any = [
+ { all = [{ contains = ["ctrl+c:cancel", "ctrl+enter:interject"] }, { contains = ["waiting"] }] },
+ { line_regex = ['^\s*[\x{2801}-\x{28FF}]\s+(Run|Read|Search|List)\b'] },
+]
+
+[[rules]]
+id = "prompt_hints_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(2)"
+visible_idle = true
+contains = ["ctrl+.:shortcuts"]
+not = [
+ { contains = ["esc:cancel"] },
+ { contains = ["ctrl+c:cancel"] },
+]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/hermes.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/hermes.toml
new file mode 100644
index 000000000000..17542184971e
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/hermes.toml
@@ -0,0 +1,102 @@
+id = "hermes"
+version = "2026.07.24.1"
+min_engine_version = 2
+updated_at = "2026-07-24T19:12:54Z"
+aliases = ["hermes-agent"]
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1100
+region = "osc_title"
+visible_blocker = true
+regex = ['^⚠[\u{fe0e}\u{fe0f}]?(?:\s|$)']
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1050
+region = "osc_title"
+visible_working = true
+regex = ['^⏳[\u{fe0e}\u{fe0f}]?(?:\s|$)']
+
+[[rules]]
+id = "dangerous_command_approval"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+any = [
+ { contains = ["dangerous"] },
+ { contains = ["approval"] },
+ { contains = ["allow once", "deny"] },
+ { line_regex = ['(?i)^\s*[▸>]?\s*1\.\s*allow'] },
+]
+all = [
+ { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["↑/↓ to select"] }, { contains = ["show full command"] }] },
+]
+
+[[rules]]
+id = "clarification_prompt"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+any = [
+ { contains = ["hermes needs your"] },
+ { line_regex = ['^\s*ask\s+\S'] },
+ { contains = ["type your answer"] },
+]
+all = [
+ { any = [{ contains = ["enter confirm"] }, { contains = ["enter to confirm"] }, { contains = ["enter send"] }, { contains = ["press enter"] }, { contains = ["↑/↓ select"] }, { contains = ["↑/↓ to select"] }, { contains = ["other (type"] }] },
+]
+
+[[rules]]
+id = "credential_prompt"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+any = [
+ { contains = ["sudo password"] },
+ { contains = ["skill setup"] },
+ { contains = ["🔑", "for "] },
+]
+
+[[rules]]
+id = "confirmation_prompt"
+state = "blocked"
+priority = 900
+region = "bottom_non_empty_lines(14)"
+visible_blocker = true
+all = [
+ { any = [{ contains = ["approve once", "cancel"] }, { contains = ["start a new session", "keep going"] }] },
+ { any = [{ contains = ["enter to confirm"] }, { contains = ["enter confirm"] }, { contains = ["type 1/2/3"] }, { contains = ["y/n quick"] }] },
+]
+
+[[rules]]
+id = "interrupt_status_working"
+state = "working"
+priority = 950
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+any = [
+ { contains = ["msg=interrupt"] },
+ { contains = ["ctrl+c to interrupt"] },
+]
+
+[[rules]]
+id = "classic_cancel_working"
+state = "working"
+priority = 500
+region = "bottom_non_empty_lines(5)"
+visible_working = true
+contains = ["ctrl+c cancel"]
+
+[[rules]]
+id = "osc_title_idle"
+state = "idle"
+priority = 100
+region = "osc_title"
+visible_idle = true
+regex = ['^✓[\u{fe0e}\u{fe0f}]?(?:\s|$)']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kilo.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kilo.toml
new file mode 100644
index 000000000000..4ef004e1de4a
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kilo.toml
@@ -0,0 +1,24 @@
+id = "kilo"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["kilo-code", "kilo code", "herdr:kilo"]
+
+[[rules]]
+id = "opencode_permission"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["△ Permission required"] },
+ { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] },
+]
+
+[[rules]]
+id = "esc_interrupt_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["esc interrupt"]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kimi.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kimi.toml
new file mode 100644
index 000000000000..b4d0100fbae7
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kimi.toml
@@ -0,0 +1,77 @@
+id = "kimi"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["kimi-code", "kimi code"]
+
+[[rules]]
+id = "current_approval_panel"
+state = "blocked"
+priority = 400
+region = "whole_recent"
+visible_blocker = true
+contains = ["↵ confirm"]
+any = [
+ { contains = ["run this command?"] },
+ { contains = ["write this file?"] },
+ { contains = ["apply these edits?"] },
+ { contains = ["stop this task?"] },
+ { contains = ["ready to build with this plan?"] },
+ { line_regex = ['(?i)^\s*▶?\s*approve .*\?$'] },
+]
+all = [
+ { contains = [" choose"] },
+ { any = [{ contains = ["approve"] }, { contains = ["reject"] }, { contains = ["revise"] }] },
+]
+
+[[rules]]
+id = "question_panel"
+state = "blocked"
+priority = 390
+region = "whole_recent"
+visible_blocker = true
+contains = ["↑↓ select", "esc cancel"]
+line_regex = ['^\s*question\s*$', '^\s*\? ']
+any = [
+ { contains = ["↵ choose"] },
+ { contains = ["↵ toggle"] },
+ { contains = ["↵ save"] },
+]
+
+[[rules]]
+id = "legacy_approval_panel"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+contains = ["requesting approval", "reject"]
+any = [
+ { contains = ["approve once"] },
+ { contains = ["approve for this session"] },
+]
+all = [
+ { any = [{ contains = ["1/2/3/4 choose"] }, { contains = ["↵ confirm"] }] },
+]
+
+[[rules]]
+id = "background_agent_status_working"
+state = "working"
+priority = 120
+region = "bottom_non_empty_lines(3)"
+visible_working = true
+line_regex = ['(?i)\bkimi[-\w.]*\s+thinking\b.*\[[1-9][0-9]*\s+agents?\s+running\]']
+
+[[rules]]
+id = "moon_spinner_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*(🌕|🌖|🌗|🌘|🌑|🌒|🌓|🌔)\s*$']
+
+[[rules]]
+id = "braille_spinner_working"
+state = "working"
+priority = 90
+region = "whole_recent"
+visible_working = true
+line_regex = ['(?i)^\s*[\u2800-\u28FF]+\s*(thinking\.\.\.|working\.\.\.|using )']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kiro.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kiro.toml
new file mode 100644
index 000000000000..9d50f9320322
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/kiro.toml
@@ -0,0 +1,97 @@
+id = "kiro"
+version = "2026.09.19.1"
+min_engine_version = 2
+updated_at = "2026-09-19T00:00:00Z"
+aliases = ["kiro-cli"]
+
+[[rules]]
+id = "live_prompt_idle"
+state = "idle"
+priority = 1100
+region = "bottom_non_empty_lines(4)"
+visible_idle = true
+line_regex = ['(?i)^\s*[>›]\s*ask a question or describe a task(?:\s+(?:enter|↵))?\s*$']
+
+[[rules]]
+id = "tool_approval"
+state = "blocked"
+priority = 1050
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+any = [
+ { all = [
+ { regex = ['(?is)(?:^|\n)\s*esc\s+to\s+close\b(?:[^\n]*\bto\s+navigate\b[^\n]*|.*\bto\s+navigate\b.*\bto\s+select\b.*\btab\s+to\s+edit|[^\n]*\benter\s+to\s+see\s+more\s+options)\s*\z'] },
+ { any = [
+ { line_regex = [
+ '(?i)^\s*[>❯]\s*(?:allow|always allow|deny|always deny)\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?allow\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?always allow\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?deny\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?always deny\s*$',
+ ] },
+ { line_regex = [
+ '(?i)^\s*[>❯]\s*(?:yes, single permission|trust, always allow in this session|no \(tab to edit\))\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?yes, single permission\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?trust, always allow in this session\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?no \(tab to edit\)\s*$',
+ ] },
+ { line_regex = [
+ '(?i)^\s*[>❯]\s*(?:trust\b.*|entire tool)\s*$',
+ '(?i)^\s*(?:[>❯]\s*)?(?:trust )?entire tool(?:\s+\([^\n]*\))?(?:\s+(?:session|workspace|always))?\s*$',
+ ] },
+ ] },
+ ] },
+]
+
+[[rules]]
+id = "tool_approval_edit"
+state = "blocked"
+priority = 1045
+region = "whole_recent"
+visible_blocker = true
+regex = ['(?im)(?:^|\n)[ \t]*[-─]+[ \t]*\n[^\n]*requires\s+approval\s*[·.]\s*modify\s+request[ \t]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*?[ \t]*[>›][ \t]*[^\n]*\n(?:[ \t]*\n|[^\n]*[^\s─-][^\n]*\n)*[ \t]*[-─]+[ \t]*\n[ \t]*esc[ \t]+to[ \t]+close[ \t]*\n?\z']
+
+[[rules]]
+id = "crew_approval"
+state = "blocked"
+priority = 1040
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = [
+ "tool approval",
+ "approve all pending",
+ "configure individually (agent monitor)",
+ "exit (cancel subagents)",
+]
+
+[[rules]]
+id = "question_panel"
+state = "blocked"
+priority = 1030
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+contains = ["to navigate", "to submit", "esc to cancel"]
+
+[[rules]]
+id = "live_working_footer"
+state = "working"
+priority = 950
+region = "bottom_non_empty_lines(4)"
+visible_working = true
+contains = ["kiro is working", "type to steer", "ctrl+s to queue"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 900
+region = "osc_title"
+visible_working = true
+regex = ['(?i)^[◐◓◑◒/|\\-]\s+kiro:']
+
+[[rules]]
+id = "osc_progress_working"
+state = "working"
+priority = 890
+region = "osc_progress"
+visible_working = true
+regex = ['^4;3;?$']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/letta.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/letta.toml
new file mode 100644
index 000000000000..e27e2aa72258
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/letta.toml
@@ -0,0 +1,95 @@
+id = "letta"
+version = "2026.08.24.1"
+min_engine_version = 3
+updated_at = "2026-08-24T00:00:00Z"
+aliases = ["letta-code", "letta code"]
+
+# Letta keeps completed tool rows and spinners in the transcript. A standalone
+# screen spinner is not working evidence. Prefer its live OSC title/progress
+# signals, with visible status chrome and running tool rows as fallbacks.
+[[rules]]
+id = "osc_progress_blocked"
+state = "blocked"
+priority = 1400
+region = "osc_progress"
+visible_blocker = true
+regex = ['^4;3(?:;|$)']
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1300
+region = "osc_title"
+visible_blocker = true
+regex = ['^\[ [!.] \] Action Required(?: \| |$)']
+
+[[rules]]
+id = "command_approval"
+state = "blocked"
+priority = 1200
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+contains = ["Run this command?", "Enter to select · Esc to cancel"]
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 900
+region = "osc_title"
+visible_working = true
+regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)']
+
+[[rules]]
+id = "active_status"
+state = "working"
+priority = 850
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$']
+
+[[rules]]
+id = "running_tool"
+state = "working"
+priority = 800
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$']
+
+[[rules]]
+id = "profile_selector"
+state = "unknown"
+priority = 700
+region = "bottom_non_empty_lines(12)"
+contains = ["Create a new agent (--new)", "Enter select · Esc exit"]
+
+[[rules]]
+id = "composer_input"
+state = "unknown"
+priority = 150
+region = "bottom_non_empty_lines(8)"
+line_regex = ['^\s*›\s+\S.*$']
+not = [
+ { line_regex = ['^\s*›\s+Try\s+"'] },
+]
+
+[[rules]]
+id = "composer_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(8)"
+visible_idle = true
+any = [
+ { line_regex = ['^\s*›\s*$'] },
+ { line_regex = ['^\s*›\s+Try\s+"'] },
+]
+not = [
+ { line_regex = ['^\s*(?:\S+\s+)+is(?: \S+)*… \((?:esc to interrupt(?: · .*)?|interrupting)\)\s*$'] },
+ { line_regex = ['^\s*(?:└\s*)?Running\.\.\.\s*(?:\(.*\))?$'] },
+]
+
+[[rules]]
+id = "no_live_state_evidence"
+state = "unknown"
+priority = 0
+region = "whole_recent"
+regex = ['(?s)^.*$']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/maki.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/maki.toml
new file mode 100644
index 000000000000..5c58404a52a9
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/maki.toml
@@ -0,0 +1,68 @@
+id = "maki"
+version = "2026.07.09.2"
+min_engine_version = 1
+updated_at = "2026-07-09T00:00:00Z"
+
+# Maki renders a persistent one-line status bar on the bottom row. It starts
+# with the mode label "[BUILD]", "[PLAN]", or "[BASH]" when idle and gets a
+# leading braille spinner cell while the agent is streaming. Permission
+# requests and the plan-complete form replace the input box above the status
+# bar. Maki does not set OSC title or OSC 9;4 progress.
+
+[[rules]]
+id = "permission_prompt"
+state = "blocked"
+priority = 980
+region = "whole_recent"
+visible_blocker = true
+contains = ["permission required"]
+any = [
+ { contains = ["y allow", "n deny"] },
+ { contains = ["confirm allow"] },
+ { contains = ["confirm deny"] },
+ { contains = ["enter deny", "esc cancel"] },
+]
+
+[[rules]]
+id = "plan_complete_form"
+state = "blocked"
+priority = 970
+region = "whole_recent"
+visible_blocker = true
+contains = ["plan complete", "enter confirm"]
+any = [
+ { contains = ["space toggle parallel"] },
+ { contains = ["edit plan"] },
+]
+
+[[rules]]
+id = "status_bar_spinner_working"
+state = "working"
+priority = 900
+region = "bottom_non_empty_lines(1)"
+visible_working = true
+line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} \[(BUILD|PLAN|BASH)\]']
+
+[[rules]]
+id = "status_bar_idle"
+state = "idle"
+priority = 850
+region = "bottom_non_empty_lines(1)"
+visible_idle = true
+line_regex = ['^ \[(BUILD|PLAN|BASH)\]']
+
+# On narrow panes the right side of the status bar overwrites the mode label,
+# so fall back to the prompt chevron above the input box border. The not-gates
+# keep this from matching the streaming placeholder or a status bar that still
+# shows the spinner.
+[[rules]]
+id = "prompt_box_idle"
+state = "idle"
+priority = 840
+region = "bottom_non_empty_lines(3)"
+visible_idle = true
+line_regex = ['^❯ ']
+not = [
+ { contains = ["queue another prompt"] },
+ { line_regex = ['^( [\x{2800}-\x{28FF}]){1,2} '] },
+]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/muse.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/muse.toml
new file mode 100644
index 000000000000..50818422c3cb
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/muse.toml
@@ -0,0 +1,113 @@
+id = "muse"
+version = "2026.08.26.1"
+min_engine_version = 2
+updated_at = "2026-08-26T00:00:00Z"
+aliases = ["muse-code", "muse-cli"]
+
+# Evidence: live bottom-buffer captures from Muse Code 0.2.1 in Herdr, using a local
+# deterministic Responses provider so each UI state could be held and inspected.
+#
+# Idle has a `⟩` prompt and `model · effort · cwd` footer. Active turns show
+# `◆ Working (... · esc to interrupt)` or another activity label with the same interrupt hint.
+#
+# Structured questions show two co-occurring footer controls:
+# Enter to select · ↑/↓ to move · Tab for an optional note · Esc to interrupt
+# Multi-select uses `Enter to toggle` instead. The paired controls distinguish a real picker
+# from ordinary transcript text that happens to mention one action.
+#
+# First launch in an untrusted directory shows `Do you trust this workspace?` together with
+# `Trust and continue`. This is a real blocker. User-opened `/theme` and `/skills` menus are
+# not blockers; their paired footer controls identify overlays whose prior state must be kept.
+#
+# Muse 0.2.1 command approval shows `Allow this stage once` together with
+# `Always allow in this workspace`. Muse 0.1 used `Allow once` with
+# `Allow for this session`. Network approval shows `Yes, proceed` together with
+# `Yes, don't ask again this session`. Each approval rule requires a pair because Muse can
+# emit any one of these phrases as ordinary assistant text after a completed turn.
+
+[[rules]]
+id = "workspace_trust_blocked"
+state = "blocked"
+priority = 970
+region = "bottom_non_empty_lines(12)"
+visible_blocker = true
+contains = ["Do you trust this workspace?"]
+any = [
+ { contains = ["Trust and continue"] },
+ { contains = ["Use Up/Down"] },
+]
+
+[[rules]]
+id = "pick_request_blocked"
+state = "blocked"
+priority = 950
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+any = [
+ { contains = ["Enter to select", "Tab for an optional note"] },
+ { contains = ["Enter to toggle", "Esc to interrupt"] },
+]
+
+[[rules]]
+id = "menu_overlay"
+state = "unknown"
+priority = 940
+region = "bottom_non_empty_lines(8)"
+skip_state_update = true
+any = [
+ { contains = ["enter confirm", "esc go back"] },
+ { contains = ["enter save", "esc go back"] },
+ { contains = ["space toggle", "esc close", "type filter"] },
+]
+
+[[rules]]
+id = "working_esc_interrupt"
+state = "working"
+priority = 900
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+contains = ["esc to interrupt"]
+not = [
+ { contains = ["Enter to select", "Tab for an optional note"] },
+ { contains = ["Enter to toggle", "Esc to interrupt"] },
+]
+
+[[rules]]
+id = "blocked_approval"
+state = "blocked"
+priority = 850
+region = "bottom_non_empty_lines(8)"
+visible_blocker = true
+any = [
+ { contains = ["Allow this stage once", "Always allow in this workspace"] },
+ { contains = ["Allow once", "Allow for this session"] },
+ { contains = ["Yes, proceed", "Yes, don't ask again this session"] },
+]
+
+[[rules]]
+id = "idle_prompt"
+state = "idle"
+priority = 700
+region = "bottom_non_empty_lines(5)"
+visible_idle = true
+any = [
+ { line_regex = ['^\s*⟩\s*$'] },
+ { line_regex = ['^\s*⟩\s+\S'] },
+]
+not = [
+ { contains = ["esc to interrupt"] },
+ { contains = ["Enter to select", "Tab for an optional note"] },
+ { contains = ["Enter to toggle", "Esc to interrupt"] },
+ { contains = ["enter confirm", "esc go back"] },
+ { contains = ["enter save", "esc go back"] },
+ { contains = ["space toggle", "esc close", "type filter"] },
+]
+
+[[rules]]
+id = "idle_status_fallback"
+state = "idle"
+priority = 500
+region = "bottom_non_empty_lines(3)"
+visible_idle = true
+line_regex = ['^\s*\S+ · (none|minimal|low|medium|high|xhigh|ultra) · ']
+not = [{ contains = ["esc to interrupt"] }]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/opencode.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/opencode.toml
new file mode 100644
index 000000000000..5245238371da
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/opencode.toml
@@ -0,0 +1,37 @@
+id = "opencode"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["open-code", "herdr:opencode"]
+
+[[rules]]
+id = "permission_required"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["△ Permission required"] },
+ { contains = ["esc dismiss"], any = [{ contains = ["enter confirm"] }, { contains = ["enter submit"] }, { contains = ["enter toggle"] }], all = [{ any = [{ contains = ["↑↓ select"] }, { contains = ["⇆ tab"] }] }] },
+]
+
+[[rules]]
+id = "interrupt_hint_working"
+state = "working"
+priority = 110
+region = "whole_recent"
+visible_working = true
+any = [
+ { contains = ["esc to interrupt"] },
+ { contains = ["ctrl+c to interrupt"] },
+ { contains = ["press esc to interrupt"] },
+ { line_regex = ['(?i).*opencode.*esc (again to )?interrupt'] },
+]
+
+[[rules]]
+id = "progress_bar_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+regex = ['(■|⬝){4,}']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/pi.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/pi.toml
new file mode 100644
index 000000000000..77b2d6324e54
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/pi.toml
@@ -0,0 +1,21 @@
+id = "pi"
+version = "2026.09.14.1"
+min_engine_version = 1
+updated_at = "2026-09-14T00:00:00Z"
+aliases = ["herdr:pi"]
+
+[[rules]]
+id = "working_literal"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["Working..."]
+
+[[rules]]
+id = "working_border"
+state = "working"
+priority = 100
+region = "bottom_non_empty_lines(12)"
+visible_working = true
+line_regex = ['^── [⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Working ─+$']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qodercli.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qodercli.toml
new file mode 100644
index 000000000000..51ca805ed77f
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qodercli.toml
@@ -0,0 +1,38 @@
+id = "qodercli"
+version = "2026.06.10.1"
+min_engine_version = 1
+updated_at = "2026-06-10T00:00:00Z"
+aliases = ["qoderclicn", "qoder", "qodercn"]
+
+[[rules]]
+id = "confirmation_or_input_blocker"
+state = "blocked"
+priority = 300
+region = "whole_recent"
+visible_blocker = true
+any = [
+ { contains = ["waiting for user confirmation"], any = [{ contains = ["yes"] }, { contains = ["no"] }, { contains = ["allow"] }, { contains = ["reject"] }] },
+ { contains = ["awaiting approval"], any = [{ contains = ["allow"] }, { contains = ["reject"] }] },
+ { contains = ["permission required"] },
+ { contains = ["allow once or always?"] },
+ { contains = ["asking user"] },
+ { contains = ["enter your response"] },
+ { contains = ["review your answers:"] },
+ { contains = ["shell awaiting input"] },
+]
+
+[[rules]]
+id = "cancel_hint_working"
+state = "working"
+priority = 100
+region = "whole_recent"
+visible_working = true
+contains = ["(esc to cancel,"]
+
+[[rules]]
+id = "spinner_working"
+state = "working"
+priority = 90
+region = "whole_recent"
+visible_working = true
+line_regex = ['^\s*[\u2800-\u28FF]\s+.*\p{Alphabetic}']
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qwen.toml b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qwen.toml
new file mode 100644
index 000000000000..bbed23711b50
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/manifests/qwen.toml
@@ -0,0 +1,119 @@
+id = "qwen"
+version = "2026.08.14.1"
+min_engine_version = 2
+updated_at = "2026-08-14T00:00:00Z"
+aliases = ["qwen-code", "qwen code"]
+
+# Qwen Code keeps its composer visible while responding, so the prompt box is
+# not idle evidence by itself. Its status-prefixed terminal titles are the
+# primary locale-independent signals when ui.showStatusInTitle is enabled.
+# Exact screen fallbacks cover built-in locales and narrow terminals. OSC 9;4
+# is supplemental because Qwen emits it only while a tool is executing and
+# only in terminals that advertise progress support.
+
+[[rules]]
+id = "osc_title_blocked"
+state = "blocked"
+priority = 1200
+region = "osc_title"
+visible_blocker = true
+regex = ['^\x{2733}\x{FE0E}? ']
+
+[[rules]]
+id = "osc_title_working"
+state = "working"
+priority = 1100
+region = "osc_title"
+visible_working = true
+regex = ['^\x{25D0}\x{FE0E}? ']
+
+[[rules]]
+id = "waiting_for_confirmation"
+state = "blocked"
+priority = 1000
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+line_regex = ['^\s*⠏\s+.*\.\.\.\s*$']
+any = [
+ { contains = ["Waiting for user confirmation..."] },
+ { contains = ["等待用户确认..."] },
+ { contains = ["等待用戶確認..."] },
+ { contains = ["Warten auf Benutzerbestätigung..."] },
+ { contains = ["En attente de la confirmation de l'utilisateur..."] },
+ { contains = ["ユーザーの確認を待っています..."] },
+ { contains = ["Aguardando confirmação do usuário..."] },
+ { contains = ["Ожидание подтверждения от пользователя..."] },
+ { contains = ["Esperant la confirmació de l'usuari..."] },
+]
+
+[[rules]]
+id = "tool_confirmation"
+state = "blocked"
+priority = 990
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+contains = ["yes, allow once"]
+any = [
+ { contains = ["apply this change?"] },
+ { contains = ["allow execution of:"] },
+ { contains = ["allow execution of mcp tool"] },
+ { contains = ["do you want to proceed?"] },
+ { contains = ["shell command execution"] },
+]
+
+[[rules]]
+id = "question_dialog"
+state = "blocked"
+priority = 980
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+line_regex = [
+ '^\s*[❯›]\s*(?:\[(?: |✓)\]\s*)?\d+\.\s+',
+ '^\s*↑/↓\s*:.*(?:Enter|Return)\s*:',
+]
+
+[[rules]]
+id = "folder_trust_dialog"
+state = "blocked"
+priority = 970
+region = "bottom_non_empty_lines(20)"
+visible_blocker = true
+contains = ["do you trust this folder?", "trust folder (", "don't trust (esc)"]
+
+[[rules]]
+id = "cancel_hint_working"
+state = "working"
+priority = 900
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*(?:[⠁-⣿]|\.{1,2})\s+.*\(\d+(?:m(?:\s+\d+s)?|s).*\s·\sesc to cancel\)\s*$']
+
+[[rules]]
+id = "narrow_cancel_hint_working"
+state = "working"
+priority = 890
+region = "bottom_non_empty_lines(8)"
+visible_working = true
+line_regex = ['^\s*\(\d+(?:m(?:\s+\d+s)?|s)\s·\sesc to cancel\)\s*$']
+
+[[rules]]
+id = "osc_tool_progress_working"
+state = "working"
+priority = 850
+region = "osc_progress"
+visible_working = true
+regex = ['^4;3(?:;|$)']
+
+[[rules]]
+id = "composer_idle"
+state = "idle"
+priority = 100
+region = "bottom_non_empty_lines(30)"
+visible_idle = true
+line_regex = ['^\s*>\s*(?:type\s*)?.*$']
+any = [
+ { contains = ["type your message"] },
+ { contains = ["your message or @path/to/file"] },
+ { contains = ["@path/to/file"] },
+ { contains = ["type", "mes", "sage", "@pat", "h/to", "/fil"] },
+]
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/detect.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/detect.rs
new file mode 100644
index 000000000000..990723ec5c24
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/detect.rs
@@ -0,0 +1,1547 @@
+//! Screen-derived agent lifecycle detection.
+//!
+//! Detection semantics derived from herdr (https://github.com/herdrdev/herdr),
+//! Apache-2.0, commit `7b675f42af35508eab66ac42fe1598628597a893`, especially
+//! `src/detect/mod.rs` and `src/pane/agent_detection.rs`, modified by
+//! manaflow. First-acquisition OSC retention follows herdr commit
+//! `82e6a80eb3ae39fb3d3ebd4d1fed19389767e605` (`src/pane.rs`), adapted here
+//! as a local metadata fence because the generic host API does not let a
+//! plugin clear terminal OSC state.
+//!
+//! The plugin watches every PTY's output stream; when a terminal goes quiet
+//! (debounced), the foreground process name selects a herdr-derived manifest
+//! and the terminal tail is evaluated against it. State transitions, never
+//! per-scan states, append namespaced `agent.*` journal events, so the
+//! journal-derived roster covers agents that expose no hooks. The engine port
+//! lives in [`manifest`]; this module owns pure edge-trigger bookkeeping.
+
+use std::collections::HashMap;
+use std::time::{Duration, Instant};
+
+use crate::manifest::{Detection, ScreenState};
+
+/// States emitted by the detector. They are serialized as the generic cmux
+/// agent state strings at the journal boundary.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AgentState {
+ Working,
+ Blocked,
+ Idle,
+ Done,
+}
+
+impl AgentState {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Working => "working",
+ Self::Blocked => "blocked",
+ Self::Idle => "idle",
+ Self::Done => "done",
+ }
+ }
+}
+
+/// Output must be quiet this long before the screen is evaluated, so
+/// mid-redraw frames are rarely matched.
+pub(crate) const QUIESCENCE_DEBOUNCE_MS: u64 = 300;
+
+/// A screen that never goes quiet (agent spinners animate every ~100ms,
+/// so a working codex never quiesces) is still evaluated at this pace.
+/// Without it, quiescence gating starves detection during the exact
+/// phase it exists to report.
+pub(crate) const MAX_EVAL_INTERVAL_MS: u64 = 1_000;
+
+/// Recent PTY output is a working signal for a screen source. It upgrades an
+/// otherwise idle screen and expires through one deterministic re-evaluation.
+pub(crate) const WORKING_ACTIVITY_WINDOW_MS: u64 = 1_500;
+
+/// Herdr confirms a plain idle screen several times before replacing a
+/// working state. This avoids a single redraw frame making a live turn look
+/// complete.
+pub(crate) const PENDING_IDLE_RECHECK_MS: u64 = 100;
+pub(crate) const PENDING_IDLE_CONFIRMATIONS: u8 = 3;
+pub(crate) const PENDING_IDLE_CAP_MS: u64 = 700;
+
+/// A visible blocker is still live evidence even when its text does not
+/// change. Refreshing it keeps roster recency useful for long prompts.
+pub(crate) const STABLE_BLOCKER_REFRESH_MS: u64 = 800;
+
+/// Do not classify the first screen after a process identity edge. A shell
+/// can leave its old prompt in the viewport while the agent is starting. The
+/// process identity gives immediate presence; this grace window gives the
+/// agent time to draw its own screen before screen rules can assert state.
+pub(crate) const AGENT_STARTUP_GRACE_MS: u64 = 3_000;
+
+/// Process inspection can briefly return no foreground process while a PTY
+/// changes groups or a platform permission check races the scan. Keep the
+/// last agent through the same six consecutive misses used by herdr before
+/// treating the identity as an exit.
+pub(crate) const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6;
+
+#[derive(Debug, Clone, Default)]
+struct PendingIdle {
+ started_at: Option,
+ confirmations: u8,
+}
+
+/// The part of a tracked terminal that an emission mutates. The scanner
+/// records this snapshot before it appends to the journal. If admission fails,
+/// the snapshot is restored so the next scan can publish the same edge.
+#[derive(Debug, Clone)]
+struct TrackerSnapshot {
+ emitted: Option<(String, AgentState)>,
+ identity_presence_needed: bool,
+ visible_idle: bool,
+ visible_blocker: bool,
+ visible_working: bool,
+ last_visible_blocker_refresh: Option,
+ pending_idle: PendingIdle,
+}
+
+impl TrackerSnapshot {
+ fn capture(entry: &TrackedTerminal) -> Self {
+ Self {
+ emitted: entry.emitted.clone(),
+ identity_presence_needed: entry.identity_presence_needed,
+ visible_idle: entry.visible_idle,
+ visible_blocker: entry.visible_blocker,
+ visible_working: entry.visible_working,
+ last_visible_blocker_refresh: entry.last_visible_blocker_refresh,
+ pending_idle: entry.pending_idle.clone(),
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+struct PendingEmission {
+ emission: ScreenDetectEmission,
+ before: TrackerSnapshot,
+ after: TrackerSnapshot,
+}
+
+impl PendingEmission {
+ fn arm(entry: &mut TrackedTerminal, before: TrackerSnapshot, emission: ScreenDetectEmission) {
+ let after = TrackerSnapshot::capture(entry);
+ entry.pending_emission = Some(Self { emission, before, after });
+ }
+
+ fn matches(&self, emission: &ScreenDetectEmission) -> bool {
+ self.emission == *emission
+ }
+}
+
+impl TrackerSnapshot {
+ fn restore(self, entry: &mut TrackedTerminal) {
+ entry.emitted = self.emitted;
+ entry.identity_presence_needed = self.identity_presence_needed;
+ entry.visible_idle = self.visible_idle;
+ entry.visible_blocker = self.visible_blocker;
+ entry.visible_working = self.visible_working;
+ entry.last_visible_blocker_refresh = self.last_visible_blocker_refresh;
+ entry.pending_idle = self.pending_idle;
+ }
+}
+
+impl PendingIdle {
+ fn clear(&mut self) {
+ self.started_at = None;
+ self.confirmations = 0;
+ }
+
+ fn active(&self) -> bool {
+ self.started_at.is_some()
+ }
+
+ fn should_hold(&mut self, now: Instant) -> bool {
+ let Some(started_at) = self.started_at else {
+ self.started_at = Some(now);
+ self.confirmations = 0;
+ return true;
+ };
+ if now.duration_since(started_at).as_millis() as u64 >= PENDING_IDLE_CAP_MS {
+ self.clear();
+ return false;
+ }
+ self.confirmations = self.confirmations.saturating_add(1);
+ if self.confirmations >= PENDING_IDLE_CONFIRMATIONS {
+ self.clear();
+ false
+ } else {
+ true
+ }
+ }
+}
+
+/// Whether retained OSC metadata may be used without a new PTY revision.
+/// Herdr clears the host's OSC fields when leaving an identified agent. The
+/// cmux host API is deliberately generic and cannot perform that reset for a
+/// plugin, so the plugin models the same boundary locally.
+#[derive(Debug, Clone, Copy, Default)]
+enum OscMetadataState {
+ /// No agent has been identified on this terminal yet.
+ #[default]
+ NeverIdentified,
+ /// The first recognized agent may have emitted its title or progress
+ /// before process inspection caught up, so retained evidence stays usable.
+ FirstAgent,
+ /// A replacement or confirmed exit occurred. A revision is optional for
+ /// older hosts. A known fence fails closed when the current host omits its
+ /// revision, because the plugin cannot prove that retained OSC data is new.
+ Fenced { identity_revision: Option },
+}
+
+impl OscMetadataState {
+ fn is_fresh(self, stream_revision: Option) -> bool {
+ match self {
+ Self::NeverIdentified | Self::FirstAgent => true,
+ // Old hosts do not expose a revision. Preserve their historical
+ // compatibility behavior because there is no generation anchor
+ // to compare against.
+ Self::Fenced { identity_revision: None } => true,
+ // Once a host has supplied an anchor, missing metadata is not
+ // evidence that the retained OSC fields belong to a new process.
+ Self::Fenced { identity_revision: Some(identity_revision) } => {
+ stream_revision.is_some_and(|current| current > identity_revision)
+ }
+ }
+ }
+
+ fn fence(&mut self, stream_revision: Option) {
+ *self = Self::Fenced { identity_revision: stream_revision };
+ }
+}
+
+/// One state transition the scanner must journal.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ScreenDetectEmission {
+ pub terminal_id: String,
+ /// Manifest id of the detected agent (`codex`, `claude`, ...).
+ pub agent: String,
+ pub state: AgentState,
+ pub matched_rule: Option,
+ pub visible_idle: bool,
+ pub visible_blocker: bool,
+ pub visible_working: bool,
+}
+
+#[derive(Debug, Default)]
+struct TrackedTerminal {
+ /// Last observed output-stream revision.
+ revision: u64,
+ /// When that revision was first observed (debounce anchor).
+ quiet_since: Option,
+ /// Revision already evaluated; skip re-evaluating identical screens.
+ evaluated_revision: Option,
+ /// When the screen was last evaluated (the max-interval pacer anchor).
+ last_evaluated_at: Option,
+ /// When output last advanced the revision. The first observation only
+ /// anchors the tracker and is not treated as fresh activity.
+ last_output_at: Option,
+ /// The last evaluation used output activity to upgrade idle to working.
+ /// This creates one expiry re-evaluation even when the screen is unchanged.
+ evaluated_with_activity: bool,
+ /// Agent the foreground process matched on the previous scan; identity
+ /// edges trigger immediate evaluation, before any quiescence.
+ foreground_agent: Option,
+ /// Foreground process group for the matched agent. A replacement process
+ /// can keep the same executable name, so a group change is also an
+ /// identity edge when both probes provide a group id.
+ foreground_process_group: Option,
+ /// Deadline for the stale-screen guard after an agent identity edge.
+ startup_grace_until: Option,
+ /// First acquisition accepts retained evidence. A replacement or confirmed
+ /// exit changes this to `Fenced`, so a later process cannot inherit the
+ /// prior process's metadata.
+ osc_metadata_state: OscMetadataState,
+ /// Consecutive process probes that did not identify an agent. A positive
+ /// probe resets this counter, so a transient inspection miss cannot close
+ /// a live row.
+ foreground_misses: u8,
+ /// Last (agent, state) journaled; emissions are edges over this.
+ emitted: Option<(String, AgentState)>,
+ /// Visibility evidence from the last emitted state. It drives stable
+ /// blocker refresh without treating every evaluation as a transition.
+ visible_idle: bool,
+ visible_blocker: bool,
+ visible_working: bool,
+ last_visible_blocker_refresh: Option,
+ pending_idle: PendingIdle,
+ /// The pre-emission state until the scanner confirms journal admission.
+ /// Only one emission is in flight because appends are synchronous.
+ pending_emission: Option,
+ /// A process identity edge remains unsatisfied until its presence event
+ /// is admitted. This is separate from the last screen state because an
+ /// agent can replace another agent while both report `idle`.
+ identity_presence_needed: bool,
+ /// A failed transport keeps the exact edge available for idempotent
+ /// replay. The scanner retries this before evaluating a newer screen.
+ retry_emission: Option,
+}
+
+/// Pure edge-trigger state for the scanner. All timing is passed in, so
+/// tests drive it deterministically.
+#[derive(Debug, Default)]
+pub struct ScreenDetectTracker {
+ terminals: HashMap,
+}
+
+impl ScreenDetectTracker {
+ /// Record the terminal's current output revision. Returns `true` when
+ /// the screen changed since the last evaluation and either output has
+ /// been quiet for the debounce window or the max-interval pacer is due
+ /// (a never-quiet spinner screen still evaluates at 1Hz; quiescence
+ /// alone starves detection during the exact phase it must report). A
+ /// `true` return arms the pacer: the caller always evaluates then.
+ pub fn observe_revision(&mut self, terminal_id: &str, revision: u64, now: Instant) -> bool {
+ let entry = self.terminals.entry(terminal_id.to_string()).or_default();
+ if entry.quiet_since.is_none() {
+ entry.revision = revision;
+ entry.quiet_since = Some(now);
+ } else if entry.revision != revision {
+ entry.revision = revision;
+ entry.quiet_since = Some(now);
+ entry.last_output_at = Some(now);
+ }
+ let output_active = entry.last_output_at.is_some_and(|at| {
+ now.duration_since(at).as_millis() as u64 <= WORKING_ACTIVITY_WINDOW_MS
+ });
+ let activity_expired = entry.evaluated_with_activity && !output_active;
+ let pending_idle_due = entry.pending_idle.active()
+ && entry.last_evaluated_at.is_none_or(|at| {
+ now.duration_since(at).as_millis() as u64 >= PENDING_IDLE_RECHECK_MS
+ });
+ let stable_blocker_due = entry.visible_blocker
+ && entry.last_visible_blocker_refresh.is_none_or(|at| {
+ now.duration_since(at).as_millis() as u64 >= STABLE_BLOCKER_REFRESH_MS
+ });
+ if entry.evaluated_revision == Some(entry.revision)
+ && !activity_expired
+ && !pending_idle_due
+ && !stable_blocker_due
+ {
+ return false;
+ }
+ let quiet_since = entry.quiet_since.expect("anchored above");
+ let quiesced = now.duration_since(quiet_since).as_millis() as u64 >= QUIESCENCE_DEBOUNCE_MS;
+ let overdue = entry.last_evaluated_at.is_none_or(|evaluated_at| {
+ now.duration_since(evaluated_at).as_millis() as u64 >= MAX_EVAL_INTERVAL_MS
+ });
+ if quiesced || overdue || activity_expired || pending_idle_due || stable_blocker_due {
+ entry.last_evaluated_at = Some(now);
+ entry.evaluated_with_activity = false;
+ return true;
+ }
+ false
+ }
+
+ /// One-shot work owed by a concrete observation. A stable terminal has
+ /// no deadline, including a visible blocker: journal recency is event
+ /// time, not a heartbeat. Process uncertainty is a bounded confirmation
+ /// sequence armed by output, never a periodic process scan.
+ pub(crate) fn next_deadline(&self, terminal_id: &str, now: Instant) -> Option {
+ let entry = self.terminals.get(terminal_id)?;
+ entry.foreground_agent.as_ref()?;
+ if let Some(deadline) = entry.startup_grace_until {
+ return Some(deadline.max(now));
+ }
+ let mut deadlines = Vec::new();
+ if entry.foreground_misses > 0 && entry.foreground_misses < AGENT_MISS_CONFIRMATION_ATTEMPTS
+ {
+ deadlines.push(now + Duration::from_millis(PENDING_IDLE_RECHECK_MS));
+ }
+ if entry.evaluated_revision != Some(entry.revision) {
+ if let Some(quiet) = entry.quiet_since {
+ deadlines.push(quiet + Duration::from_millis(QUIESCENCE_DEBOUNCE_MS));
+ }
+ }
+ if entry.evaluated_with_activity {
+ if let Some(output) = entry.last_output_at {
+ deadlines.push(output + Duration::from_millis(WORKING_ACTIVITY_WINDOW_MS + 1));
+ }
+ }
+ if entry.pending_idle.active() {
+ deadlines.push(now + Duration::from_millis(PENDING_IDLE_RECHECK_MS));
+ }
+ deadlines.into_iter().min().map(|deadline| deadline.max(now))
+ }
+
+ /// Mark that the last screen evaluation used flowing PTY output to
+ /// upgrade an idle state. The tracker then owes an expiry evaluation.
+ pub(crate) fn note_activity_upgrade(&mut self, terminal_id: &str) {
+ if let Some(entry) = self.terminals.get_mut(terminal_id) {
+ entry.evaluated_with_activity = true;
+ }
+ }
+
+ /// True while PTY output has advanced within the activity window.
+ pub(crate) fn output_active(&self, terminal_id: &str, now: Instant) -> bool {
+ self.terminals.get(terminal_id).and_then(|entry| entry.last_output_at).is_some_and(|at| {
+ now.duration_since(at).as_millis() as u64 <= WORKING_ACTIVITY_WINDOW_MS
+ })
+ }
+
+ /// Return whether generic OSC metadata may be attributed to the current
+ /// foreground process. Hosts without a stream revision remain supported,
+ /// but the plugin cannot prove that their retained metadata is fresh. A
+ /// terminal with a known fence fails closed while its current revision is
+ /// missing.
+ pub(crate) fn metadata_is_fresh(
+ &self,
+ terminal_id: &str,
+ stream_revision: Option,
+ ) -> bool {
+ let Some(entry) = self.terminals.get(terminal_id) else {
+ return true;
+ };
+ // Match herdr's first-acquisition rule. A newly recognized agent may
+ // have emitted its OSC title or progress before the process probe
+ // caught up, so do not discard that evidence on the first edge.
+ entry.osc_metadata_state.is_fresh(stream_revision)
+ }
+
+ /// True when this terminal previously journaled a screen-derived state
+ /// that has not been closed out by an exit emission.
+ pub fn has_live_emission(&self, terminal_id: &str) -> bool {
+ self.terminals.get(terminal_id).is_some_and(|entry| entry.emitted.is_some())
+ }
+
+ /// Return whether the process identity still needs a durable presence
+ /// edge. This stays true after a failed append, including when the
+ /// foreground agent changed while an older agent row was live.
+ pub(crate) fn needs_identity_presence(&self, terminal_id: &str, agent: &str) -> bool {
+ self.terminals.get(terminal_id).is_none_or(|entry| {
+ entry.identity_presence_needed
+ || entry.emitted.as_ref().is_none_or(|(current_agent, _)| current_agent != agent)
+ })
+ }
+
+ /// Record which agent the foreground process currently matches. Returns
+ /// `true` on an identity edge (spawn, swap, or exit), which evaluates
+ /// the screen immediately: presence comes from the process, so the row
+ /// appears the moment `codex` starts, not after its first quiet screen.
+ pub fn note_foreground_agent(&mut self, terminal_id: &str, agent: Option<&str>) -> bool {
+ self.note_foreground_agent_at(terminal_id, agent, Instant::now())
+ }
+
+ /// Record a foreground identity edge with deterministic timing. A newly
+ /// identified process starts a grace window during which the scanner must
+ /// not interpret the previous shell or agent viewport as its state.
+ pub fn note_foreground_agent_at(
+ &mut self,
+ terminal_id: &str,
+ agent: Option<&str>,
+ now: Instant,
+ ) -> bool {
+ self.note_foreground_job_at(terminal_id, agent, None, now)
+ }
+
+ /// Record a foreground identity and, when available, its process group.
+ /// A same-name process replacement is an edge only when both observations
+ /// carry a group id. Missing group data must not manufacture a restart.
+ pub fn note_foreground_job_at(
+ &mut self,
+ terminal_id: &str,
+ agent: Option<&str>,
+ process_group_id: Option,
+ now: Instant,
+ ) -> bool {
+ self.note_foreground_job_at_with_revision(terminal_id, agent, process_group_id, None, now)
+ }
+
+ /// Record a foreground identity edge and the host stream revision seen at
+ /// that edge. On replacement edges, the revision lets the userland
+ /// detector reject OSC title or progress retained from the previous
+ /// process without adding agent semantics to the host metadata API. The
+ /// first acquisition keeps evidence that may have arrived before probing.
+ pub(crate) fn note_foreground_job_at_with_revision(
+ &mut self,
+ terminal_id: &str,
+ agent: Option<&str>,
+ process_group_id: Option,
+ stream_revision: Option,
+ now: Instant,
+ ) -> bool {
+ let entry = self.terminals.entry(terminal_id.to_string()).or_default();
+ match agent {
+ Some(agent) => {
+ // A successful probe confirms the existing identity and
+ // cancels any transient-miss window.
+ entry.foreground_misses = 0;
+ let agent_changed = entry.foreground_agent.as_deref() != Some(agent);
+ let process_group_changed = matches!(
+ (entry.foreground_process_group, process_group_id),
+ (Some(previous), Some(current)) if previous != current
+ );
+ if !agent_changed && !process_group_changed {
+ // A platform can expose the group only after the first
+ // probe. Enrich the identity without restarting grace.
+ if process_group_id.is_some() {
+ entry.foreground_process_group = process_group_id;
+ }
+ // Likewise, an older daemon can begin exposing the
+ // revision after the identity was established. Anchor it
+ // once, so retained metadata is fenced as soon as the
+ // host provides the evidence needed to fence it.
+ if let OscMetadataState::Fenced { identity_revision } =
+ &mut entry.osc_metadata_state
+ && identity_revision.is_none()
+ {
+ *identity_revision = stream_revision;
+ }
+ return false;
+ }
+ // A first acquisition keeps OSC evidence already emitted by
+ // the process. Once any agent was identified, a replacement
+ // must wait for a newer stream revision, including after a
+ // confirmed exit where the host could not clear its state.
+ let first_acquisition = entry.foreground_agent.is_none()
+ && entry.emitted.is_none()
+ && matches!(entry.osc_metadata_state, OscMetadataState::NeverIdentified);
+ if first_acquisition {
+ entry.osc_metadata_state = OscMetadataState::FirstAgent;
+ } else {
+ entry.osc_metadata_state.fence(stream_revision);
+ }
+ }
+ None => {
+ let Some(_) = entry.foreground_agent else {
+ entry.foreground_misses = 0;
+ return false;
+ };
+ entry.foreground_misses = entry.foreground_misses.saturating_add(1);
+ if entry.foreground_misses < AGENT_MISS_CONFIRMATION_ATTEMPTS {
+ return false;
+ }
+ // The identity is actually gone. Clear the counter before
+ // publishing the edge so a later agent starts cleanly.
+ entry.foreground_misses = 0;
+ // The host cannot clear its retained OSC fields on this edge.
+ // Preserve a fence so the next acquisition cannot inherit the
+ // first agent's metadata.
+ entry.osc_metadata_state.fence(stream_revision);
+ }
+ }
+ entry.foreground_agent = agent.map(str::to_string);
+ entry.foreground_process_group = agent.and(process_group_id);
+ entry.identity_presence_needed = agent.is_some();
+ entry.startup_grace_until =
+ agent.map(|_| now + Duration::from_millis(AGENT_STARTUP_GRACE_MS));
+ // A process identity edge invalidates the prior screen evaluation.
+ // If the first read for the new process fails, the next scan must
+ // retry even when the PTY revision did not change.
+ entry.evaluated_revision = None;
+ // Do not carry shell or previous-agent output activity across the
+ // identity edge. New PTY output during the grace window will re-arm
+ // this signal through observe_revision.
+ entry.last_output_at = None;
+ entry.evaluated_with_activity = false;
+ entry.pending_idle.clear();
+ true
+ }
+
+ /// The last identity that survived the miss-confirmation window. The
+ /// scanner uses this to distinguish a transient process-query miss from
+ /// a confirmed agent exit without exposing process policy to core.
+ pub(crate) fn foreground_agent(&self, terminal_id: &str) -> Option<&str> {
+ self.terminals.get(terminal_id).and_then(|entry| entry.foreground_agent.as_deref())
+ }
+
+ /// Returns `true` while the stale-screen guard is active.
+ pub(crate) fn startup_grace_active(&self, terminal_id: &str, now: Instant) -> bool {
+ self.terminals
+ .get(terminal_id)
+ .and_then(|entry| entry.startup_grace_until)
+ .is_some_and(|until| now < until)
+ }
+
+ /// End an expired startup grace window and force one screen evaluation.
+ /// The return value is edge-triggered, so a steady process does not cause
+ /// repeated forced reads after the deadline.
+ pub(crate) fn finish_startup_grace(&mut self, terminal_id: &str, now: Instant) -> bool {
+ let Some(entry) = self.terminals.get_mut(terminal_id) else {
+ return false;
+ };
+ let Some(until) = entry.startup_grace_until else {
+ return false;
+ };
+ if now < until {
+ return false;
+ }
+ entry.startup_grace_until = None;
+ entry.evaluated_revision = None;
+ entry.pending_idle.clear();
+ true
+ }
+
+ /// Emit presence from process identity without reading the viewport.
+ /// This keeps the roster responsive while the startup grace window blocks
+ /// stale screen classification.
+ pub(crate) fn record_identity_presence_at(
+ &mut self,
+ terminal_id: &str,
+ agent: &str,
+ _now: Instant,
+ ) -> Option {
+ let entry = self.terminals.entry(terminal_id.to_string()).or_default();
+ entry.pending_emission = None;
+ // A direct tracker caller may advance state without the scanner. In
+ // that case an old retry is superseded; the scanner retries first.
+ entry.retry_emission = None;
+ let before = TrackerSnapshot::capture(entry);
+ entry.pending_idle.clear();
+ entry.visible_idle = false;
+ entry.visible_blocker = false;
+ entry.visible_working = false;
+ entry.last_visible_blocker_refresh = None;
+ let next = (agent.to_string(), AgentState::Idle);
+ if entry.emitted.as_ref() == Some(&next) && !entry.identity_presence_needed {
+ entry.identity_presence_needed = false;
+ return None;
+ }
+ entry.emitted = Some(next);
+ let emission = ScreenDetectEmission {
+ terminal_id: terminal_id.to_string(),
+ agent: agent.to_string(),
+ state: AgentState::Idle,
+ matched_rule: None,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ entry.identity_presence_needed = false;
+ PendingEmission::arm(entry, before, emission.clone());
+ Some(emission)
+ }
+
+ /// Fold one evaluated detection. `None` detection means the foreground
+ /// process is not a supported agent (or is gone): a live screen-derived
+ /// entry is closed with a session-ended-equivalent `Done` emission.
+ pub fn record_detection(
+ &mut self,
+ terminal_id: &str,
+ detection: Option<(&str, Detection)>,
+ ) -> Option {
+ self.record_detection_at(terminal_id, detection, Instant::now(), false, false)
+ }
+
+ /// Record one evaluated screen with explicit timing and lifecycle edges.
+ /// The scanner uses this method; the timing-free wrapper above keeps the
+ /// pure state API convenient for callers that only need edge folding.
+ pub fn record_detection_at(
+ &mut self,
+ terminal_id: &str,
+ detection: Option<(&str, Detection)>,
+ now: Instant,
+ identity_edge: bool,
+ process_exited: bool,
+ ) -> Option {
+ self.record_detection_at_with_revision(
+ terminal_id,
+ detection,
+ now,
+ identity_edge,
+ process_exited,
+ None,
+ )
+ }
+
+ /// Record one evaluated screen and, when the host supplied one, the
+ /// daemon's output revision at the lifecycle edge. The local `revision`
+ /// field is only a scheduling key and must never be used as an OSC fence.
+ pub(crate) fn record_detection_at_with_revision(
+ &mut self,
+ terminal_id: &str,
+ detection: Option<(&str, Detection)>,
+ now: Instant,
+ identity_edge: bool,
+ process_exited: bool,
+ stream_revision: Option,
+ ) -> Option {
+ let entry = self.terminals.entry(terminal_id.to_string()).or_default();
+ entry.pending_emission = None;
+ // See the identity-presence path above. A scanner retry is handled
+ // before this method is called, so a fresh direct fold can replace it.
+ entry.retry_emission = None;
+ let before = TrackerSnapshot::capture(entry);
+ if process_exited {
+ // A terminal exit is authoritative. Do not retain the identity
+ // or its startup grace when the PTY has gone away.
+ if entry.foreground_agent.is_some() {
+ // `entry.revision` may be a local screen hash on older hosts.
+ // Only a host-provided stream revision can establish the
+ // post-exit generation boundary.
+ entry.osc_metadata_state.fence(stream_revision);
+ }
+ entry.foreground_agent = None;
+ entry.foreground_process_group = None;
+ entry.foreground_misses = 0;
+ entry.startup_grace_until = None;
+ entry.identity_presence_needed = false;
+ }
+ entry.evaluated_revision = Some(entry.revision);
+ let Some((agent, detection)) = detection else {
+ entry.pending_idle.clear();
+ entry.visible_idle = false;
+ entry.visible_blocker = false;
+ entry.visible_working = false;
+ entry.last_visible_blocker_refresh = None;
+ let (agent, _) = entry.emitted.take()?;
+ let emission = ScreenDetectEmission {
+ terminal_id: terminal_id.to_string(),
+ agent,
+ state: AgentState::Done,
+ matched_rule: None,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ PendingEmission::arm(entry, before, emission.clone());
+ return Some(emission);
+ };
+ let asserted = if detection.skip_state_update {
+ // Agent-owned viewer (transcript scroll etc.): keep prior state.
+ None
+ } else {
+ match detection.state {
+ ScreenState::Working => Some(AgentState::Working),
+ ScreenState::Blocked => Some(AgentState::Blocked),
+ ScreenState::Idle => Some(AgentState::Idle),
+ // A matched unknown-state rule asserts nothing.
+ ScreenState::Unknown => None,
+ }
+ };
+ let state = match (asserted, &entry.emitted) {
+ (Some(state), _) => state,
+ // The screen asserts nothing but the process IS the agent:
+ // presence must not wait for a stable screen, so the first
+ // emission for a terminal is idle until a later scan refines.
+ (None, None) => AgentState::Idle,
+ // A live emission keeps its prior state through viewer screens.
+ (None, Some(_)) => return None,
+ };
+ let visible_idle = detection.visible_idle && state == AgentState::Idle;
+ let visible_blocker = detection.visible_blocker && state == AgentState::Blocked;
+ let visible_working = detection.visible_working && state == AgentState::Working;
+ let previous_state = entry.emitted.as_ref().map(|(_, state)| *state);
+ let plain_working_to_idle = previous_state == Some(AgentState::Working)
+ && state == AgentState::Idle
+ && !visible_idle
+ && !visible_blocker
+ && !identity_edge
+ && !process_exited;
+ if plain_working_to_idle {
+ if entry.pending_idle.should_hold(now) {
+ return None;
+ }
+ } else {
+ entry.pending_idle.clear();
+ }
+ let next = (agent.to_string(), state);
+ let stable_blocker_refresh = next.1 == AgentState::Blocked
+ && visible_blocker
+ && entry.visible_blocker
+ && entry.last_visible_blocker_refresh.is_none_or(|at| {
+ now.duration_since(at).as_millis() as u64 >= STABLE_BLOCKER_REFRESH_MS
+ });
+ if entry.emitted.as_ref() == Some(&next) && !stable_blocker_refresh {
+ return None;
+ }
+ entry.emitted = Some(next);
+ entry.visible_idle = visible_idle;
+ entry.visible_blocker = visible_blocker;
+ entry.visible_working = visible_working;
+ entry.last_visible_blocker_refresh = visible_blocker.then_some(now);
+ let emission = ScreenDetectEmission {
+ terminal_id: terminal_id.to_string(),
+ agent: agent.to_string(),
+ state,
+ matched_rule: detection.matched_rule,
+ visible_idle,
+ visible_blocker,
+ visible_working,
+ };
+ PendingEmission::arm(entry, before, emission.clone());
+ Some(emission)
+ }
+
+ /// Mark an emission durable. The tracker keeps no pending transaction
+ /// after a successful journal append.
+ pub(crate) fn commit_emission(&mut self, emission: &ScreenDetectEmission) {
+ let Some(entry) = self.terminals.get_mut(&emission.terminal_id) else { return };
+ if let Some(pending) = entry.pending_emission.take() {
+ if pending.matches(emission) {
+ // The initial append already left the tracker in this state.
+ // The restore also makes this method correct for a replayed
+ // retry.
+ pending.after.restore(entry);
+ return;
+ }
+ // A late callback for another edge must not consume the current
+ // transaction.
+ entry.pending_emission = Some(pending);
+ }
+ if let Some(retry) = entry.retry_emission.take() {
+ if retry.matches(emission) {
+ retry.after.restore(entry);
+ return;
+ }
+ // A late callback for another edge must not consume the retry.
+ entry.retry_emission = Some(retry);
+ }
+ }
+
+ /// Undo an edge when journal admission fails. The next scan must be able
+ /// to emit the same transition again instead of treating it as delivered.
+ pub(crate) fn rollback_emission(&mut self, emission: &ScreenDetectEmission) {
+ let Some(entry) = self.terminals.get_mut(&emission.terminal_id) else { return };
+ if let Some(pending) = entry.pending_emission.take() {
+ if pending.matches(emission) {
+ pending.before.clone().restore(entry);
+ entry.retry_emission = Some(pending);
+ // Force a fresh evaluation even when the PTY revision did not
+ // move. The retry remains pending until its exact envelope
+ // is accepted or explicitly discarded.
+ entry.evaluated_revision = None;
+ return;
+ }
+ entry.pending_emission = Some(pending);
+ }
+ if entry.retry_emission.as_ref().is_some_and(|pending| pending.matches(emission)) {
+ entry.evaluated_revision = None;
+ return;
+ }
+ // Keep the retry safe if a caller supplies an emission created by an
+ // older tracker that did not retain a snapshot.
+ entry.evaluated_revision = None;
+ }
+
+ /// Drop an emission after a definite admission failure. Uncertain
+ /// transport failures use `rollback_emission` and retain the retry.
+ pub(crate) fn discard_emission(&mut self, emission: &ScreenDetectEmission) {
+ let Some(entry) = self.terminals.get_mut(&emission.terminal_id) else { return };
+ if entry.pending_emission.as_ref().is_some_and(|pending| pending.matches(emission)) {
+ entry.pending_emission = None;
+ }
+ if entry.retry_emission.as_ref().is_some_and(|pending| pending.matches(emission)) {
+ entry.retry_emission = None;
+ }
+ }
+
+ /// Drop terminals that left the session. Closed terminals are retired
+ /// from the roster by the terminal lifecycle, not by an exit emission.
+ pub fn retain_terminals(&mut self, live: impl Fn(&str) -> bool) {
+ self.terminals.retain(|terminal_id, _| live(terminal_id));
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::{HashSet, hash_map::DefaultHasher};
+ use std::hash::BuildHasher;
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use std::time::Duration;
+
+ #[derive(Clone)]
+ struct CountingBuildHasher {
+ builds: Arc,
+ }
+
+ impl BuildHasher for CountingBuildHasher {
+ type Hasher = DefaultHasher;
+
+ fn build_hasher(&self) -> Self::Hasher {
+ self.builds.fetch_add(1, Ordering::Relaxed);
+ DefaultHasher::new()
+ }
+ }
+
+ fn detection(state: ScreenState) -> Detection {
+ Detection {
+ state,
+ skip_state_update: false,
+ matched_rule: Some("rule".into()),
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ }
+ }
+
+ #[test]
+ fn screen_detect_tracker_debounces_quiescence_per_revision() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+
+ // A never-evaluated terminal evaluates on first sight.
+ assert!(tracker.observe_revision("term_a", 1, t0));
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle))));
+ // An unchanged screen never re-evaluates.
+ assert!(!tracker.observe_revision("term_a", 1, at(400)));
+
+ // New output re-arms the debounce; inside the window with a recent
+ // evaluation nothing fires.
+ assert!(!tracker.observe_revision("term_a", 2, at(500)));
+ assert!(!tracker.observe_revision("term_a", 2, at(700)));
+ // Quiet long enough: evaluate exactly once per revision.
+ assert!(tracker.observe_revision("term_a", 2, at(900)));
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle))));
+ assert!(!tracker.observe_revision("term_a", 2, at(1_100)));
+ }
+
+ #[test]
+ fn screen_detect_activity_expires_with_one_recheck() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+
+ assert!(tracker.observe_revision("term_a", 1, t0));
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle))));
+ assert!(!tracker.observe_revision("term_a", 2, at(400)));
+ assert!(tracker.observe_revision("term_a", 2, at(800)));
+ assert!(tracker.output_active("term_a", at(800)));
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working))));
+ tracker.note_activity_upgrade("term_a");
+
+ assert!(!tracker.observe_revision("term_a", 2, at(1_200)));
+ assert!(tracker.observe_revision("term_a", 2, at(2_301)));
+ assert!(!tracker.output_active("term_a", at(2_301)));
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle))));
+ assert!(!tracker.observe_revision("term_a", 2, at(2_400)));
+ }
+
+ #[test]
+ fn screen_detect_tracker_paces_evaluation_of_never_quiet_spinner_screens() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+
+ assert!(tracker.observe_revision("term_a", 1, t0));
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Idle))));
+
+ // A working spinner redraws every ~100ms, so the screen never goes
+ // quiet for the debounce window. The pacer still evaluates at 1Hz;
+ // without it a working codex would stay idle forever.
+ let mut evaluations = 0;
+ for tick in 1..=25u64 {
+ if tracker.observe_revision("term_a", 1 + tick, at(tick * 100)) {
+ evaluations += 1;
+ tracker
+ .record_detection("term_a", Some(("codex", detection(ScreenState::Working))));
+ }
+ }
+ assert_eq!(evaluations, 2, "1Hz pacer under 2.5s of continuous output");
+ assert!(tracker.has_live_emission("term_a"));
+ }
+
+ #[test]
+ fn screen_detect_tracker_emits_only_state_edges() {
+ let mut tracker = ScreenDetectTracker::default();
+
+ let first =
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working))));
+ assert_eq!(
+ first,
+ Some(ScreenDetectEmission {
+ terminal_id: "term_a".into(),
+ agent: "codex".into(),
+ state: AgentState::Working,
+ matched_rule: Some("rule".into()),
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ })
+ );
+ // Same state again: no event (edge-triggered, never per-scan).
+ let repeat =
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working))));
+ assert_eq!(repeat, None);
+ // Transition to blocked emits.
+ let blocked =
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Blocked))));
+ assert_eq!(blocked.map(|emission| emission.state), Some(AgentState::Blocked));
+ }
+
+ #[test]
+ fn failed_journal_admission_rolls_back_an_edge_for_retry() {
+ let mut tracker = ScreenDetectTracker::default();
+
+ let first = tracker
+ .record_detection("term_a", Some(("codex", detection(ScreenState::Working))))
+ .expect("first edge");
+ tracker.commit_emission(&first);
+
+ let blocked = tracker
+ .record_detection("term_a", Some(("codex", detection(ScreenState::Blocked))))
+ .expect("blocked edge");
+ tracker.rollback_emission(&blocked);
+
+ let retry = tracker
+ .record_detection("term_a", Some(("codex", detection(ScreenState::Blocked))))
+ .expect("a rejected edge must be retried");
+ assert_eq!(retry.state, AgentState::Blocked);
+ assert_eq!(retry.agent, "codex");
+ }
+
+ #[test]
+ fn replayed_edge_commits_the_post_state_after_rollback() {
+ let mut tracker = ScreenDetectTracker::default();
+ let working = tracker
+ .record_detection("term_a", Some(("codex", detection(ScreenState::Working))))
+ .expect("working edge");
+ tracker.commit_emission(&working);
+
+ let blocked = tracker
+ .record_detection("term_a", Some(("codex", detection(ScreenState::Blocked))))
+ .expect("blocked edge");
+ tracker.rollback_emission(&blocked);
+ tracker.commit_emission(&blocked);
+
+ assert_eq!(
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Blocked)))),
+ None,
+ "a successful replay must commit the edge exactly once",
+ );
+ }
+
+ #[test]
+ fn committed_edges_ignore_a_late_rollback() {
+ let mut tracker = ScreenDetectTracker::default();
+ let working = tracker
+ .record_detection("term_a", Some(("codex", detection(ScreenState::Working))))
+ .expect("working edge");
+ tracker.commit_emission(&working);
+ tracker.rollback_emission(&working);
+
+ assert_eq!(
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working)))),
+ None,
+ "a committed edge must not be undone by a late failure callback",
+ );
+ }
+
+ #[test]
+ fn screen_detect_tracker_confirms_plain_idle_before_downgrading_working() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+ tracker.record_detection_at(
+ "term_a",
+ Some(("codex", detection(ScreenState::Working))),
+ t0,
+ false,
+ false,
+ );
+
+ // Three 100 ms rechecks are held. The fourth confirms idle.
+ assert!(
+ tracker
+ .record_detection_at(
+ "term_a",
+ Some(("codex", detection(ScreenState::Idle))),
+ at(0),
+ false,
+ false,
+ )
+ .is_none()
+ );
+ assert!(
+ tracker
+ .record_detection_at(
+ "term_a",
+ Some(("codex", detection(ScreenState::Idle))),
+ at(PENDING_IDLE_RECHECK_MS),
+ false,
+ false,
+ )
+ .is_none()
+ );
+ assert!(
+ tracker
+ .record_detection_at(
+ "term_a",
+ Some(("codex", detection(ScreenState::Idle))),
+ at(PENDING_IDLE_RECHECK_MS * 2),
+ false,
+ false,
+ )
+ .is_none()
+ );
+ assert_eq!(
+ tracker
+ .record_detection_at(
+ "term_a",
+ Some(("codex", detection(ScreenState::Idle))),
+ at(PENDING_IDLE_RECHECK_MS * 3),
+ false,
+ false,
+ )
+ .map(|emission| emission.state),
+ Some(AgentState::Idle)
+ );
+ }
+
+ #[test]
+ fn screen_detect_tracker_refreshes_a_stable_visible_blocker() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let blocked = Detection {
+ state: ScreenState::Blocked,
+ skip_state_update: false,
+ matched_rule: Some("confirm".into()),
+ visible_idle: false,
+ visible_blocker: true,
+ visible_working: false,
+ };
+ assert!(
+ tracker
+ .record_detection_at("term_a", Some(("codex", blocked.clone())), t0, false, false)
+ .is_some()
+ );
+ assert!(
+ tracker
+ .record_detection_at(
+ "term_a",
+ Some(("codex", blocked.clone())),
+ t0 + Duration::from_millis(STABLE_BLOCKER_REFRESH_MS - 1),
+ false,
+ false,
+ )
+ .is_none()
+ );
+ assert!(
+ tracker
+ .record_detection_at(
+ "term_a",
+ Some(("codex", blocked)),
+ t0 + Duration::from_millis(STABLE_BLOCKER_REFRESH_MS),
+ false,
+ false,
+ )
+ .is_some()
+ );
+ }
+
+ #[test]
+ fn screen_detect_tracker_closes_departed_agents_with_done() {
+ let mut tracker = ScreenDetectTracker::default();
+ assert!(
+ tracker.record_detection("term_a", None).is_none(),
+ "a terminal that never emitted stays silent"
+ );
+
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working))));
+ assert!(tracker.has_live_emission("term_a"));
+ let done = tracker.record_detection("term_a", None);
+ assert_eq!(
+ done,
+ Some(ScreenDetectEmission {
+ terminal_id: "term_a".into(),
+ agent: "codex".into(),
+ state: AgentState::Done,
+ matched_rule: None,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ })
+ );
+ assert!(!tracker.has_live_emission("term_a"));
+ assert_eq!(tracker.record_detection("term_a", None), None, "done is an edge too");
+ }
+
+ #[test]
+ fn screen_detect_tracker_keeps_prior_state_for_viewers_and_unknowns() {
+ let mut tracker = ScreenDetectTracker::default();
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working))));
+
+ let viewer = Detection {
+ state: ScreenState::Unknown,
+ skip_state_update: true,
+ matched_rule: Some("transcript_viewer".into()),
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ assert_eq!(tracker.record_detection("term_a", Some(("codex", viewer))), None);
+
+ let unknown = detection(ScreenState::Unknown);
+ assert_eq!(tracker.record_detection("term_a", Some(("codex", unknown))), None);
+ assert!(tracker.has_live_emission("term_a"), "working emission still owns the terminal");
+ }
+
+ #[test]
+ fn screen_detect_tracker_flags_identity_edges_for_immediate_evaluation() {
+ let mut tracker = ScreenDetectTracker::default();
+ // Shell pane: no agent means no edge, first scan included.
+ assert!(!tracker.note_foreground_agent("term_a", None));
+ assert!(!tracker.note_foreground_agent("term_a", None));
+ // codex launches: edge fires once, then the identity is steady.
+ assert!(tracker.note_foreground_agent("term_a", Some("codex")));
+ assert!(!tracker.note_foreground_agent("term_a", Some("codex")));
+ // Swapping agents in place is an edge, and so is exiting.
+ assert!(tracker.note_foreground_agent("term_a", Some("claude")));
+ for attempt in 1..AGENT_MISS_CONFIRMATION_ATTEMPTS {
+ assert!(
+ !tracker.note_foreground_agent("term_a", None),
+ "miss {attempt} must stay inside the confirmation window"
+ );
+ assert_eq!(tracker.foreground_agent("term_a"), Some("claude"));
+ }
+ assert!(tracker.note_foreground_agent("term_a", None));
+ assert_eq!(tracker.foreground_agent("term_a"), None);
+ }
+
+ #[test]
+ fn identity_edge_retries_a_failed_screen_read_without_new_output() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+
+ // Establish a previously evaluated screen for one foreground agent.
+ assert!(tracker.note_foreground_agent_at("term_a", Some("claude"), t0));
+ assert!(tracker.observe_revision("term_a", 1, t0));
+ tracker.record_detection_at(
+ "term_a",
+ Some(("claude", detection(ScreenState::Working))),
+ t0,
+ false,
+ false,
+ );
+
+ // The process changes, but the first read after the edge is assumed
+ // to fail. The retry must still be armed by the identity edge.
+ assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), at(100)));
+ assert!(
+ tracker.observe_revision("term_a", 1, at(QUIESCENCE_DEBOUNCE_MS)),
+ "an identity edge must invalidate the old evaluated revision"
+ );
+ }
+
+ #[test]
+ fn screen_detect_tracker_resets_identity_misses_on_a_positive_probe() {
+ let mut tracker = ScreenDetectTracker::default();
+ assert!(tracker.note_foreground_agent("term_a", Some("codex")));
+ for _ in 0..AGENT_MISS_CONFIRMATION_ATTEMPTS - 1 {
+ assert!(!tracker.note_foreground_agent("term_a", None));
+ }
+ // A successful process probe prevents the previous misses from
+ // carrying into the next disappearance window.
+ assert!(!tracker.note_foreground_agent("term_a", Some("codex")));
+ for _ in 0..AGENT_MISS_CONFIRMATION_ATTEMPTS - 1 {
+ assert!(!tracker.note_foreground_agent("term_a", None));
+ }
+ assert!(tracker.note_foreground_agent("term_a", None));
+ }
+
+ #[test]
+ fn screen_detect_tracker_process_exit_clears_identity_even_without_a_done_row() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), t0));
+ assert!(tracker.record_detection_at("term_a", None, t0, true, true).is_none());
+ assert_eq!(tracker.foreground_agent("term_a"), None);
+ assert!(!tracker.startup_grace_active("term_a", t0 + Duration::from_secs(4)));
+ }
+
+ #[test]
+ fn screen_detect_tracker_graces_new_identity_before_reading_the_screen() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+
+ assert!(tracker.observe_revision("term_a", 1, t0));
+ tracker.record_detection("term_a", Some(("codex", detection(ScreenState::Working))));
+ assert!(!tracker.observe_revision("term_a", 2, at(100)));
+ assert!(tracker.output_active("term_a", at(100)));
+ assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), t0));
+ assert!(!tracker.output_active("term_a", at(100)));
+ assert!(tracker.startup_grace_active("term_a", at(AGENT_STARTUP_GRACE_MS - 1)));
+ assert!(!tracker.startup_grace_active("term_a", at(AGENT_STARTUP_GRACE_MS)));
+
+ // Presence is published without screen evidence. The scanner's grace
+ // check prevents a stale shell prompt from reaching record_detection.
+ assert_eq!(
+ tracker
+ .record_identity_presence_at("term_a", "codex", t0)
+ .map(|emission| emission.state),
+ Some(AgentState::Idle)
+ );
+ assert!(!tracker.needs_identity_presence("term_a", "codex"));
+ tracker.rollback_emission(&ScreenDetectEmission {
+ terminal_id: "term_a".into(),
+ agent: "codex".into(),
+ state: AgentState::Idle,
+ matched_rule: None,
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ });
+ assert!(tracker.needs_identity_presence("term_a", "codex"));
+
+ // The scanner calls finish once the deadline passes. It clears the
+ // evaluated revision so an unchanged viewport is read exactly once.
+ assert!(tracker.finish_startup_grace("term_a", at(AGENT_STARTUP_GRACE_MS)));
+ assert!(!tracker.finish_startup_grace("term_a", at(AGENT_STARTUP_GRACE_MS + 1)));
+ assert!(!tracker.startup_grace_active("term_a", at(AGENT_STARTUP_GRACE_MS + 1)));
+ }
+
+ #[test]
+ fn screen_detect_tracker_restarts_grace_for_an_agent_swap() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+
+ assert!(tracker.note_foreground_agent_at("term_a", Some("codex"), t0));
+ assert!(!tracker.note_foreground_agent_at("term_a", Some("codex"), at(500)));
+ assert!(tracker.note_foreground_agent_at("term_a", Some("claude"), at(700)));
+ assert!(tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS - 1)));
+ assert!(!tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS)));
+ }
+
+ #[test]
+ fn screen_detect_tracker_restarts_grace_for_same_agent_process_replacement() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+ let at = |milliseconds: u64| t0 + Duration::from_millis(milliseconds);
+
+ assert!(tracker.note_foreground_job_at("term_a", Some("codex"), Some(41), t0));
+ assert!(!tracker.note_foreground_job_at("term_a", Some("codex"), Some(41), at(500)));
+ assert!(tracker.note_foreground_job_at("term_a", Some("codex"), Some(42), at(700)));
+ assert!(tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS - 1)));
+ assert!(!tracker.startup_grace_active("term_a", at(700 + AGENT_STARTUP_GRACE_MS)));
+ }
+
+ #[test]
+ fn same_agent_process_replacement_republishes_idle_presence() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+
+ assert!(tracker.note_foreground_job_at("term_a", Some("codex"), Some(41), t0));
+ let first = tracker.record_identity_presence_at("term_a", "codex", t0).unwrap();
+ tracker.commit_emission(&first);
+ assert!(!tracker.needs_identity_presence("term_a", "codex"));
+
+ assert!(tracker.note_foreground_job_at(
+ "term_a",
+ Some("codex"),
+ Some(42),
+ t0 + Duration::from_millis(1),
+ ));
+ assert!(tracker.needs_identity_presence("term_a", "codex"));
+ let replacement = tracker
+ .record_identity_presence_at("term_a", "codex", t0 + Duration::from_millis(1))
+ .expect("replacement must emit presence even when state stays idle");
+ assert_eq!(replacement.agent, "codex");
+ assert_eq!(replacement.state, AgentState::Idle);
+ }
+
+ #[test]
+ fn screen_detect_tracker_keeps_first_acquisition_osc_evidence_and_fences_replacements() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+
+ assert!(tracker.note_foreground_job_at_with_revision(
+ "term_a",
+ Some("codex"),
+ None,
+ Some(41),
+ t0,
+ ));
+ // Herdr keeps evidence emitted before the first process probe. The
+ // userland equivalent does not require a revision on this edge.
+ assert!(tracker.metadata_is_fresh("term_a", Some(41)));
+ assert!(tracker.metadata_is_fresh("term_a", Some(40)));
+
+ // A replacement must not inherit the previous process's OSC fields.
+ assert!(tracker.note_foreground_job_at_with_revision(
+ "term_a",
+ Some("claude"),
+ None,
+ Some(41),
+ t0,
+ ));
+ assert!(!tracker.metadata_is_fresh("term_a", Some(41)));
+ assert!(!tracker.metadata_is_fresh("term_a", None));
+ assert!(tracker.metadata_is_fresh("term_a", Some(42)));
+
+ // A confirmed exit also leaves a fence. The host cannot clear its
+ // retained fields, so the next acquisition waits for new output.
+ for attempt in 0..AGENT_MISS_CONFIRMATION_ATTEMPTS {
+ let edge =
+ tracker.note_foreground_job_at_with_revision("term_a", None, None, Some(42), t0);
+ assert_eq!(edge, attempt + 1 == AGENT_MISS_CONFIRMATION_ATTEMPTS);
+ }
+ assert!(tracker.note_foreground_job_at_with_revision(
+ "term_a",
+ Some("codex"),
+ None,
+ Some(42),
+ t0,
+ ));
+ assert!(!tracker.metadata_is_fresh("term_a", Some(42)));
+ assert!(tracker.metadata_is_fresh("term_a", Some(43)));
+
+ // Once this terminal has supplied a generation anchor, a missing
+ // revision cannot prove that retained metadata belongs to the new
+ // process. Fail closed until the host reports a newer revision.
+ assert!(!tracker.metadata_is_fresh("term_a", None));
+
+ // If the catalog omitted the revision on a first acquisition, a later
+ // revision does not change the first-acquisition policy. The evidence
+ // may have been emitted before the process probe caught up.
+ assert!(tracker.note_foreground_job_at_with_revision(
+ "term_b",
+ Some("codex"),
+ None,
+ None,
+ t0,
+ ));
+ assert!(!tracker.note_foreground_job_at_with_revision(
+ "term_b",
+ Some("codex"),
+ None,
+ Some(41),
+ t0,
+ ));
+ assert!(tracker.metadata_is_fresh("term_b", Some(41)));
+ assert!(tracker.metadata_is_fresh("term_b", Some(42)));
+ }
+
+ #[test]
+ fn screen_detect_tracker_keeps_first_acquisition_without_revision_unfenced() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+
+ assert!(tracker.note_foreground_job_at_with_revision(
+ "term_a",
+ Some("codex"),
+ None,
+ None,
+ t0,
+ ));
+ assert!(tracker.metadata_is_fresh("term_a", None));
+
+ // Once a replacement is observed, a later revision enriches the
+ // identity and starts the fence even if the edge had no revision.
+ assert!(tracker.note_foreground_job_at_with_revision(
+ "term_a",
+ Some("claude"),
+ None,
+ None,
+ t0,
+ ));
+ assert!(!tracker.note_foreground_job_at_with_revision(
+ "term_a",
+ Some("claude"),
+ None,
+ Some(9),
+ t0,
+ ));
+ assert!(!tracker.metadata_is_fresh("term_a", Some(9)));
+ assert!(tracker.metadata_is_fresh("term_a", Some(10)));
+ }
+
+ #[test]
+ fn screen_detect_exit_does_not_use_local_scheduler_revision_as_osc_fence() {
+ let mut tracker = ScreenDetectTracker::default();
+ let t0 = Instant::now();
+
+ // A daemon without stream revisions still uses a local screen key to
+ // schedule reads. That key is not evidence about PTY generations.
+ assert!(tracker.observe_revision("term_a", 7, t0));
+ assert!(tracker.note_foreground_job_at_with_revision(
+ "term_a",
+ Some("codex"),
+ None,
+ None,
+ t0,
+ ));
+ let started = tracker
+ .record_detection_at(
+ "term_a",
+ Some(("codex", detection(ScreenState::Working))),
+ t0,
+ true,
+ false,
+ )
+ .expect("agent state edge");
+ tracker.commit_emission(&started);
+
+ // The exit has no host revision. The compatibility path must remain
+ // open; the local scheduler key must not become a durable fence.
+ let ended = tracker
+ .record_detection_at_with_revision("term_a", None, t0, true, true, None)
+ .expect("exit edge");
+ tracker.commit_emission(&ended);
+ assert!(tracker.metadata_is_fresh("term_a", Some(1)));
+ }
+
+ #[test]
+ fn screen_detect_tracker_emits_idle_presence_when_the_first_screen_asserts_nothing() {
+ let mut tracker = ScreenDetectTracker::default();
+ // First evaluation right after spawn hits a viewer/unknown screen:
+ // presence must not wait for a stable screen.
+ let viewer = Detection {
+ state: ScreenState::Unknown,
+ skip_state_update: true,
+ matched_rule: Some("transcript_viewer".into()),
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ };
+ let presence = tracker.record_detection("term_a", Some(("codex", viewer)));
+ assert_eq!(
+ presence,
+ Some(ScreenDetectEmission {
+ terminal_id: "term_a".into(),
+ agent: "codex".into(),
+ state: AgentState::Idle,
+ matched_rule: Some("transcript_viewer".into()),
+ visible_idle: false,
+ visible_blocker: false,
+ visible_working: false,
+ })
+ );
+
+ let unknown = detection(ScreenState::Unknown);
+ assert_eq!(
+ tracker
+ .record_detection("term_b", Some(("codex", unknown)))
+ .map(|emission| emission.state),
+ Some(AgentState::Idle)
+ );
+ }
+
+ #[test]
+ fn screen_detect_tracker_prunes_closed_terminals_with_one_lookup_each() {
+ const LIVE_COUNT: usize = 1_024;
+ const CLOSED_COUNT: usize = 1_024;
+
+ let mut tracker = ScreenDetectTracker::default();
+ let live_ids: Vec = (0..LIVE_COUNT).map(|index| format!("live-{index}")).collect();
+ let closed_ids: Vec =
+ (0..CLOSED_COUNT).map(|index| format!("closed-{index}")).collect();
+ for terminal_id in live_ids.iter().chain(&closed_ids) {
+ tracker.record_detection(terminal_id, Some(("codex", detection(ScreenState::Working))));
+ }
+
+ let builds = Arc::new(AtomicUsize::new(0));
+ let mut live = HashSet::with_capacity_and_hasher(
+ LIVE_COUNT,
+ CountingBuildHasher { builds: builds.clone() },
+ );
+ live.extend(live_ids.iter().map(String::as_str));
+ builds.store(0, Ordering::Relaxed);
+
+ tracker.retain_terminals(|terminal_id| live.contains(terminal_id));
+
+ assert_eq!(
+ builds.load(Ordering::Relaxed),
+ LIVE_COUNT + CLOSED_COUNT,
+ "retention must make one indexed membership lookup per tracked terminal",
+ );
+ assert!(live_ids.iter().all(|terminal_id| tracker.has_live_emission(terminal_id)));
+ assert!(closed_ids.iter().all(|terminal_id| !tracker.has_live_emission(terminal_id)));
+ }
+}
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/diagnostics.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/diagnostics.rs
new file mode 100644
index 000000000000..6a964cddecf7
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/diagnostics.rs
@@ -0,0 +1,268 @@
+//! Read-only diagnostics for a live terminal.
+//!
+//! Herdr's live `agent explain` command is useful when a row is wrong. The
+//! equivalent belongs in this package because process identity, manifests,
+//! and state interpretation are plugin policy. The daemon is used only for
+//! the generic terminal list, process, and screen reads.
+
+#[cfg(test)]
+mod target_selection_tests {
+ use std::collections::BTreeMap;
+
+ use cmux::{TerminalId, TerminalLifecycle, TerminalSnapshot};
+
+ use super::resolve_snapshot;
+
+ fn snapshot(hex: &str, title: &str) -> TerminalSnapshot {
+ TerminalSnapshot {
+ id: TerminalId::parse(format!("term_{hex}"))
+ .expect("test terminal ID has the required shape"),
+ tab_ids: Vec::new(),
+ title: title.to_string(),
+ cwd: None,
+ cols: 80,
+ rows: 24,
+ running: true,
+ lifecycle: TerminalLifecycle::Running,
+ stream_revision: Some(1),
+ exit: None,
+ extra: BTreeMap::new(),
+ }
+ }
+
+ #[test]
+ fn live_target_accepts_an_exact_terminal_id() {
+ let terminals = vec![snapshot("11111111111111111111111111111111", "build")];
+ let selected = resolve_snapshot(&terminals, "term_11111111111111111111111111111111")
+ .expect("terminal ID should resolve");
+ assert_eq!(selected.title, "build");
+ }
+
+ #[test]
+ fn live_target_rejects_an_ambiguous_title() {
+ let terminals = vec![
+ snapshot("11111111111111111111111111111111", "agent"),
+ snapshot("22222222222222222222222222222222", "agent"),
+ ];
+ let error = resolve_snapshot(&terminals, "agent").expect_err("duplicate title must fail");
+ assert!(error.contains("more than one terminal"), "{error}");
+ assert!(error.contains("term_1111"), "{error}");
+ assert!(error.contains("term_2222"), "{error}");
+ }
+}
+use cmux::{
+ Client, Config, ProcessInfoResult, ReadScreenOptions, Selector, SessionId, TerminalId,
+ TerminalSnapshot,
+};
+use serde_json::{Value, json};
+
+use crate::manifest::{DetectionInput, ManifestSet};
+use crate::process;
+
+const MAX_TARGET_BYTES: usize = 256;
+
+/// Explain one live terminal selected by its opaque ID or exact title.
+///
+/// The operation is read-only. It never registers a producer, appends a
+/// journal event, changes terminal scroll position, or sends input.
+pub fn explain_live(socket: &str, session_name: &str, target: &str) -> Result {
+ validate_target(target)?;
+ let session_selector = session_selector(session_name)?;
+ let client = Client::connect(Config::from_socket_path(socket))
+ .map_err(|error| format!("connect to cmux: {error}"))?;
+ let session = client.session(session_selector);
+ let snapshots = session.terminal_snapshots().map_err(|error| error.to_string())?;
+ let snapshot = resolve_snapshot(&snapshots, target)?;
+ let terminal = session.terminal(snapshot.id.clone());
+ let process_info = terminal.process().map_err(|error| error.to_string())?;
+ let screen = terminal.read_screen(ReadScreenOptions).map_err(|error| error.to_string())?;
+
+ let (manifests, manifest_warning) = match ManifestSet::from_environment() {
+ Ok(set) => (set, None),
+ Err(error) => {
+ eprintln!("cmux-agent-screen-detection: optional manifest source ignored: {error}");
+ (ManifestSet::bundled().clone(), Some(error))
+ }
+ };
+
+ let native_job = process::foreground_job(process_info.pid);
+ let process_group_authoritative = native_job.is_some();
+ let job = native_job.unwrap_or_else(|| process::fallback_job(&process_info));
+ // Do not label the one-process SDK fallback as a native process-group
+ // result. The fallback job uses the terminal's reported PID as a synthetic
+ // group ID, so its identity is useful but not authoritative.
+ let group_identified =
+ process_group_authoritative.then(|| process::identify_job(&manifests, &job)).flatten();
+ let group_identity_available = group_identified.is_some();
+ let identified = group_identified
+ .or_else(|| process::identify_job_with_process_fallback(&manifests, &job, &process_info));
+ let primary_process_name = process_name(&process_info);
+ let (identified_process_name, identity_source) = identified
+ .map(|(_, candidate)| {
+ (
+ candidate,
+ if group_identity_available {
+ "foreground_process_group"
+ } else {
+ "sdk_process_fallback"
+ },
+ )
+ })
+ .unwrap_or((primary_process_name, "sdk_process_fallback"));
+
+ let explanation = manifests.explain(
+ &identified_process_name,
+ DetectionInput {
+ screen: &screen.text,
+ // The scanner uses the same generic title and OSC progress fields.
+ // A one-shot explain has no earlier revision to compare, so it
+ // reports the metadata and its freshness limitation explicitly.
+ osc_title: &snapshot.title,
+ osc_progress: screen.osc_progress.as_deref().unwrap_or_default(),
+ },
+ );
+ let mut output = serde_json::to_value(explanation)
+ .map_err(|error| format!("encode explanation: {error}"))?;
+ let object = output
+ .as_object_mut()
+ .ok_or_else(|| "explanation did not encode as an object".to_string())?;
+ object.insert("terminal_id".into(), json!(snapshot.id.as_str()));
+ object.insert("terminal_title".into(), json!(snapshot.title.clone()));
+ object.insert("terminal_lifecycle".into(), json!(lifecycle_name(snapshot)));
+ object.insert(
+ "process".into(),
+ json!({
+ "pid": process_info.pid,
+ "executable": process_info.executable,
+ "foreground_executable": process_info.foreground_executable,
+ "foreground_cwd": process_info.foreground_cwd,
+ "identity_source": identity_source,
+ "process_group_authoritative": process_group_authoritative,
+ }),
+ );
+ object.insert(
+ "screen".into(),
+ json!({
+ "source": "terminal.screen.read",
+ "viewport": "live_bottom",
+ "revision": screen.revision.or(snapshot.stream_revision),
+ "cols": screen.cols,
+ "rows": screen.rows,
+ "cursor_row": screen.cursor_row,
+ "cursor_col": screen.cursor_col,
+ "cursor_visible": screen.cursor_visible,
+ "osc_progress_present": screen
+ .osc_progress
+ .as_deref()
+ .is_some_and(|progress| !progress.is_empty()),
+ "metadata_freshness": "one_shot_unknown",
+ }),
+ );
+ if let Some(warning) = manifest_warning {
+ object.insert("manifest_load_warning".into(), json!(warning));
+ }
+ Ok(output)
+}
+
+fn validate_target(target: &str) -> Result<(), String> {
+ if target.is_empty() {
+ return Err("live explain target must not be empty".into());
+ }
+ if target.len() > MAX_TARGET_BYTES {
+ return Err(format!("live explain target exceeds {MAX_TARGET_BYTES} bytes"));
+ }
+ Ok(())
+}
+
+fn session_selector(session_name: &str) -> Result, String> {
+ if session_name.trim().is_empty() {
+ return Err("CMUX_TUI_SESSION_ID must not be empty".into());
+ }
+ match SessionId::parse(session_name.to_owned()) {
+ Ok(id) => Ok(Selector::id(id)),
+ Err(_) => Ok(Selector::name(session_name.to_owned())),
+ }
+}
+
+/// Resolve an exact terminal ID or exact title. A title is not a stable
+/// identity, so duplicate titles fail with actionable IDs instead of choosing
+/// whichever catalog entry happened to arrive first.
+pub(crate) fn resolve_snapshot<'a>(
+ snapshots: &'a [TerminalSnapshot],
+ target: &str,
+) -> Result<&'a TerminalSnapshot, String> {
+ if let Ok(id) = TerminalId::parse(target.to_owned()) {
+ return snapshots
+ .iter()
+ .find(|snapshot| snapshot.id == id)
+ .ok_or_else(|| format!("terminal {target:?} was not found"));
+ }
+
+ let matches = snapshots.iter().filter(|snapshot| snapshot.title == target).collect::>();
+ match matches.as_slice() {
+ [] => Err(format!("no terminal has the exact title {target:?}")),
+ [snapshot] => Ok(snapshot),
+ many => {
+ let ids =
+ many.iter().map(|snapshot| snapshot.id.as_str()).collect::>().join(", ");
+ Err(format!("more than one terminal has the exact title {target:?}; use an ID: {ids}"))
+ }
+ }
+}
+
+fn process_name(process: &ProcessInfoResult) -> String {
+ process
+ .foreground_executable
+ .clone()
+ .or_else(|| process.executable.clone())
+ .or_else(|| process.argv.first().cloned())
+ .unwrap_or_else(|| "unknown".into())
+}
+
+fn lifecycle_name(snapshot: &TerminalSnapshot) -> &'static str {
+ match snapshot.lifecycle {
+ cmux::TerminalLifecycle::Launching => "launching",
+ cmux::TerminalLifecycle::Running => "running",
+ cmux::TerminalLifecycle::Exited => "exited",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::collections::BTreeMap;
+
+ fn snapshot(hex: &str, title: &str) -> TerminalSnapshot {
+ TerminalSnapshot {
+ id: TerminalId::parse(format!("term_{hex}"))
+ .expect("test terminal ID has the required shape"),
+ tab_ids: Vec::new(),
+ title: title.to_string(),
+ cwd: None,
+ cols: 80,
+ rows: 24,
+ running: true,
+ lifecycle: cmux::TerminalLifecycle::Running,
+ stream_revision: Some(1),
+ exit: None,
+ extra: BTreeMap::new(),
+ }
+ }
+
+ #[test]
+ fn target_rejects_an_empty_or_oversized_value() {
+ assert!(validate_target("").is_err());
+ assert!(validate_target(&"x".repeat(MAX_TARGET_BYTES + 1)).is_err());
+ }
+
+ #[test]
+ fn target_reports_missing_ids_and_titles() {
+ let terminals = vec![snapshot("11111111111111111111111111111111", "build")];
+ assert!(
+ resolve_snapshot(&terminals, "term_22222222222222222222222222222222")
+ .unwrap_err()
+ .contains("was not found")
+ );
+ assert!(resolve_snapshot(&terminals, "missing").unwrap_err().contains("no terminal has"));
+ }
+}
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/lib.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/lib.rs
new file mode 100644
index 000000000000..5aea1955ef0e
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/lib.rs
@@ -0,0 +1,12 @@
+//! Reference userland agent plugin.
+//!
+//! The daemon supervises this process, but all agent-specific policy lives
+//! here. The package can be replaced by another implementation that emits
+//! the same generic journal envelope.
+
+pub mod detect;
+pub mod diagnostics;
+pub mod manifest;
+pub mod manifest_update;
+pub mod process;
+pub mod scanner;
diff --git a/cmux-tui/bindings/examples/rust-agent-screen-detection/src/main.rs b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/main.rs
new file mode 100644
index 000000000000..e3b4bc1e9a4c
--- /dev/null
+++ b/cmux-tui/bindings/examples/rust-agent-screen-detection/src/main.rs
@@ -0,0 +1,368 @@
+use std::env;
+use std::path::Path;
+use std::process::ExitCode;
+
+const MAX_EXPLAIN_SCREEN_BYTES: usize = 8 * 1024 * 1024;
+
+fn main() -> ExitCode {
+ let mut args = env::args().skip(1);
+ let command = args.next();
+ match command.as_deref() {
+ Some("--help") | Some("-h") => {
+ print_help();
+ return ExitCode::SUCCESS;
+ }
+ Some("list") => return run_list(),
+ Some("status") => return run_status(),
+ Some("explain") => return run_explain(args.collect()),
+ Some("update") => return run_update(args.collect()),
+ Some(other) => {
+ eprintln!("cmux-agent-screen-detection: unknown command {other:?}");
+ print_help();
+ return ExitCode::from(2);
+ }
+ None => {}
+ }
+
+ let socket = match env::var("CMUX_TUI_SOCKET") {
+ Ok(value) if !value.is_empty() => value,
+ _ => {
+ eprintln!("cmux-agent-screen-detection: CMUX_TUI_SOCKET is required");
+ return ExitCode::FAILURE;
+ }
+ };
+ let session = env::var("CMUX_TUI_SESSION_ID").unwrap_or_else(|_| "main".into());
+ let plugin_id = match required_plugin_id(env::var("CMUX_PLUGIN_ID").ok()) {
+ Ok(value) => value,
+ Err(error) => {
+ eprintln!("cmux-agent-screen-detection: {error}");
+ return ExitCode::FAILURE;
+ }
+ };
+ match cmux_agent_screen_detection::scanner::run(&socket, &session, &plugin_id) {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("cmux-agent-screen-detection: {error}");
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn required_plugin_id(value: Option) -> Result {
+ let Some(value) = value else {
+ return Err("CMUX_PLUGIN_ID is required".into());
+ };
+ if value.trim().is_empty() {
+ return Err("CMUX_PLUGIN_ID must not be blank".into());
+ }
+ cmux_agent_screen_detection::scanner::validate_plugin_id(&value)
+ .map_err(|error| format!("invalid CMUX_PLUGIN_ID: {error}"))?;
+ Ok(value)
+}
+
+fn run_list() -> ExitCode {
+ match cmux_agent_screen_detection::manifest::ManifestSet::from_environment() {
+ Ok(set) => {
+ let manifests = set
+ .manifests()
+ .map(|manifest| {
+ serde_json::json!({
+ "id": manifest.id(),
+ "version": manifest.version().map(ToString::to_string),
+ "source": manifest.source().label(),
+ })
+ })
+ .collect::>();
+ print_json(&serde_json::json!({
+ "engine_version": cmux_agent_screen_detection::manifest::SCREEN_DETECT_ENGINE_VERSION,
+ "manifests": manifests,
+ }))
+ }
+ Err(error) => print_error(error),
+ }
+}
+
+fn run_status() -> ExitCode {
+ let cache_dir = cmux_agent_screen_detection::manifest_update::environment_cache_dir();
+ print_json(&cmux_agent_screen_detection::manifest_update::status_json(&cache_dir))
+}
+
+fn run_explain(arguments: Vec) -> ExitCode {
+ let live = arguments.iter().any(|argument| argument == "--live");
+ let mut process = None;
+ let mut screen_path = None;
+ let mut live_target = None;
+ let mut title = String::new();
+ let mut progress = String::new();
+ let mut json_output = true;
+ let mut index = 0;
+ while index < arguments.len() {
+ let value = &arguments[index];
+ let next = |index: &mut usize, name: &str| -> Result {
+ *index += 1;
+ arguments.get(*index).cloned().ok_or_else(|| format!("{name} needs a value"))
+ };
+ match value.as_str() {
+ "--live" => {}
+ "--json" => json_output = true,
+ "--format" => {
+ let format = match next(&mut index, "--format") {
+ Ok(value) => value,
+ Err(error) => return print_error(error),
+ };
+ match format.as_str() {
+ "json" => json_output = true,
+ "text" => json_output = false,
+ _ => return print_error("--format must be json or text".into()),
+ }
+ }
+ "--terminal" => {
+ if live_target.is_some() {
+ return print_error("live explain target was supplied more than once".into());
+ }
+ live_target = Some(match next(&mut index, "--terminal") {
+ Ok(value) => value,
+ Err(error) => return print_error(error),
+ });
+ }
+ "--process" => {
+ process = Some(match next(&mut index, "--process") {
+ Ok(value) => value,
+ Err(error) => return print_error(error),
+ })
+ }
+ "--screen" => {
+ screen_path = Some(match next(&mut index, "--screen") {
+ Ok(value) => value,
+ Err(error) => return print_error(error),
+ })
+ }
+ "--title" => {
+ title = match next(&mut index, "--title") {
+ Ok(value) => value,
+ Err(error) => return print_error(error),
+ }
+ }
+ "--progress" => {
+ progress = match next(&mut index, "--progress") {
+ Ok(value) => value,
+ Err(error) => return print_error(error),
+ }
+ }
+ _ if live && live_target.is_none() => live_target = Some(value.clone()),
+ _ if live => return print_error(format!("unexpected live explain argument {value:?}")),
+ _ if process.is_none() => process = Some(value.clone()),
+ _ if screen_path.is_none() => screen_path = Some(value.clone()),
+ _ => return print_error(format!("unexpected explain argument {value:?}")),
+ }
+ index += 1;
+ }
+
+ if !live && live_target.is_some() {
+ return print_error("--terminal requires --live".into());
+ }
+ if live {
+ if process.is_some() || screen_path.is_some() || !title.is_empty() || !progress.is_empty() {
+ return print_error(
+ "--live cannot be combined with --process, --screen, --title, or --progress".into(),
+ );
+ }
+ let Some(target) = live_target else {
+ return print_error(
+ "usage: cmux-agent-screen-detection explain --live ".into(),
+ );
+ };
+ let socket = match env::var("CMUX_TUI_SOCKET") {
+ Ok(value) if !value.is_empty() => value,
+ _ => return print_error("CMUX_TUI_SOCKET is required for live explain".into()),
+ };
+ let session = env::var("CMUX_TUI_SESSION_ID").unwrap_or_else(|_| "main".into());
+ return match cmux_agent_screen_detection::diagnostics::explain_live(
+ &socket, &session, &target,
+ ) {
+ Ok(value) if json_output => print_json(&value),
+ Ok(value) => print_explain_text(&value),
+ Err(error) => print_error(error),
+ };
+ }
+
+ let Some(process) = process else {
+ return print_error(explain_file_usage());
+ };
+ let Some(screen_path) = screen_path else {
+ return print_error(explain_file_usage());
+ };
+ let screen = match cmux_agent_screen_detection::manifest::read_bounded_utf8_file(
+ Path::new(&screen_path),
+ MAX_EXPLAIN_SCREEN_BYTES,
+ ) {
+ Ok(screen) => screen,
+ Err(error) => return print_error(format!("read screen {screen_path}: {error}")),
+ };
+ match cmux_agent_screen_detection::manifest::ManifestSet::from_environment() {
+ Ok(set) => {
+ let value = serde_json::to_value(set.explain(
+ &process,
+ cmux_agent_screen_detection::manifest::DetectionInput {
+ screen: &screen,
+ osc_title: &title,
+ osc_progress: &progress,
+ },
+ ))
+ .expect("detection explanation is serializable");
+ if json_output { print_json(&value) } else { print_explain_text(&value) }
+ }
+ Err(error) => print_error(error),
+ }
+}
+
+fn explain_file_usage() -> String {
+ "usage: cmux-agent-screen-detection explain