diff --git a/.github/workflows/app-ffi-release.yml b/.github/workflows/app-ffi-release.yml index 6235e1f5d20a..f4209084c17b 100644 --- a/.github/workflows/app-ffi-release.yml +++ b/.github/workflows/app-ffi-release.yml @@ -1,6 +1,6 @@ # Builds the app's ONE Rust static library, CCmuxAppFFI.xcframework -# (cmux-tui/crates/cmux-app-ffi: the remote desktop core and the sidebar layout -# reducer C ABIs over one Rust runtime, universal macOS arm64 + x86_64; see +# (cmux-tui/crates/cmux-app-ffi: the remote desktop core, the sidebar layout +# reducer and the cmux-motion C ABIs over one Rust runtime, universal macOS arm64 + x86_64; see # scripts/cmux-next/build-app-ffi.sh), and publishes it as a prerelease in this # repository, tagged cmux-app-ffi-. # Packages/macOS/CmuxNext/Package.swift pins one release by URL and SwiftPM @@ -42,6 +42,7 @@ on: - "cmux-tui/crates/cmux-layout-reducer/**" - "cmux-tui/crates/cmux-layout-reducer-ffi/**" - "cmux-tui/crates/cmux-palette-ranker/**" + - "cmux-tui/crates/cmux-motion/**" - "cmux-tui/crates/cmux-rd-core/**" - "cmux-tui/crates/cmux-rd-proto/**" - "cmux-tui/crates/cmux-rd-ffi/**" diff --git a/.github/workflows/cloud-vm-image-contract.yml b/.github/workflows/cloud-vm-image-contract.yml index 9431e10d15fd..e50b51f2bb59 100644 --- a/.github/workflows/cloud-vm-image-contract.yml +++ b/.github/workflows/cloud-vm-image-contract.yml @@ -14,6 +14,8 @@ on: - web/scripts/verify-devbox-image.ts - web/scripts/devbox-agent-launch.* - web/tests/devbox-agent-launch-test.py + - web/scripts/devbox-codex-hook-trust.py + - web/tests/devbox-codex-hook-trust-test.py - web/scripts/check-devbox-agent-pins.ts - web/services/vms/images/manifest.json - web/services/vms/images/sizes.ts @@ -33,6 +35,8 @@ on: - web/scripts/verify-devbox-image.ts - web/scripts/devbox-agent-launch.* - web/tests/devbox-agent-launch-test.py + - web/scripts/devbox-codex-hook-trust.py + - web/tests/devbox-codex-hook-trust-test.py - web/scripts/check-devbox-agent-pins.ts - web/services/vms/images/manifest.json - web/services/vms/images/sizes.ts diff --git a/.github/workflows/cmux-next-batch.yml b/.github/workflows/cmux-next-batch.yml index 369fe532853d..28fae3da9150 100644 --- a/.github/workflows/cmux-next-batch.yml +++ b/.github/workflows/cmux-next-batch.yml @@ -293,7 +293,8 @@ jobs: names=() # Formatting first: the bundles are then built from formatted sources. if [[ " $KINDS " == *" format "* ]]; then - (cd webviews && bun install --frozen-lockfile && bun run check:fix) \ + (cd webviews && bun install --frozen-lockfile) || fail "bun install" + (cd webviews && bun run check:fix) \ || echo "check:fix left issues it cannot fix; keeping what it fixed" names+=("webviews formatting") fi diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 420e8dc0f2cd..a2db527901bf 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -3181,6 +3181,14 @@ jobs: with: persist-credentials: false + # notarize-nightly-dmg.sh runs the bundle notice check after Apple + # accepts, and that check reads ghostty-next/build.zig.zon. Without the + # submodule, run 38669958906 skipped five accepted builds. + - name: Initialize cmux-next notices submodule + if: github.ref_name == 'nightly-next' + shell: bash + run: bash scripts/ci/init-submodule.sh ghostty-next + - name: Select Xcode run: ./scripts/select-ci-xcode.sh @@ -3224,7 +3232,7 @@ jobs: : > "$pending" if ! CMUX_CHANNEL="$SIGN_CHANNEL" CMUX_NOTARY_CONTINUE_STATE="$dir/$STATE_NAME" GITHUB_OUTPUT="$pending" \ ./scripts/ci/notarize-nightly-dmg.sh "$dir/$APP_NAME" "$dir/$DMG_NAME" "recovered/$IMMUTABLE_NAME"; then - echo "::notice::run $run_id: Apple did not accept submission $SUBMISSION_ID; skipping" + echo "::notice::run $run_id: submission $SUBMISSION_ID was not accepted, or a check after acceptance failed (see the log above); skipping" echo "::endgroup::"; continue fi if grep -Fxq "submission_pending=true" "$pending"; then @@ -3949,6 +3957,14 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" fi + # The DMG notarization below runs the bundle notice check, which reads + # ghostty-next/build.zig.zon. Without it, run 38669958906 retired an + # accepted submission. + - name: Initialize cmux-next notices submodule + if: steps.order.outputs.skip != 'true' + shell: bash + run: bash scripts/ci/init-submodule.sh ghostty-next + - name: Select Xcode if: steps.order.outputs.skip != 'true' run: ./scripts/select-ci-xcode.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 558828dbc9d1..f6c163a841fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,69 @@ All notable changes to cmux are documented here. +## [0.65.2] - 2026-10-14 + +### Changed +- The one-time Cloud welcome also shows on 0.65.2 for anyone who has not seen it yet, and only once ([#20057](https://github.com/manaflow-ai/cmux/pull/20057), [#20243](https://github.com/manaflow-ai/cmux/pull/20243)) +- Cloud upgrade offers the next plan tier and no longer asks Max subscribers to buy Pro ([#20603](https://github.com/manaflow-ai/cmux/pull/20603)) + +### Fixed +- A new Cloud split shows its terminal sooner ([#20016](https://github.com/manaflow-ai/cmux/pull/20016)) +- Cloud machine actions answer within the app's request budget and keep running in the background ([#19982](https://github.com/manaflow-ai/cmux/pull/19982)) +- Fixes a Cloud workspace layout that kept rebuilding the pane it had just reused ([#20198](https://github.com/manaflow-ai/cmux/pull/20198)) +- Fn-Left and Fn-Right send Home and End in the terminal ([#18671](https://github.com/manaflow-ai/cmux/pull/18671)) -- thanks @theoklitosBam7 for the report! +- The terminal reports its real color scheme to CSI 996 queries ([#20024](https://github.com/manaflow-ai/cmux/pull/20024)) -- thanks @teamleaderleo, and thanks @sebushi113 for the report! +- Fixes invisible terminal text on light themes when `minimum-contrast` is set ([#19921](https://github.com/manaflow-ai/cmux/pull/19921)) -- thanks @teamleaderleo, and thanks @travisp for the report! +- Canvas mode shows the translucent window backdrop when `background-opacity` is below 1 ([#17548](https://github.com/manaflow-ai/cmux/pull/17548)) -- thanks @csyio, and thanks @CinnamonRolls1 for the report! +- The Cloud CLI bridge keeps its owner across remote reconnects ([#16213](https://github.com/manaflow-ai/cmux/pull/16213)) -- thanks @teamleaderleo! +- `cmux browser` rejects invalid automation arguments instead of running them ([#18838](https://github.com/manaflow-ai/cmux/pull/18838)) -- thanks @soyeladice-svg, and thanks @teamleaderleo for the report! +- Sign-in refreshes its token only when it is close to expiring, retries when the sign-in service is busy instead of failing, and shows the error details when sign-in fails ([#20237](https://github.com/manaflow-ai/cmux/pull/20237), [#20270](https://github.com/manaflow-ai/cmux/pull/20270)) +- Cmd-N and the controls that share it run a custom `ui.newWorkspace.action` again ([#20287](https://github.com/manaflow-ai/cmux/pull/20287)) -- thanks @nebullii, and thanks @clain23 for the report! +- Restored `cmux ssh` workspaces keep connecting instead of showing SSH errors, and cmux's private route marker no longer leaks into `scp` and `ssh` ([#20586](https://github.com/manaflow-ai/cmux/pull/20586), [#20279](https://github.com/manaflow-ai/cmux/pull/20279)) -- thanks @nebullii, and thanks @dericknwq for the report! +- Agent Running/Idle status stays accurate when a Stop hook never arrives, and Claude stays idle after a helper's PreToolUse follows Stop ([#20564](https://github.com/manaflow-ai/cmux/pull/20564), [#20262](https://github.com/manaflow-ai/cmux/pull/20262)) -- thanks @BMeyendriesch for the report! +- Acknowledged agent notifications stay dismissed ([#20547](https://github.com/manaflow-ai/cmux/pull/20547)) +- Agent sessions restore more reliably after a relaunch: Codex lock contention is retried, a Claude session that never wrote a transcript is not restored, Claude Code resumes in the right directory, and agy and OpenCode auto-resume ([#20246](https://github.com/manaflow-ai/cmux/pull/20246), [#20499](https://github.com/manaflow-ai/cmux/pull/20499), [#20498](https://github.com/manaflow-ai/cmux/pull/20498), [#20249](https://github.com/manaflow-ai/cmux/pull/20249), [#20226](https://github.com/manaflow-ai/cmux/pull/20226), [#20441](https://github.com/manaflow-ai/cmux/pull/20441)) +- OpenCode lifecycle notifications work again ([#20444](https://github.com/manaflow-ai/cmux/pull/20444)) +- Codex no longer asks to review cmux's own hooks, locally or on Cloud machines ([#20482](https://github.com/manaflow-ai/cmux/pull/20482), [#20524](https://github.com/manaflow-ai/cmux/pull/20524)) +- Backspace no longer drops text an input method commits on the same keypress ([#20466](https://github.com/manaflow-ai/cmux/pull/20466)) -- thanks @vjintageboy! +- Return reaches forms in browser panes ([#20449](https://github.com/manaflow-ai/cmux/pull/20449)) +- Windows stay on their own Space after a display reconnects ([#20428](https://github.com/manaflow-ai/cmux/pull/20428)) -- thanks @MinsehKim for the report! +- Remote tmux split shortcuts go to the projected panes ([#18333](https://github.com/manaflow-ai/cmux/pull/18333)) -- thanks @Mirksen, and thanks @dev-fennek for the report! +- Surfaces created through the control API no longer replay inherited startup work ([#20312](https://github.com/manaflow-ai/cmux/pull/20312)) -- thanks @SeanPedersen for the report! +- A custom upload command gets the right remote paths ([#20260](https://github.com/manaflow-ai/cmux/pull/20260)) +- Fixes hangs from per-render tab-strip work ([#20238](https://github.com/manaflow-ai/cmux/pull/20238)) +- Shared team members can enable Cloud, a failed team or plan fetch is no longer read as no team or the free plan, a full Cloud resource pool is explained instead of reported as a missing plan, and the first upgrade click opens checkout ([#20425](https://github.com/manaflow-ai/cmux/pull/20425), [#20056](https://github.com/manaflow-ai/cmux/pull/20056), [#20066](https://github.com/manaflow-ai/cmux/pull/20066), [#20252](https://github.com/manaflow-ai/cmux/pull/20252)) +- Cloud machines recover when frozen, machine stats and pause no longer hang on an unresponsive machine, wedged terminal links recover, and `cmux vm restart` prints OK like pause and resume ([#20361](https://github.com/manaflow-ai/cmux/pull/20361), [#20345](https://github.com/manaflow-ai/cmux/pull/20345), [#20376](https://github.com/manaflow-ai/cmux/pull/20376), [#20526](https://github.com/manaflow-ai/cmux/pull/20526)) + +### Removed +- Claude Code and Codex terminals no longer draw the agent turn rail or the restoring-session banner, so their text lines up with plain terminals again after a relaunch; the Previous/Next Agent Turn shortcuts (Cmd-Shift-Up/Down) and their command palette entries are gone with it ([#20060](https://github.com/manaflow-ai/cmux/pull/20060)) +- Terminal panes no longer show a Jump to Bottom button ([#20180](https://github.com/manaflow-ai/cmux/pull/20180)) + +### Thanks to 18 contributors! + +- [@austinywang](https://github.com/austinywang) +- [@BMeyendriesch](https://github.com/BMeyendriesch) +- [@CinnamonRolls1](https://github.com/CinnamonRolls1) +- [@clain23](https://github.com/clain23) +- [@csyio](https://github.com/csyio) +- [@dericknwq](https://github.com/dericknwq) +- [@dev-fennek](https://github.com/dev-fennek) +- [@lawrencecchen](https://github.com/lawrencecchen) +- [@MinsehKim](https://github.com/MinsehKim) +- [@Mirksen](https://github.com/Mirksen) +- [@nebullii](https://github.com/nebullii) +- [@SeanPedersen](https://github.com/SeanPedersen) +- [@sebushi113](https://github.com/sebushi113) +- [@soyeladice-svg](https://github.com/soyeladice-svg) +- [@teamleaderleo](https://github.com/teamleaderleo) +- [@theoklitosBam7](https://github.com/theoklitosBam7) +- [@travisp](https://github.com/travisp) +- [@vjintageboy](https://github.com/vjintageboy) + ## [0.65.1] - 2026-10-12 ### Added -- Ctrl-Tab and Cmd-Shift-] / Cmd-Shift-[ move between sidebar workspaces from any pane, Ctrl-Cmd-E focuses the workspace sidebar, and Ctrl-Cmd-1…9 focuses a pane by number; all are rebindable ([#19795](https://github.com/manaflow-ai/cmux/pull/19795), [#19324](https://github.com/manaflow-ai/cmux/pull/19324), [#19263](https://github.com/manaflow-ai/cmux/pull/19263)) +- Ctrl-Cmd-E focuses the workspace sidebar, and Ctrl-Cmd-1…9 focuses a pane by number; all are rebindable ([#19324](https://github.com/manaflow-ai/cmux/pull/19324), [#19263](https://github.com/manaflow-ai/cmux/pull/19263)) - Cmd-Shift-B jumps back to the pane where you last sent a prompt ([#18951](https://github.com/manaflow-ai/cmux/pull/18951)) -- thanks @lucasr1b! - Cmd-Option-F global search finds open Claude Code and Codex sessions by what was said in them ([#18962](https://github.com/manaflow-ai/cmux/pull/18962)) -- thanks @lucasr1b! - Programs can report idle, working, blocked, done, or error status with OSC 7501, shown in the workspace sidebar and published as `program_status.changed` on the `cmux-events` stream ([#18742](https://github.com/manaflow-ai/cmux/pull/18742), [#19091](https://github.com/manaflow-ai/cmux/pull/19091)) @@ -26,7 +85,6 @@ All notable changes to cmux are documented here. ### Changed - Cmd-N always creates a local workspace, Cmd-Y creates a Cloud workspace, and Cmd-Shift-Y opens New Cloud Machine ([#18895](https://github.com/manaflow-ai/cmux/pull/18895)) -- Previous and Next tab shortcuts move between workspaces when the focused pane has only one tab ([#18939](https://github.com/manaflow-ai/cmux/pull/18939)) - Cmd-click on a path opens relative paths as files, opens folders in Finder, opens `notes.md:12` in the Markdown viewer, and does nothing for missing paths; Cmd-hover underlines only paths that exist ([#19376](https://github.com/manaflow-ai/cmux/pull/19376), [#19397](https://github.com/manaflow-ai/cmux/pull/19397), [#19443](https://github.com/manaflow-ai/cmux/pull/19443), [#19452](https://github.com/manaflow-ai/cmux/pull/19452)) - cmux downloads are smaller: stable ships thin arm64 and x86_64 builds in an LZMA-compressed DMG with a one-hop update path, and the app drops duplicate binaries and unused libraries ([#19255](https://github.com/manaflow-ai/cmux/pull/19255), [#19211](https://github.com/manaflow-ai/cmux/pull/19211), [#19386](https://github.com/manaflow-ai/cmux/pull/19386), [#19356](https://github.com/manaflow-ai/cmux/pull/19356), [#19428](https://github.com/manaflow-ai/cmux/pull/19428), [#19379](https://github.com/manaflow-ai/cmux/pull/19379), [#19355](https://github.com/manaflow-ai/cmux/pull/19355)) - SSH helper payloads for Linux and macOS hosts download on demand instead of shipping inside the app ([#19305](https://github.com/manaflow-ai/cmux/pull/19305), [#19361](https://github.com/manaflow-ai/cmux/pull/19361)) @@ -40,6 +98,14 @@ All notable changes to cmux are documented here. - cmux Cloud (beta): Cloud machine create and resize options follow your plan's limits, and the Cloud sidebar gets hover, copy, refresh, empty-row, and tab-switch polish ([#18481](https://github.com/manaflow-ai/cmux/pull/18481), [#18303](https://github.com/manaflow-ai/cmux/pull/18303), [#18693](https://github.com/manaflow-ai/cmux/pull/18693), [#19085](https://github.com/manaflow-ai/cmux/pull/19085)) -- thanks @lucasr1b, @azooz2003-bit! ### Fixed +- The Cloud VM limit header names the machines it counts and no longer counts paused ones ([#19968](https://github.com/manaflow-ai/cmux/pull/19968)) +- Fixes dragging a sidebar workspace to reorder it, and a crash when the drag ends ([#20015](https://github.com/manaflow-ai/cmux/pull/20015), [#20018](https://github.com/manaflow-ai/cmux/pull/20018)) +- Cloud connects on the first try, re-enrolls a dead tunnel instead of timing out on every machine, and scopes sign-out to its own build ([#19961](https://github.com/manaflow-ai/cmux/pull/19961), [#19977](https://github.com/manaflow-ai/cmux/pull/19977)) +- Account-owned Macs stay in My Devices across team switches ([#18518](https://github.com/manaflow-ai/cmux/pull/18518)) -- thanks @azooz2003-bit! +- Fixes the terminal mode reset after a hibernated agent is reaped ([#19806](https://github.com/manaflow-ai/cmux/pull/19806)) -- thanks @russell7 for the report! +- Agent status is reported in `cmux ssh-tmux` mirrors ([#19104](https://github.com/manaflow-ai/cmux/pull/19104)) +- Cloud workspaces are selected in the window that owns them, and a window closed during Cloud workspace creation is released ([#19970](https://github.com/manaflow-ai/cmux/pull/19970), [#20001](https://github.com/manaflow-ai/cmux/pull/20001)) +- New Machine is created in process ([#18700](https://github.com/manaflow-ai/cmux/pull/18700)) - Fixes a startup exception crash in CmuxMain ([#18452](https://github.com/manaflow-ai/cmux/pull/18452)) - Fixes a crash from a stack overflow while the Settings window is built ([#18457](https://github.com/manaflow-ai/cmux/pull/18457)) - Fixes a crash on Edit > Undo after a text editor was closed ([#18467](https://github.com/manaflow-ai/cmux/pull/18467)) @@ -117,7 +183,7 @@ All notable changes to cmux are documented here. - Remote tmux (beta): New Workspace can be routed to the host, origin colors and host suffixes are repaired, and detach no longer adds a second replacement terminal ([#19112](https://github.com/manaflow-ai/cmux/pull/19112), [#19096](https://github.com/manaflow-ai/cmux/pull/19096), [#19148](https://github.com/manaflow-ai/cmux/pull/19148)) - Custom sidebars (beta) autofocus only after a user gesture, and `clipboard.write` is gesture-gated and write-only ([#19372](https://github.com/manaflow-ai/cmux/pull/19372), [#19333](https://github.com/manaflow-ai/cmux/pull/19333)) -### Thanks to 67 contributors! +### Thanks to 68 contributors! - [@AdamDureyAtWillship](https://github.com/AdamDureyAtWillship) - [@aidaodedjl](https://github.com/aidaodedjl) @@ -173,6 +239,7 @@ All notable changes to cmux are documented here. - [@pipitmart](https://github.com/pipitmart) - [@roypadina](https://github.com/roypadina) - [@RuslanTar](https://github.com/RuslanTar) +- [@russell7](https://github.com/russell7) - [@s4ff0x](https://github.com/s4ff0x) - [@samuelpatro](https://github.com/samuelpatro) - [@scs0209](https://github.com/scs0209) diff --git a/Native/OptChat/optchat-chief/src/host.rs b/Native/OptChat/optchat-chief/src/host.rs index ce7b38efcae6..a23e7cb8af0b 100644 --- a/Native/OptChat/optchat-chief/src/host.rs +++ b/Native/OptChat/optchat-chief/src/host.rs @@ -449,6 +449,12 @@ pub fn conversation_source(flags: &Flags) -> Result { } } +/// The build-session kill switch, the test of acpmux launch_policy.rs. +fn agent_launch_disabled() -> bool { + env("ACPMUX_AGENT_LAUNCH").is_some_and(|v| v.trim().eq_ignore_ascii_case("deny")) + || env("CMUX_AGENTS_DISABLED").is_some_and(|v| v.trim() == "1") +} + /// Runs the host; returns the exit code. pub fn run(flags: &Flags, started_ms: u64) -> i32 { // SIGTERM, SIGINT and SIGHUP stop the host and what it started (E20). @@ -478,6 +484,13 @@ pub fn run(flags: &Flags, started_ms: u64) -> i32 { log(format!("creating {}: {e}", paths.root.display())); return 1; } + // cx-tgf4h: agents are off in this build session (acpmux refuses every + // launch), so no turn could start; the host would only hold messages. + if agent_launch_disabled() { + log("not starting: agents are disabled in this build session \ + (ACPMUX_AGENT_LAUNCH=deny or CMUX_AGENTS_DISABLED=1)"); + return 0; + } let _lock = match HostLock::take(&paths.host_lock, started_ms) { Ok(lock) => { // A stop mark an earlier host of this home left says nothing now. diff --git a/Packages/macOS/CmuxNext/Package.swift b/Packages/macOS/CmuxNext/Package.swift index 4d97da30beb9..3f2887c11188 100644 --- a/Packages/macOS/CmuxNext/Package.swift +++ b/Packages/macOS/CmuxNext/Package.swift @@ -121,8 +121,8 @@ let daemonSwiftSettings: [SwiftSetting] = [ /// when the FFI sources differ from the pinned source sha. let appFFI: Target = .binaryTarget( name: "CCmuxAppFFI", - url: "https://github.com/manaflow-ai/cmux/releases/download/cmux-app-ffi-579db042d5b697448d83dc7b7e973776cb235183/CCmuxAppFFI.xcframework.zip", - checksum: "2d23efb8b5e47b37295acaa112bcf14b62fce4fdc9fc3c1c5b21441fbd912be4" + url: "https://github.com/manaflow-ai/cmux/releases/download/cmux-app-ffi-4f33baa5d8670ef9066883ed9ae7fa891a7651b7/CCmuxAppFFI.xcframework.zip", + checksum: "42ae442948b6e4944f2c66de523b98f618dcce39e9244e6e8aaa45136d739227" ) let package = Package( diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings index 2e7c246c4727..87a8559ad7eb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings @@ -1,6 +1,399 @@ { "sourceLanguage": "en", "strings": { + "action.window.closeOthers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Other Windows" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ほかのウィンドウを閉じる" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "إغلاق النوافذ الأخرى" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Zatvori ostale prozore" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Luk andre vinduer" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Andere Fenster schließen" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Cerrar las demás ventanas" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Fermer les autres fenêtres" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Chiudi le altre finestre" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "បិទបង្អួចផ្សេងទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "다른 윈도우 닫기" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Lukk andre vinduer" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zamknij pozostałe okna" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Fechar Outras Janelas" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Закрыть другие окна" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ปิดหน้าต่างอื่น" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Diğer Pencereleri Kapat" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Закрити інші вікна" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Đóng các cửa sổ khác" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "关闭其他窗口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "關閉其他視窗" + } + } + } + }, + "action.workspaceGroup.closeOthers": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close Other Groups" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ほかのグループを閉じる" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "إغلاق المجموعات الأخرى" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Zatvori ostale grupe" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Luk andre grupper" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Andere Gruppen schließen" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Cerrar los demás grupos" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Fermer les autres groupes" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Chiudi gli altri gruppi" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "បិទក្រុមផ្សេងទៀត" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "다른 그룹 닫기" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Lukk andre grupper" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Zamknij pozostałe grupy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Fechar Outros Grupos" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Закрыть другие группы" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ปิดกลุ่มอื่น" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Diğer Grupları Kapat" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Закрити інші групи" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Đóng các nhóm khác" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "关闭其他分组" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "關閉其他群組" + } + } + } + }, + "action.workspaceGroup.duplicate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Duplicate Workspace Group" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースグループを複製" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تكرار مجموعة مساحات العمل" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Dupliciraj grupu radnih prostora" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Dupliker arbejdsområdegruppe" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Arbeitsbereichsgruppe duplizieren" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Duplicar grupo de espacios de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Dupliquer le groupe d’espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Duplica gruppo di aree di lavoro" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ចម្លងក្រុមកន្លែងធ្វើការ" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "작업 공간 그룹 복제" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Dupliser arbeidsområdegruppe" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Duplikuj grupę przestrzeni roboczych" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Duplicar Grupo de Áreas de Trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Дублировать группу рабочих пространств" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ทำสำเนากลุ่มเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Çalışma Alanı Grubunu Çoğalt" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Дублювати групу робочих областей" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Nhân bản nhóm không gian làm việc" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "复制工作区分组" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "複製工作區群組" + } + } + } + }, "action.workspace.closeAll": { "extractionState": "manual", "localizations": { @@ -77833,657 +78226,788 @@ } } }, - "action.agentChat.copyIdentifier": { + "action.jumpToLastPrompt": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy Agent Identifier" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Agent-Kennung kopieren" + "value": "Jump to Last Prompt" } }, - "fr": { + "ja": { "stringUnit": { - "state": "translated", - "value": "Copier l’identifiant de l’agent" + "state": "needs_review", + "value": "最後のプロンプトへ移動" } }, "ar": { "stringUnit": { - "state": "translated", - "value": "نسخ معرّف الوكيل" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Copiar identificador del agente" + "state": "needs_review", + "value": "الانتقال إلى آخر موجّه" } }, - "zh-Hant": { + "bs": { "stringUnit": { - "state": "translated", - "value": "拷貝代理識別碼" + "state": "needs_review", + "value": "Idi na posljednji upit" } }, - "zh-Hans": { + "da": { "stringUnit": { - "state": "translated", - "value": "复制智能体标识符" + "state": "needs_review", + "value": "Gå til seneste prompt" } }, - "ko": { + "de": { "stringUnit": { - "state": "translated", - "value": "에이전트 식별자 복사" + "state": "needs_review", + "value": "Zum letzten Prompt springen" } }, - "ja": { + "es": { "stringUnit": { - "state": "translated", - "value": "エージェント識別子をコピー" + "state": "needs_review", + "value": "Ir al último prompt" } }, - "bs": { + "fr": { "stringUnit": { - "state": "translated", - "value": "Kopiraj identifikator agenta" + "state": "needs_review", + "value": "Aller au dernier prompt" } }, - "da": { + "it": { "stringUnit": { - "state": "translated", - "value": "Kopiér agent-id" + "state": "needs_review", + "value": "Vai all’ultimo prompt" } }, - "it": { + "km": { "stringUnit": { - "state": "translated", - "value": "Copia identificatore dell’agente" + "state": "needs_review", + "value": "ទៅកាន់សំណើចុងក្រោយ" } }, - "km": { + "ko": { "stringUnit": { - "state": "translated", - "value": "ចម្លងលេខសម្គាល់ភ្នាក់ងារ" + "state": "needs_review", + "value": "마지막 프롬프트로 이동" } }, "nb": { "stringUnit": { - "state": "translated", - "value": "Kopier agent-ID" + "state": "needs_review", + "value": "Gå til siste ledetekst" } }, "pl": { "stringUnit": { - "state": "translated", - "value": "Kopiuj identyfikator agenta" + "state": "needs_review", + "value": "Przejdź do ostatniego polecenia" } }, "pt-BR": { "stringUnit": { - "state": "translated", - "value": "Copiar identificador do agente" + "state": "needs_review", + "value": "Ir para o último prompt" } }, "ru": { "stringUnit": { - "state": "translated", - "value": "Скопировать идентификатор агента" + "state": "needs_review", + "value": "Перейти к последнему запросу" } }, "th": { "stringUnit": { - "state": "translated", - "value": "คัดลอกตัวระบุเอเจนต์" + "state": "needs_review", + "value": "ไปยังพรอมต์ล่าสุด" } }, "tr": { "stringUnit": { - "state": "translated", - "value": "Ajan Tanımlayıcısını Kopyala" + "state": "needs_review", + "value": "Son isteme git" } }, "uk": { "stringUnit": { - "state": "translated", - "value": "Скопіювати ідентифікатор агента" + "state": "needs_review", + "value": "Перейти до останнього запиту" } }, "vi": { "stringUnit": { - "state": "translated", - "value": "Sao chép mã định danh tác tử" + "state": "needs_review", + "value": "Chuyển đến lời nhắc gần nhất" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "跳转到最后的提示" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "跳至最後的提示" } } } }, - "action.jumpToLastPrompt": { + "action.appearance.animations.newTab.toggle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Jump to Last Prompt" + "value": "Toggle New Tab Animations" } }, "ja": { "stringUnit": { "state": "needs_review", - "value": "最後のプロンプトへ移動" + "value": "新しいタブのアニメーションを切り替え" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "الانتقال إلى آخر موجّه" + "value": "تبديل حركة علامات التبويب الجديدة" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Idi na posljednji upit" + "value": "Uključi/isključi animacije novih kartica" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Gå til seneste prompt" + "value": "Slå animationer af nye faner til/fra" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Zum letzten Prompt springen" + "value": "Animationen neuer Tabs ein/aus" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Ir al último prompt" + "value": "Activar o desactivar animaciones de pestañas nuevas" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Aller au dernier prompt" + "value": "Activer/désactiver les animations des nouveaux onglets" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Vai all’ultimo prompt" + "value": "Attiva/disattiva animazioni delle nuove schede" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ទៅកាន់សំណើចុងក្រោយ" + "value": "បិទ/បើកចលនាផ្ទាំងថ្មី" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "마지막 프롬프트로 이동" + "value": "새 탭 애니메이션 전환" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Gå til siste ledetekst" + "value": "Slå animasjoner av nye faner av/på" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Przejdź do ostatniego polecenia" + "value": "Przełącz animacje nowych kart" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Ir para o último prompt" + "value": "Alternar animações de novas abas" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Перейти к последнему запросу" + "value": "Переключить анимацию новых вкладок" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ไปยังพรอมต์ล่าสุด" + "value": "สลับภาพเคลื่อนไหวแท็บใหม่" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Son isteme git" + "value": "Yeni sekme animasyonlarını aç/kapat" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Перейти до останнього запиту" + "value": "Перемкнути анімацію нових вкладок" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Chuyển đến lời nhắc gần nhất" + "value": "Bật/tắt hiệu ứng tab mới" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "跳转到最后的提示" + "value": "切换新标签页动画" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "跳至最後的提示" + "value": "切換新分頁動畫" } } } }, - "action.window.closeOthers": { + "action.appearance.animations.newWorkspace.toggle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Other Windows" + "value": "Toggle New Workspace Animations" } }, "ja": { "stringUnit": { - "state": "translated", - "value": "ほかのウィンドウを閉じる" + "state": "needs_review", + "value": "新しいワークスペースのアニメーションを切り替え" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "إغلاق النوافذ الأخرى" + "value": "تبديل حركة مساحات العمل الجديدة" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Zatvori ostale prozore" + "value": "Uključi/isključi animacije novih radnih prostora" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Luk andre vinduer" + "value": "Slå animationer af nye arbejdsområder til/fra" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Andere Fenster schließen" + "value": "Animationen neuer Arbeitsbereiche ein/aus" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Cerrar las demás ventanas" + "value": "Activar o desactivar animaciones de espacios de trabajo nuevos" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Fermer les autres fenêtres" + "value": "Activer/désactiver les animations des nouveaux espaces de travail" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Chiudi le altre finestre" + "value": "Attiva/disattiva animazioni dei nuovi spazi di lavoro" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "បិទបង្អួចផ្សេងទៀត" + "value": "បិទ/បើកចលនាកន្លែងធ្វើការថ្មី" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "다른 윈도우 닫기" + "value": "새 작업 공간 애니메이션 전환" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Lukk andre vinduer" + "value": "Slå animasjoner av nye arbeidsområder av/på" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Zamknij pozostałe okna" + "value": "Przełącz animacje nowych obszarów roboczych" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Fechar Outras Janelas" + "value": "Alternar animações de novos espaços de trabalho" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Закрыть другие окна" + "value": "Переключить анимацию новых рабочих пространств" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ปิดหน้าต่างอื่น" + "value": "สลับภาพเคลื่อนไหวพื้นที่ทำงานใหม่" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Diğer Pencereleri Kapat" + "value": "Yeni çalışma alanı animasyonlarını aç/kapat" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Закрити інші вікна" + "value": "Перемкнути анімацію нових робочих просторів" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Đóng các cửa sổ khác" + "value": "Bật/tắt hiệu ứng không gian làm việc mới" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "关闭其他窗口" + "value": "切换新工作区动画" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "關閉其他視窗" + "value": "切換新工作區動畫" } } } }, - "action.workspaceGroup.closeOthers": { + "action.appearance.animations.bounce.subtle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Close Other Groups" + "value": "Use Bouncy Animations" } }, "ja": { "stringUnit": { - "state": "translated", - "value": "ほかのグループを閉じる" + "state": "needs_review", + "value": "弾むアニメーションを使用" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "إغلاق المجموعات الأخرى" + "value": "استخدام حركة مرتدة" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Zatvori ostale grupe" + "value": "Koristi animacije s odskokom" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Luk andre grupper" + "value": "Brug fjedrende animationer" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Andere Gruppen schließen" + "value": "Federnde Animationen verwenden" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Cerrar los demás grupos" + "value": "Usar animaciones con rebote" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Fermer les autres groupes" + "value": "Utiliser des animations avec rebond" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Chiudi gli altri gruppi" + "value": "Usa animazioni con rimbalzo" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "បិទក្រុមផ្សេងទៀត" + "value": "ប្រើចលនាលោត" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "다른 그룹 닫기" + "value": "통통 튀는 애니메이션 사용" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Lukk andre grupper" + "value": "Bruk fjærende animasjoner" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Zamknij pozostałe grupy" + "value": "Używaj animacji z odbiciem" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Fechar Outros Grupos" + "value": "Usar animações com quique" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Закрыть другие группы" + "value": "Использовать пружинистую анимацию" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ปิดกลุ่มอื่น" + "value": "ใช้ภาพเคลื่อนไหวแบบเด้ง" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Diğer Grupları Kapat" + "value": "Sekmeli animasyonlar kullan" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Закрити інші групи" + "value": "Використовувати пружну анімацію" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Đóng các nhóm khác" + "value": "Dùng hiệu ứng có độ nảy" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "关闭其他分组" + "value": "使用弹跳动画" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "關閉其他群組" + "value": "使用彈跳動畫" } } } }, - "action.workspaceGroup.duplicate": { + "action.appearance.animations.bounce.none": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Duplicate Workspace Group" + "value": "Use Animations Without Bounce" } }, "ja": { "stringUnit": { - "state": "translated", - "value": "ワークスペースグループを複製" + "state": "needs_review", + "value": "弾まないアニメーションを使用" } }, "ar": { "stringUnit": { "state": "needs_review", - "value": "تكرار مجموعة مساحات العمل" + "value": "استخدام حركة بلا ارتداد" } }, "bs": { "stringUnit": { "state": "needs_review", - "value": "Dupliciraj grupu radnih prostora" + "value": "Koristi animacije bez odskoka" } }, "da": { "stringUnit": { "state": "needs_review", - "value": "Dupliker arbejdsområdegruppe" + "value": "Brug animationer uden fjedring" } }, "de": { "stringUnit": { "state": "needs_review", - "value": "Arbeitsbereichsgruppe duplizieren" + "value": "Animationen ohne Federn verwenden" } }, "es": { "stringUnit": { "state": "needs_review", - "value": "Duplicar grupo de espacios de trabajo" + "value": "Usar animaciones sin rebote" } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Dupliquer le groupe d’espaces de travail" + "value": "Utiliser des animations sans rebond" } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Duplica gruppo di aree di lavoro" + "value": "Usa animazioni senza rimbalzo" } }, "km": { "stringUnit": { "state": "needs_review", - "value": "ចម្លងក្រុមកន្លែងធ្វើការ" + "value": "ប្រើចលនាគ្មានការលោត" } }, "ko": { "stringUnit": { "state": "needs_review", - "value": "작업 공간 그룹 복제" + "value": "바운스 없는 애니메이션 사용" } }, "nb": { "stringUnit": { "state": "needs_review", - "value": "Dupliser arbeidsområdegruppe" + "value": "Bruk animasjoner uten fjæring" } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Duplikuj grupę przestrzeni roboczych" + "value": "Używaj animacji bez odbicia" } }, "pt-BR": { "stringUnit": { "state": "needs_review", - "value": "Duplicar Grupo de Áreas de Trabalho" + "value": "Usar animações sem quique" } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Дублировать группу рабочих пространств" + "value": "Использовать анимацию без отскока" } }, "th": { "stringUnit": { "state": "needs_review", - "value": "ทำสำเนากลุ่มเวิร์กสเปซ" + "value": "ใช้ภาพเคลื่อนไหวแบบไม่เด้ง" } }, "tr": { "stringUnit": { "state": "needs_review", - "value": "Çalışma Alanı Grubunu Çoğalt" + "value": "Sekmesiz animasyonlar kullan" } }, "uk": { "stringUnit": { "state": "needs_review", - "value": "Дублювати групу робочих областей" + "value": "Використовувати анімацію без відскоку" } }, "vi": { "stringUnit": { "state": "needs_review", - "value": "Nhân bản nhóm không gian làm việc" + "value": "Dùng hiệu ứng không nảy" } }, "zh-Hans": { "stringUnit": { "state": "needs_review", - "value": "复制工作区分组" + "value": "使用无弹跳动画" } }, "zh-Hant": { "stringUnit": { "state": "needs_review", - "value": "複製工作區群組" + "value": "使用無彈跳動畫" + } + } + } + }, + "action.agentChat.copyIdentifier": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy Agent Identifier" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Agent-Kennung kopieren" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier l’identifiant de l’agent" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ معرّف الوكيل" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar identificador del agente" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝代理識別碼" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "复制智能体标识符" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 식별자 복사" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェント識別子をコピー" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kopiraj identifikator agenta" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kopiér agent-id" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copia identificatore dell’agente" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចម្លងលេខសម្គាល់ភ្នាក់ងារ" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kopier agent-ID" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kopiuj identyfikator agenta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar identificador do agente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать идентификатор агента" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คัดลอกตัวระบุเอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan Tanımlayıcısını Kopyala" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скопіювати ідентифікатор агента" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Sao chép mã định danh tác tử" } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Preview.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Preview.swift new file mode 100644 index 000000000000..6cb7ca5b2be7 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry+Preview.swift @@ -0,0 +1,75 @@ +/// A live preview of an action before it runs: the palette's highlighted +/// "Window Style: Card" row shows that style in the window without writing +/// cmux.json. `show` applies the look at once and returns how to put the +/// previous look back. Previews of one `group` (one setting) replace each +/// other in place: moving from Card to Flat applies Flat and keeps Card's +/// revert to the committed value, so each move is one cheap write. +public struct ActionPreview { + public var group: String + public var show: @MainActor () -> @MainActor () -> Void + + public init(group: String, show: @escaping @MainActor () -> @MainActor () -> Void) { + self.group = group + self.show = show + } +} + +/// The preview on screen now: its action, group and the revert to the look +/// before the group's first preview. +struct ActivePreview { + var id: ActionID + var group: String + var revert: @MainActor () -> Void +} + +extension ActionRegistry { + /// Makes `id` previewable (`showPreview`). + public func bindPreview(_ id: ActionID, _ preview: ActionPreview) { + previews[canonicalID(for: id)] = preview + } + + public func hasPreview(_ id: ActionID) -> Bool { + previews[canonicalID(for: id)] != nil + } + + /// The action previewed now, if any. + public var previewedAction: ActionID? { activePreview?.id } + + /// Shows the preview of `id`; nil, or an action with no preview, ends + /// the one on screen (the highlight moved to another row). + public func showPreview(_ id: ActionID?) { + guard let id = id.map(canonicalID(for:)), let preview = previews[id] else { return endPreview() } + guard id != activePreview?.id else { return } + if let active = activePreview, active.group == preview.group { + _ = preview.show() + activePreview?.id = id + return + } + endPreview() + activePreview = ActivePreview(id: id, group: preview.group, revert: preview.show()) + } + + /// Puts the committed look back (Escape, the palette closed, a refused run). + public func endPreview() { + guard let active = activePreview else { return } + activePreview = nil + active.revert() + } + + /// Runs `perform` for `id` with the preview settled: running the + /// previewed action keeps its look (the run sets the same value; a + /// handler that refuses calls `endPreview`), running anything else, or + /// a run that did not happen, reverts first. + func settlingPreview(for id: ActionID, _ perform: () -> Bool) -> Bool { + guard let previewed = activePreview?.id else { return perform() } + guard previewed == canonicalID(for: id) else { + endPreview() + return perform() + } + let ran = perform() + if activePreview?.id == previewed { + if ran { activePreview = nil } else { endPreview() } + } + return ran + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift index 6c6d24fc0fc3..581725f01ba9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionRegistry.swift @@ -73,6 +73,9 @@ public final class ActionRegistry { /// with the hovered value (action, argument name, value, target), and /// with a nil value when the menu closes. The App previews themes here. @ObservationIgnored public var choicePreview: (@MainActor (ActionID, String, String?, ActionTargetRef?) -> Void)? + /// Live previews by action (`ActionRegistry+Preview`) and the one on screen. + @ObservationIgnored var previews: [ActionID: ActionPreview] = [:] + @ObservationIgnored var activePreview: ActivePreview? /// Every known value of a suggested argument (`ActionSuggestions.source`), /// supplied by the App. @ObservationIgnored public var argumentSuggestions: (@MainActor (String) -> [ActionEnumCase])? @@ -306,6 +309,10 @@ public final class ActionRegistry { /// Performs with a target and typed arguments (palette, CLI, context menus). Fails when a required argument is missing. @discardableResult public func perform(_ id: ActionID, invocation: ActionInvocation) -> Bool { + settlingPreview(for: id) { performNow(id, invocation: invocation) } + } + + private func performNow(_ id: ActionID, invocation: ActionInvocation) -> Bool { if disabledFeature(for: id) != nil { return false } if let route = keyWindowRoute?(canonicalID(for: id), invocation) { return route.perform { refuse($0) } } guard let action = action(for: id), isAvailable(id, for: invocation), action.isEnabled() else { return false } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index 2ad0c0ce29e1..a036d4883521 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -104,7 +104,8 @@ nonisolated extension ActionSurfaceCatalog { "palette.shortcutKeymap", "palette.installCLI", "palette.uninstallCLI", "palette.restartSocketListener", "palette.checkForUpdates", "updates.whatsNew", "announcements.show", "announcements.hide", "palette.applyUpdateIfAvailable", "palette.switchAppChannel", "appearance.density.compact", "appearance.density.comfortable", "appearance.animationSpeed.fast", - "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "browser.defaultEngine.chromium", + "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "appearance.animations.newTab.toggle", "appearance.animations.newWorkspace.toggle", "appearance.animations.bounce.subtle", "appearance.animations.bounce.none", + "browser.defaultEngine.chromium", "browser.defaultEngine.webkit", "appearance.paneBorder.toggle", "appearance.panePadding.toggle", "appearance.paneCorners.toggle", "layout.centerFocusedColumn.never", "layout.centerFocusedColumn.always", "layout.centerFocusedColumn.onOverflow", "focusRing.toggle", "focusRing.style.ring", "focusRing.style.glow", @@ -277,6 +278,11 @@ nonisolated extension ActionSurfaceCatalog { "appearance.animationSpeed.fast", "appearance.animationSpeed.normal", "appearance.animationSpeed.off", + + "appearance.animations.newTab.toggle", + "appearance.animations.newWorkspace.toggle", + "appearance.animations.bounce.subtle", + "appearance.animations.bounce.none", "browser.defaultEngine.chromium", "browser.defaultEngine.webkit", "appearance.paneBorder.toggle", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift index 4daf449b611f..b61d53e34228 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenuExemptions.swift @@ -53,7 +53,8 @@ nonisolated extension ActionSurfaceCatalog { "palette.applyUpdateIfAvailable", "palette.switchAppChannel", "palette.pro.upgrade", "sendFeedback", "help.featureFlags", "help.documentation", "help.showCrashLogs", "appearance.density.compact", "appearance.density.comfortable", "appearance.animationSpeed.fast", - "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "browser.defaultEngine.chromium", + "appearance.animationSpeed.normal", "appearance.animationSpeed.off", "appearance.animations.newTab.toggle", "appearance.animations.newWorkspace.toggle", "appearance.animations.bounce.subtle", "appearance.animations.bounce.none", + "browser.defaultEngine.chromium", "browser.defaultEngine.webkit", "appearance.paneBorder.toggle", "appearance.panePadding.toggle", "appearance.paneCorners.toggle", "layout.centerFocusedColumn.never", "layout.centerFocusedColumn.always", "layout.centerFocusedColumn.onOverflow", "focusRing.toggle", "focusRing.style.ring", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift index 094201caffde..530774b6ad66 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/SettingsActionCatalog.swift @@ -217,6 +217,30 @@ nonisolated enum SettingsActionCatalog: ActionCatalogGroup { keywords: ["animation", "motion", "speed", "off", "disable", "reduce", "appearance"], category: .settings, symbol: "figure.stand", surfaces: [.palette], cliName: "settings turn-off-animations" ), + ActionDescriptor( + id: "appearance.animations.newTab.toggle", + title: String(localized: "action.appearance.animations.newTab.toggle", defaultValue: "Toggle New Tab Animations", bundle: .module), + keywords: ["animation", "motion", "tab", "new tab", "appearance"], category: .settings, + symbol: "plus.rectangle.on.rectangle", surfaces: [.palette], cliName: "settings toggle-new-tab-animations" + ), + ActionDescriptor( + id: "appearance.animations.newWorkspace.toggle", + title: String(localized: "action.appearance.animations.newWorkspace.toggle", defaultValue: "Toggle New Workspace Animations", bundle: .module), + keywords: ["animation", "motion", "workspace", "sidebar", "appearance"], category: .settings, + symbol: "sidebar.left", surfaces: [.palette], cliName: "settings toggle-new-workspace-animations" + ), + ActionDescriptor( + id: "appearance.animations.bounce.subtle", + title: String(localized: "action.appearance.animations.bounce.subtle", defaultValue: "Use Bouncy Animations", bundle: .module), + keywords: ["animation", "motion", "spring", "bounce", "bouncy", "appearance"], category: .settings, + symbol: "arrow.up.and.down.and.sparkles", surfaces: [.palette], cliName: "settings use-bouncy-animations" + ), + ActionDescriptor( + id: "appearance.animations.bounce.none", + title: String(localized: "action.appearance.animations.bounce.none", defaultValue: "Use Animations Without Bounce", bundle: .module), + keywords: ["animation", "motion", "spring", "bounce", "critically damped", "appearance"], category: .settings, + symbol: "arrow.right.to.line", surfaces: [.palette], cliName: "settings use-animations-without-bounce" + ), ActionDescriptor( id: "browser.defaultEngine.chromium", title: String(localized: "action.browser.defaultEngine.chromium", defaultValue: "Use Chromium for New Browser Tabs", bundle: .module), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift index 1ed8b16d0b89..55fce3554032 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxEnvironment.swift @@ -33,6 +33,20 @@ public nonisolated struct AcpmuxEnvironment: Sendable, Equatable { public static let computerUseKeys = ["CMUX_NEXT_CUA_SOCKET", "CMUX_NEXT_CUA_SOCKET_AUTH_TOKEN"] public static let computerUseHostKey = "CMUX_NEXT_CUA_SOCKET_HOST_AUTH_TOKEN" + /// The build-session kill switch (cx-9ettq): with either set, acpmux + /// starts no agent and the Chief host does not start. A GUI test job sets + /// one on the app; `childEnvironment` passes it to every acpmux daemon, + /// router and Chief host the app starts. + public static let agentLaunchKeys = ["ACPMUX_AGENT_LAUNCH", "CMUX_AGENTS_DISABLED"] + + /// Whether `environment` turns agent launches off (`ACPMUX_AGENT_LAUNCH=deny` + /// or `CMUX_AGENTS_DISABLED=1`, the same test as acpmux's launch_policy.rs). + public static func agentLaunchDisabled(_ environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + let launch = environment["ACPMUX_AGENT_LAUNCH"]?.trimmingCharacters(in: .whitespaces).lowercased() + let disabled = environment["CMUX_AGENTS_DISABLED"]?.trimmingCharacters(in: .whitespaces) + return launch == "deny" || disabled == "1" + } + public var logPath: String { home.appendingPathComponent("daemon.log").path } /// Resolves the environment of `build` (nil: no build, the shared home), @@ -90,6 +104,9 @@ public nonisolated struct AcpmuxEnvironment: Sendable, Equatable { var childEnvironment = ["ACPMUX_HOME": home.path, "ACPMUX_SOCKET": socket, "ACPMUX_LOCAL_ROUTER": "1", "ACPMUX_SECRET_TOOLS": "1"] childEnvironment.merge(launchEnvironment) { _, new in new } + for key in agentLaunchKeys { + if let value = environment[key], !value.isEmpty { childEnvironment[key] = value } + } if let cli = bundledBinDirectory?.appendingPathComponent("cmux").path, isExecutable(cli) { childEnvironment["CMUX_BUNDLED_CLI_PATH"] = cli } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift index e93b9690e7a2..e81689e3ac1d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods+Params.swift @@ -14,7 +14,9 @@ nonisolated extension AcpmuxPaneMethods { "session/prompt": (["sessionId", "prompt", "_meta"], ["promptId", "steer", "interrupt", "context"]), "session/set_model": (["sessionId", "modelId"], []), "session/cancel": (["sessionId"], []), - "_acpmux/watch": (["enabled"], []), + // `limit`: the newest page of the session list, the rest by `_acpmux/sessions` `cursor` (cx-2lv1k). + "_acpmux/watch": (["enabled", "limit"], []), + "_acpmux/sessions": (["limit", "cursor"], []), // `maxBytes`: the most one page downloads, opening a chat or paging older turns in (cx-2lv1k). "_acpmux/events": (["sessionId", "afterSeq", "beforeSeq", "limit", "maxBytes", "kinds"], []), // `visible`: whether the pane shows the chat; acpmux owns unread from it (cx-n8ou). @@ -54,6 +56,8 @@ nonisolated extension AcpmuxPaneMethods { "_acpmux/harness/health": (["refresh", "rescan"], []), "_acpmux/side": (["sessionId", "chat"], []), "_acpmux/offer_respond": (["sessionId", "offerId", "outcome", "childSessionId"], []), + // `subagentSessionId`: a child of that chat, which acpmux looks up in the chat's own map. + "_acpmux/subagent_stop": (["sessionId", "subagentSessionId"], []), "acp.session.fork": (["sessionId", "throughSeq"], []), "acp.trust.get": (["cwd", "sessionId"], []), "acp.trust.set": (["cwd", "level", "sessionId"], []), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift index 6421d7fc2800..15433ffd030d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AcpmuxPaneMethods.swift @@ -22,6 +22,9 @@ nonisolated enum AcpmuxPaneMethods { "session/new", "session/prompt", "session/set_model", "session/set_mode", "session/set_config_option", // acpmux extensions (direct.ts). "_acpmux/watch", "_acpmux/events", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/warm", + // The session list's later pages (sessionPages.ts, cx-2lv1k): the same summaries watch answers, + // read by cursor after watch's first page. + "_acpmux/sessions", "_acpmux/kill", "_acpmux/prewarm", "_acpmux/harnesses", "_acpmux/models", "_acpmux/permission_respond", // Read-only, and its reply is filtered to ``replyShapes`` (ad349). "_acpmux/status", "_acpmux/draft_get", @@ -67,6 +70,11 @@ nonisolated enum AcpmuxPaneMethods { // Dismiss or Start on a chat's session offer chip (direct.ts dismissOffer, startOffer; // cx-807da): acpmux records the answer; Start names the child chat the pane just made. "_acpmux/offer_respond", + // Stop a running Claude subagent or workflow of a chat in the pane's scope (direct.ts + // stopSubagent, cx-liswe): acpmux sends Claude's stop_task for the task it already holds + // for that child (claude_stdio/workflows.rs). It stops work like `_acpmux/kill`, so no + // gesture; caller params never reach Claude. + "_acpmux/subagent_stop", ] /// The shape of a reply the relay filters itself, without relying on the daemon's redaction. @@ -204,7 +212,7 @@ nonisolated enum AcpmuxPaneMethods { "session/prompt", "session/set_mode", "session/set_config_option", "session/set_model", "session/cancel", "_acpmux/kill", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/ask", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/permission_respond", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke", "_acpmux/secret_submit", "_acpmux/archive", "_acpmux/markUnread", "_acpmux/side", - "_acpmux/offer_respond", + "_acpmux/offer_respond", "_acpmux/subagent_stop", ] /// Params beside `sessionId` that name a session, which must be in the pane's scope too: New diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift index 6eb710fd711a..bfe9c1717c75 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift @@ -32,7 +32,7 @@ public nonisolated struct AgentPageOps { "newTab.remember", "newTab.setTemplate", "newTab.inputReady", "newTab.touched", "newTab.context", "shortcut.edit", "action.run", "file.open", "browser.open", "project.list", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "app.action", "chats.open", "chats.openInTerminal", "chats.page", "quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.openTab", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", - "shell.run", "shell.read", "shell.stop", + "shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart", "git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture", "dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings", "transport.open", "transport.send", "transport.close", "transport.gesture", "transport.gesture.release", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift index 6b2238c1a158..24c291e6f154 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift @@ -71,6 +71,7 @@ enum AgentPaneHandshakeReply { if handshake.connection?.remote != nil { model.remoteChat = true } // The connection stays here; the reply never encodes it. model.pendingConnection = handshake.connection + model.transport.acpmuxSocket = handshake.connection?.remote == nil ? handshake.connection?.socketPath : nil handshake.connection = nil return AgentPaneReply.handshake(handshake) } catch { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHarnessCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHarnessCard.swift new file mode 100644 index 000000000000..f41a4de75d53 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHarnessCard.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The agent CLI card's requests (cx-1mbnu): `harness.update` runs the CLI's own update on this +/// Mac's acpmux (`_acpmux/harness/update`) and waits for it; `harness.restart` restarts this +/// chat's agent on the installed version (`_acpmux/sessions/restart_harness`, after its turn). +/// The page names only a harness id; acpmux builds the command and refuses one needing sudo. +@MainActor +enum AgentPaneHarnessCard { + static func reply(to request: AgentPaneRequest, transport: AgentPaneTransport, session sessionId: String?) async -> [String: Any] { + // Page script cannot make a gesture: only the person's click updates or restarts. + guard transport.gestures.consume() else { + return AgentPaneReply.failure(code: "harness.gesture_required", message: AgentPaneModel.shellGestureMessage) + } + guard let socket = transport.acpmuxSocket else { return AgentPaneReply.failure(code: "harness.unavailable", message: unavailableMessage) } + do { + switch request { + case .harnessUpdate(let id): + // Each updater may run 15 minutes (acpmux `probe.rs`); an auto-update may hold the turn. + let result = try await AcpmuxStatusClient.call(socketPath: socket, method: "_acpmux/harness/update", params: ["ids": [id]], + deadline: .seconds(35 * 60), detailed: true) + let row = (result["results"] as? [[String: Any]])?.first { $0["id"] as? String == id } + var value: [String: Any] = ["ok": row?["ok"] as? Bool ?? false] + if let version = row?["version"] as? String { value["version"] = version } + if let error = row?["error"] as? String { value["error"] = error } + return AgentPaneReply.success(value) + case .harnessRestart: + // Only a chat this pane started or shows: the page can adopt any id (`chat.persistSession`). + guard let session = sessionId, transport.sessions.contains(session) else { + return AgentPaneReply.failure(code: "harness.not_this_chat", message: unavailableMessage) + } + let result = try await AcpmuxStatusClient.call(socketPath: socket, method: "_acpmux/sessions/restart_harness", + params: ["sessions": [session]], deadline: .seconds(300), detailed: true) + var value: [String: Any] = ["restarted": result["restarted"] as? Int ?? 0, "after_turn": result["after_turn"] as? Int ?? 0, + "failed": result["failed"] as? Int ?? 0] + if let error = (result["errors"] as? [[String: Any]])?.first?["error"] as? String { value["error"] = error } + return AgentPaneReply.success(value) + default: + return AgentPaneModel.unsupported("harness") + } + } catch AcpmuxStatusClient.Failure.rpc(let message) { + return AgentPaneReply.failure(code: "harness.failed", message: message) + } catch let error as AcpmuxRPCError { + // An agent service from before this app's update does not know the method yet. + return AgentPaneReply.failure(code: "harness.failed", message: error.isMethodMissing ? oldServiceMessage : error.isUnreachable ? unavailableMessage : error.message) + } catch is AgentPaneDeadlineExceeded { + return AgentPaneReply.failure(code: "harness.failed", message: slowMessage) + } catch { + return AgentPaneReply.failure(code: "harness.failed", message: unavailableMessage) + } + } + + /// The agent service still running is older than the app (it lacks the method). + static var oldServiceMessage: String { + String(localized: "agentPane.harness.oldService", + defaultValue: "The agent service on this Mac is out of date. Quit and reopen cmux, then try again.", bundle: .module) + } + + /// The update or restart outlived its deadline; it may still finish. + static var slowMessage: String { + String(localized: "agentPane.harness.slow", + defaultValue: "This is taking longer than expected. It may still finish; check Settings > Agents.", bundle: .module) + } + + /// The update or restart could not reach this Mac's agent service. + static var unavailableMessage: String { + String(localized: "agentPane.harness.unavailable", defaultValue: "The agent service is not running. Try again in a moment.", bundle: .module) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index d4e915d3f88c..5f616b331435 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -296,7 +296,7 @@ public final class AgentPaneModel { } return AgentPaneReply.success() case .gitWrite(let write): return await AgentPaneGitWriteReply.reply(to: write, in: self) - case .git, .invalidGit, .githubRepository, .gitStage, .gitPullRequest, .transportOpen, .transportSend, .transportGesture, .transportGestureRelease, .transportClose: + case .git, .invalidGit, .githubRepository, .gitStage, .gitPullRequest, .transportOpen, .transportSend, .transportGesture, .transportGestureRelease, .transportClose, .harnessUpdate, .harnessRestart: return await AgentPaneTransportReply.reply(to: request, in: self) case .turnUndo(let undo): return await AgentPaneTurnUndo.reply(to: undo, in: self) case .invalidTurnUndo: return await AgentPaneTurnUndo.reply(to: nil, in: self) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 74decf380fae..a0f17704c43e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -102,6 +102,16 @@ extension AgentPaneRequest { } else { self = .unsupported(method) } + case "harness.update": + // A harness id (profile file name), never a command. + if let id = params?["id"] as? String, !id.isEmpty, id.utf8.count <= 128, + id.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || "-_.".contains($0)) }) { + self = .harnessUpdate(id: id) + } else { + self = .unsupported(method) + } + case "harness.restart": + self = .harnessRestart case "shell.run": if let command = params?["command"] as? String, !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index 9abad9ecf086..17838d477d22 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -32,6 +32,12 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable { /// `command` in `cwd` (``AgentPaneShell``); answers `{id}`. Only with a /// real gesture in the pane. case shellRun(command: String, cwd: String?) + /// `harness.update {id}`: the chat card's Update (cx-1mbnu): this Mac's acpmux runs that agent + /// CLI's own update now and answers `{ok, version?, error?}`. Only with a real gesture. + case harnessUpdate(id: String) + /// `harness.restart {}`: the card's Restart Chat: this chat's agent restarts on the installed + /// version and resumes (after its turn); answers `{restarted, after_turn}`. Only with a gesture. + case harnessRestart /// `shell.read {id, after}`: the run's output from byte `after`. case shellRead(id: String, after: Int) /// `shell.stop {id}`: interrupt the run's process group. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift index 8028622f8f33..d256cc910fb2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransport.swift @@ -55,6 +55,8 @@ import CmuxNextCompat var draining = false /// The user's gestures in this pane; a granting frame consumes one. public let gestures: AgentPaneUserGestures + /// This Mac's acpmux socket from the last handshake (the agent CLI card's update and restart). + var acpmuxSocket: String? /// The permission options the daemon sent, to tell an allow from a deny. public let permissionOptions = AcpmuxPermissionOptions() /// The sessions this pane started or shows. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift index d7a1f8759183..4d5b000f0b70 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTransportReply.swift @@ -41,6 +41,10 @@ enum AgentPaneTransportReply { return AgentPaneReply.success(["ticket": ticket]) case .transportGestureRelease: return model.endTransport(closing: nil) case .transportClose(let connection): return model.endTransport(closing: connection) + // This Mac's agent CLIs (cx-1mbnu): never for a chat on another machine. + case .harnessUpdate, .harnessRestart: + if model.remoteChat { return AgentPaneModel.unsupported("harness") } + return await AgentPaneHarnessCard.reply(to: request, transport: model.transport, session: model.sessionId) default: return AgentPaneModel.unsupported(String(describing: request)) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift index 4be9d563a926..1ce3daf2c78b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Warmup.swift @@ -7,10 +7,19 @@ import WebKit /// about 65 ms earlier on a cold launch). The empty page goes once an agent /// page has loaded or the launch settled (`LaunchSettle`). /// -/// It also has the pool start one more web content process with no page, -/// set up while the windows build, which the first agent page takes: a -/// page in a new process waited for that process's setup, which needs main -/// thread turns the launch does not give it until about 450 ms (bd cx-z9kz0). +/// It no longer has the pool prewarm a second, page-less web content process +/// (`-[WKProcessPool _warmInitialProcess]`, cx-z9kz0, #19847). On some Macs +/// the first load that takes that process holds the main thread for about +/// 11 s: WebKit issues its font sandbox extensions to the prewarmed process +/// while that process's network setup is stalled. A standalone WKWebView app +/// on one test Mac (macOS 27.0.1) held 10.99 s with the prewarm and 0.005 s +/// without; another held 0.006 s either way. A DEV build's watchdog killed +/// every launch on the first (the New Tab spare took the process); a Release +/// build froze. The +/// prewarm saved about 65 ms before the first agent page went live on a +/// cold launch (cx-z9kz0). To bring it back, call `_warmInitialProcess` on +/// ``processPool`` after the warmup view's load, once WebKit's stall is +/// understood. extension AgentPaneView { private static var warmupView: WKWebView? /// The in-memory data store every agent page shares. WebKit gives each store its own web @@ -26,10 +35,6 @@ extension AgentPaneView { let view = WKWebView(frame: .zero, configuration: configuration) view.loadHTMLString("", baseURL: nil) warmupView = view - // WebKit's prewarmed process (`-[WKProcessPool _warmInitialProcess]`), checked first: macOS - // has no public call for it; without it the first page starts its own process. - let prewarm = NSSelectorFromString("_warmInitialProcess") // crash-allow: performed only when the pool responds to it - if processPool.responds(to: prewarm) { _ = processPool.perform(prewarm) } } public static func finishWarmup() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index 8de8813ee1a8..60abe5a6adbe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -24367,6 +24367,399 @@ } } } + }, + "agentPane.harness.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The agent service is not running. Try again in a moment." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خدمة الوكيل لا تعمل. حاول مرة أخرى بعد قليل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Usluga agenta nije pokrenuta. Pokušajte ponovo za trenutak." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten kører ikke. Prøv igen om lidt." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Agentdienst läuft nicht. Versuche es gleich noch einmal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El servicio de agentes no se está ejecutando. Inténtalo de nuevo en un momento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le service d'agents n'est pas en cours d'exécution. Réessayez dans un instant." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il servizio agenti non è in esecuzione. Riprova tra un momento." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントサービスが実行されていません。しばらくしてからもう一度お試しください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សេវាភ្នាក់ងារមិនកំពុងដំណើរការទេ។ សូមព្យាយាមម្ដងទៀតបន្តិចទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "에이전트 서비스가 실행 중이 아닙니다. 잠시 후 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten kjører ikke. Prøv igjen om et øyeblikk." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usługa agentów nie działa. Spróbuj ponownie za chwilę." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O serviço de agentes não está em execução. Tente novamente em instantes." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Служба агентов не запущена. Повторите попытку через мгновение." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บริการเอเจนต์ไม่ได้ทำงานอยู่ ลองอีกครั้งในอีกสักครู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ajan hizmeti çalışmıyor. Birazdan yeniden deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Служба агентів не запущена. Спробуйте ще раз за мить." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Dịch vụ tác tử không chạy. Hãy thử lại sau giây lát." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "代理服务未运行。请稍后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "代理程式服務未在執行。請稍後再試。" + } + } + } + }, + "agentPane.harness.oldService": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The agent service on this Mac is out of date. Quit and reopen cmux, then try again." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "خدمة الوكيل على هذا الـ Mac قديمة. أغلق cmux وأعد فتحه، ثم حاول مرة أخرى." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Usluga agenta na ovom Macu je zastarjela. Zatvorite i ponovo otvorite cmux, pa pokušajte ponovo." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten på denne Mac er forældet. Afslut og åbn cmux igen, og prøv så igen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Agentdienst auf diesem Mac ist veraltet. Beende cmux, öffne es erneut und versuche es dann noch einmal." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El servicio de agentes de este Mac está desactualizado. Cierra y vuelve a abrir cmux, y luego inténtalo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le service d'agents de ce Mac n'est pas à jour. Quittez puis rouvrez cmux, puis réessayez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il servizio agenti su questo Mac non è aggiornato. Chiudi e riapri cmux, poi riprova." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "この Mac のエージェントサービスが古くなっています。cmux を終了して再度開いてから、もう一度お試しください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សេវាភ្នាក់ងារនៅលើ Mac នេះហួសសម័យ។ សូមបិទ ហើយបើក cmux ឡើងវិញ រួចព្យាយាមម្ដងទៀត។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 Mac의 에이전트 서비스가 오래되었습니다. cmux를 종료했다가 다시 연 다음 다시 시도하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Agenttjenesten på denne Macen er utdatert. Avslutt og åpne cmux på nytt, og prøv igjen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usługa agentów na tym Macu jest nieaktualna. Zamknij i ponownie otwórz cmux, a następnie spróbuj ponownie." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O serviço de agentes deste Mac está desatualizado. Encerre e reabra o cmux e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Служба агентов на этом Mac устарела. Закройте и снова откройте cmux, затем повторите попытку." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "บริการเอเจนต์บน Mac เครื่องนี้ล้าสมัย ปิดแล้วเปิด cmux ใหม่ จากนั้นลองอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu Mac'teki ajan hizmeti güncel değil. cmux'tan çıkıp yeniden açın, sonra tekrar deneyin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Служба агентів на цьому Mac застаріла. Закрийте й знову відкрийте cmux, а потім спробуйте ще раз." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Dịch vụ tác tử trên máy Mac này đã cũ. Hãy thoát và mở lại cmux, rồi thử lại." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此 Mac 上的代理服务已过时。请退出并重新打开 cmux,然后重试。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "這台 Mac 上的代理程式服務已過時。請結束並重新開啟 cmux,然後再試一次。" + } + } + } + }, + "agentPane.harness.slow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This is taking longer than expected. It may still finish; check Settings > Agents." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يستغرق هذا وقتًا أطول من المتوقع. قد يكتمل لاحقًا؛ تحقق من الإعدادات > الوكلاء." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovo traje duže nego što je očekivano. Možda će se ipak završiti; provjerite Postavke > Agenti." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Det tager længere tid end forventet. Det kan stadig blive færdigt; se Indstillinger > Agenter." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das dauert länger als erwartet. Es wird vielleicht noch fertig; sieh unter Einstellungen > Agenten nach." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esto está tardando más de lo esperado. Puede que aún termine; consulta Ajustes > Agentes." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cela prend plus de temps que prévu. L'opération peut encore aboutir ; consultez Réglages > Agents." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ci vuole più tempo del previsto. Potrebbe comunque terminare; controlla Impostazioni > Agenti." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "予想より時間がかかっています。まだ完了する可能性があります。設定 > エージェント を確認してください。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "វាចំណាយពេលយូរជាងការរំពឹងទុក។ វាអាចនៅតែបញ្ចប់បាន សូមពិនិត្យ ការកំណត់ > ភ្នាក់ងារ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예상보다 오래 걸리고 있습니다. 아직 완료될 수 있으니 설정 > 에이전트를 확인하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Dette tar lengre tid enn forventet. Det kan fortsatt bli ferdig; sjekk Innstillinger > Agenter." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Trwa to dłużej, niż oczekiwano. Może się jeszcze zakończyć; sprawdź Ustawienia > Agenci." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Isso está demorando mais do que o esperado. Ainda pode terminar; verifique Ajustes > Agentes." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Это занимает больше времени, чем ожидалось. Процесс ещё может завершиться; проверьте Настройки > Агенты." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ใช้เวลานานกว่าที่คาดไว้ อาจยังเสร็จได้ ตรวจสอบที่ การตั้งค่า > เอเจนต์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu beklenenden uzun sürüyor. Yine de tamamlanabilir; Ayarlar > Ajanlar'a bakın." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Це триває довше, ніж очікувалося. Процес ще може завершитися; перевірте Налаштування > Агенти." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Việc này mất nhiều thời gian hơn dự kiến. Nó vẫn có thể hoàn tất; hãy kiểm tra Cài đặt > Tác tử." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "所需时间比预期长。它可能仍会完成;请查看“设置”>“代理”。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "所需時間比預期長。它可能仍會完成;請查看「設定」>「代理程式」。" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift index ea489245278c..2943a463c07b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugFrameStrip.swift @@ -1,31 +1,18 @@ #if DEBUG import AppKit +import CmuxNextActions import CmuxNextDesign import CmuxNextSettings +import CmuxNextTabs import CmuxNextWakeups -/// `debug.new_tab {action: "frame_strip"}` (DEBUG builds): the window as the -/// window server composited it, frame after frame, around one key sent -/// through the real key path (`key`, default `n`, with Command: Cmd-N; `t` -/// for Cmd-T). Frames are read off the main actor back to back for -/// `duration_ms` (default 700) after the key, and some before it. -/// -/// A paint-stable opening changes only the content area of the pane that -/// shows the new page (its `contentHost`), and that in one step: every frame -/// outside it equals the frame before the key or the last frame -/// (`intermediate` counts the ones that equal neither), and no frame shows -/// the content area blank, one flat color that was not there before the key (`blank`). PNGs of every frame and -/// one strip (`strip.png`, the frames side by side) go to `dir`. -/// -/// `modifiers` (default `["command"]`) sends another chord. In the content area `settled_ms` is the -/// first frame equal to the last for good, `first_change_ms` the first unlike before the key, and -/// `content_intermediate` counts frames equal to neither. The pane's strip (its tabs slide) is left -/// out of those and of `intermediate`; `strip_intermediate` counts its frames equal to neither, and -/// `desync` those where one of strip and content shows the new tab and the other the old one. +/// `debug.new_tab {action: "frame_strip"}` (DEBUG builds): the window as the window server +/// composited it, frame after frame, around one key sent through the real key path (`key`, default +/// `n`, with `modifiers`, default `["command"]`: Cmd-N; `t` for Cmd-T) or a `gesture` (`plus`, +/// `drag_new`). Frames are read off the main actor back to back for `duration_ms` (default 700) after +/// the key, and some before it. The report: ``NSWindow/frameStripReport(_:sentAt:sentTime:content:strip:tolerance:jump:dir:)``; motion knobs: ``MotionFrameProgress``. @MainActor enum DebugFrameStrip { - typealias Frame = WindowFrame - static func run(_ params: [String: JSONValue], _ controller: WindowController, _ services: AppServices) async -> JSONValue { guard let window = controller.window, window.windowNumber > 0 else { return .object(["error": .string("no window")]) } let number = CGWindowID(window.windowNumber) @@ -42,73 +29,31 @@ enum DebugFrameStrip { await withCheckedContinuation { (done: CheckedContinuation) in lead.schedule(after: .milliseconds(120)) { @MainActor in done.resume() } } - let sent = DebugKey.send(["key": .string(params["key"]?.stringValue ?? "n"), "modifiers": params["modifiers"] ?? .array([.string("command")]), + let sent: JSONValue + if params["gesture"]?.stringValue == "plus", let pane = controller.content?.focusedPane { + pane.stripModel.send(.newTab(after: nil)) + sent = .string("plus") + } else if params["gesture"]?.stringValue == "drag_new", let pane = controller.content?.focusedPane { + let dragged = await TabStripDebug.dragFirstTab(in: pane.view.stripView) { + _ = services.registry.perform("newSurface", invocation: ActionInvocation(target: ActionTargetRef(kind: .pane, id: pane.paneKey), origin: .cli)) + } + sent = .string(dragged ? "drag_new" : "drag_new: no tab") + } else { + sent = DebugKey.send(["key": .string(params["key"]?.stringValue ?? "n"), "modifiers": params["modifiers"] ?? .array([.string("command")]), "window": .string(controller.state.id)], services: services) - let sentAt = BenchSpans.ms(.now - start) + } + let sentAt = BenchSpans.ms(.now - start), sentTime = CACurrentMediaTime() let frames = await recording.value let spans = BenchSpans.end() - guard let first = frames.first, let last = frames.last, let before = frames.last(where: { $0.milliseconds < sentAt }) else { - return .object(["error": .string("no frames"), "key": sent]) - } - let content = (controller.content?.focusedPane?.view.contentHost).flatMap { rect(of: $0, window: window, frame: first) } - // The pane's strip, apart: its own motion (tabs sliding over) is not content. - let strip = (controller.content?.focusedPane?.view.stripView) - .flatMap { $0.isHiddenOrHasHiddenAncestor ? nil : rect(of: $0, window: window, frame: first) } - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - var rows: [JSONValue] = [] - var intermediate = 0, blank = 0, contentIntermediate = 0, desync = 0, stripIntermediate = 0 - var settledAt: Double?, firstChange: Double? - let shown = frames.firstIndex { $0.milliseconds >= before.milliseconds } ?? frames.endIndex - // Off the main actor: six comparisons a frame stalled it past the watchdog in a Debug build. - let measures = await WindowFrame.measure(Array(frames[shown...]), before: before, last: last, content: content, strip: strip) - for (offset, m) in measures.enumerated() { - let index = shown + offset, frame = frames[index] - let (insideBefore, insideFinal, stripBefore, stripFinal) = (m.insideBefore, m.insideFinal, m.stripBefore, m.stripFinal) - let isIntermediate = m.outsideBefore > tolerance && m.outsideFinal > tolerance - // One of the strip and the content at the new tab, the other still at the old one (L4). - let isDesync = strip != nil - && ((stripFinal <= tolerance && stripBefore > tolerance && insideBefore <= tolerance && insideFinal > tolerance) - || (insideFinal <= tolerance && insideBefore > tolerance && stripBefore <= tolerance && stripFinal > tolerance)) - // Neither the content before the key nor the settled one; blank: flat too (an empty terminal is flat). - let isBlank = content != nil && m.flatness > 0.995 && insideBefore > tolerance && insideFinal > tolerance - let isContentIntermediate = frame.milliseconds > sentAt && insideBefore > tolerance && insideFinal > tolerance - if isIntermediate { intermediate += 1 } - if isBlank { blank += 1 } - if isContentIntermediate { contentIntermediate += 1 } - if frame.milliseconds > sentAt { - if stripBefore > tolerance && stripFinal > tolerance { stripIntermediate += 1 } - if isDesync { desync += 1 } - if firstChange == nil, insideBefore > tolerance { firstChange = frame.milliseconds - sentAt } - if insideFinal > tolerance { settledAt = nil } else if settledAt == nil { settledAt = frame.milliseconds - sentAt } - } - let name = String(format: "frame-%03d.png", index) - frame.write(to: (dir as NSString).appendingPathComponent(name)) - rows.append(.object([ - "ms": .number((frame.milliseconds - sentAt).rounded()), "file": .string(name), - "outside_vs_before": .number(m.outsideBefore), "outside_vs_final": .number(m.outsideFinal), "intermediate": .bool(isIntermediate), - "blank": .bool(isBlank), "desync": .bool(isDesync), "content_intermediate": .bool(isContentIntermediate), - ])) - } - WindowFrame.writeStrip(frames.filter { $0.milliseconds >= before.milliseconds }, to: (dir as NSString).appendingPathComponent("strip.png")) - return .object([ - "key": sent, "dir": .string(dir), "frames": .array(rows), "intermediate": .number(Double(intermediate)), - "blank": .number(Double(blank)), "frame_count": .number(Double(rows.count)), - "content_intermediate": .number(Double(contentIntermediate)), "desync": .number(Double(desync)), - "strip_intermediate": .number(Double(stripIntermediate)), - "settled_ms": settledAt.map { .number($0.rounded()) } ?? .null, "first_change_ms": firstChange.map { .number($0.rounded()) } ?? .null, - "spans": .array(spans.map { .object(["name": .string($0.name), "ms": .number(($0.start - sentAt).rounded()), "duration_ms": .number($0.milliseconds)]) }), - "mean_interval_ms": .number(frames.count > 1 ? (last.milliseconds - first.milliseconds) / Double(frames.count - 1) : 0), - ]) - } - - /// `host`'s bounds in `frame` pixels (top-left origin). - private static func rect(of host: NSView, window: NSWindow, frame: Frame) -> CGRect? { - guard host.window === window, - let windowHeight = window.contentView?.superview?.bounds.height ?? window.contentView?.bounds.height - else { return nil } - let rect = host.convert(host.bounds, to: nil) - let sx = Double(frame.width) / window.frame.width, sy = Double(frame.height) / windowHeight - return CGRect(x: rect.minX * sx, y: (windowHeight - rect.maxY) * sy, width: rect.width * sx, height: rect.height * sy) + let pane = controller.content?.focusedPane + guard let report = await window.frameStripReport(frames, sentAt: sentAt, sentTime: sentTime, + content: params["content"]?.stringValue == "none" ? nil : pane?.view.contentHost, + strip: pane?.view.stripView, tolerance: tolerance, jump: params["jump"]?.doubleValue, dir: dir), + case .object(var fields)? = JSONValue(foundation: report) + else { return .object(["error": .string("no frames"), "key": sent]) } + fields["key"] = sent + fields["spans"] = .array(spans.map { .object(["name": .string($0.name), "ms": .number(($0.start - sentAt).rounded()), "duration_ms": .number($0.milliseconds)]) }) + return .object(fields) } } #endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift index 09b3f8c9d9d2..c1d9cafe92da 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugMotion.swift @@ -7,6 +7,7 @@ import CmuxNextTabs /// `debug.motion`: the wiring of `MotionDebug` (CmuxNextDesign), which documents its actions. enum DebugMotion { static func handle(_ params: [String: JSONValue], _ services: AppServices? = nil) -> JSONValue { + if params["action"]?.stringValue == "trajectory" { return MotionParityCorpus().document } let windows = services?.windows.controllers.compactMap(\.window) ?? [] let report = MotionDebug.handle(params.mapValues(\.foundationObject), windows: windows, samplers: [TabStripDebug.motionSample, SidebarContainerView.motionSample]) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift index 110097dadab9..4865fbd81d00 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Control/DebugPaneChrome.swift @@ -68,7 +68,7 @@ enum DebugPaneChrome { return .object([ "id": .string(controller.state.id), "scale": .number(Double(window.backingScaleFactor)), - "pane_padding": .number(Double(Metrics.panePadding)), "floating_panes": .bool(Metrics.floatingPanes), "main_pane": rect(topLeft(controller.root.contentHost.convert(controller.root.contentHost.bounds, to: nil))), "sidebar": rect(topLeft(controller.root.sidebar.convert(controller.root.sidebar.bounds, to: nil))), + "pane_padding": .number(Double(Metrics.panePadding)), "floating_panes": .bool(Metrics.floatingPanes), "main_pane": rect(topLeft(controller.root.contentHost.convert(controller.root.contentHost.bounds, to: nil))), "sidebar": rect(topLeft(controller.root.sidebar.convert(controller.root.sidebar.bounds, to: nil))), "chrome_style": .string(DesignSettings.shared.chromeStyle.rawValue), "panes": .array(panes), "window_controls": .object([ "sidebar_hidden": .bool(controller.root.sidebarHidden), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift index f5d0f9b5b525..9ae2f4467422 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AppearanceHandlers.swift @@ -38,6 +38,7 @@ enum AppearanceHandlers { } SettingChoiceActions.bind(MotionSpeed.allCases, into: registry, id: { "appearance.animationSpeed.\($0.rawValue)" }, path: AnimationSpeedSetting.configPath, fileValue: { .string($0.rawValue) }, save: save) { context.design.animationSpeed = $0 } + AnimationKindsSetting.bindActions(into: registry, design: context.design, save: save) SettingChoiceActions.bind(CenterFocusedColumn.allCases, into: registry, id: { "layout.centerFocusedColumn.\($0 == .onOverflow ? "onOverflow" : $0.rawValue)" }, path: CenterFocusedColumnSetting.configPath, fileValue: { .string($0.rawValue) }, save: save) { context.design.centerFocusedColumn = $0 } ChromeStyleSetting.bindActions(into: registry, design: context.design, save: save) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift index 33691af44256..ccebe3bdc79f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/DaemonClosedHistory.swift @@ -35,6 +35,40 @@ enum DaemonClosedHistory { id.hasPrefix("daemon:") ? String(id.dropFirst("daemon:".count)) : nil } + /// The page a closed item on `machine` is only, by machine and address: a closed tab of one + /// browser on a real address, as the daemon keys it (`closed_page`, Leo 2026-10-14). nil for a + /// terminal, chat, page tab, screen, workspace, window, group or close of several tabs: + /// nothing tells one close of those from another (two shells in one folder). + nonisolated static func pageKey(_ item: ClosedItem, machine: String) -> [String]? { + let tabs = item.tabs + guard item.kind == .tab, item.group == nil, tabs.count == 1, let tab = tabs.first, tab.kind == "browser", + !tab.empty, tab.page == nil, let url = tab.url, !url.isEmpty, url != "about:blank" else { return nil } + return [machine, url] + } + + /// `closed` (newest first, each as its item and machine) with a page closed again + /// (``pageKey(_:machine:)``) once, at its newest close: Recently Closed and Search Tabs list a + /// page once. + nonisolated static func newestOnce(_ closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + var seen = Set<[String]>() + return closed.filter { value in + let (closedItem, machine) = item(value) + return pageKey(closedItem, machine: machine).map { seen.insert($0).inserted } ?? true + } + } + + /// Every close the row of `target` stands for, among `closed`: for a page + /// (``pageKey(_:machine:)``) every close of it, so removing the row leaves none to take its + /// place; else `target` alone. + nonisolated static func closes(of target: T, among closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + let own = item(target) + guard let page = pageKey(own.item, machine: own.machine) else { return [target] } + return closed.filter { value in + let other = item(value) + return pageKey(other.item, machine: other.machine) == page + } + } + /// True when some connected daemon records closed history. static func isServed(in services: AppServices) -> Bool { services.machines.daemons.contains { $0.store.servesStateResources } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift index 352e4a4dc0d4..ce5d5f279ee6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/History/HistoryService.swift @@ -146,9 +146,10 @@ final class HistoryService { /// Closed tabs, screens and workspaces, as the daemons record them /// (`closed-history-v2`): the only closed history (reopen-closed.md 9). Newest first, a page - /// closed again once (``newestOnce(_:)``). + /// closed again once (``DaemonClosedHistory/newestOnce(_:_:)``). func closedEntries() -> [HistoryEntry] { - Self.newestOnce(DaemonClosedHistory.entries(DaemonClosedHistory.allKinds, in: services).filter { Self.isWorthReopening($0.item) }.map { entry in + let shown = DaemonClosedHistory.entries(DaemonClosedHistory.allKinds, in: services).filter { Self.isWorthReopening($0.item) } + return DaemonClosedHistory.newestOnce(shown) { ($0.item, $0.daemon.machineID) }.map { entry in let item = entry.item, tab = Self.shownTab(item) let kind: CmuxNextHistory.ClosedItem.Kind = switch item.kind { case .tab: tab?.kind == "browser" ? .browserTab : .terminalTab @@ -165,18 +166,6 @@ final class HistoryService { time: Date(timeIntervalSince1970: Double(item.closedAtMs) / 1000), title: title, detail: detail, machineName: local ? nil : entry.daemon.machineID, payload: .closed(closed)) - }) - } - - /// `entries` newest first, a page closed again (the same address on the same machine) once, - /// at its newest close (Leo 2026-10-14), as the daemon's history list shows it. A terminal, - /// chat, screen or workspace carries nothing that tells one close from another (two shells - /// in one folder): each close stays its own row. - static func newestOnce(_ entries: [HistoryEntry]) -> [HistoryEntry] { - var seen = Set<[String]>() - return entries.sorted { $0.time > $1.time }.filter { entry in - guard case .closed(let item) = entry.payload, item.kind == .browserTab, let url = item.url else { return true } - return seen.insert([item.machine, url]).inserted } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefMigration.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefMigration.swift index 2d4e64686f14..53d0dea3bebc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefMigration.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Home/HomeService+ChiefMigration.swift @@ -61,11 +61,11 @@ extension HomeService { cloudChief?.mainConversation == id.rawValue || homeStore.summary(id)?.owner == .cloud } - /// The notice row of a conversation: the local Chief's organizing - /// status while a turn waits, else the merge notice. + /// The notice row of a conversation: on the local Chief, agents off in this build session (cx-tgf4h) + /// or its organizing status while a turn waits; else the merge notice. func conversationNotice(for id: ConversationID) -> String? { let isLocalChief = HomeChiefName.select(from: conversations)?.id == id.rawValue - return (isLocalChief ? settleNotice : nil) ?? migrationNotice + return (isLocalChief ? (HomeBrainHost.agentsDisabledNotice() ?? settleNotice) : nil) ?? migrationNotice } /// What Home shows when it cannot show conversations. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift index 8b88ec968ac4..807b63811a9f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Pages/DebugPages.swift @@ -15,7 +15,8 @@ import Foundation // html/body backgrounds (the one-backdrop check), `painted` (the document's first frame, with // `painted_uptime` in host systemUptime seconds and `painted_ms` on the page clock), `instance`, // `parked`, `window_number` and `claim` (how a pooled host took its last claim: `path` -// acknowledged / refused / timedOut / loaded, `ms`); +// acknowledged / refused / timedOut / loaded, `ms`) and `in_flight` (the page's calls still +// running: id, op, age_ms); // - `snapshot` (`path`, default /tmp/cmux-page-.png): the page as WebKit rendered it; // - `command` (`command`, `text`): a dispatcher command (`find`, `focusSearch`, `back`, `forward`, // `reset`) on the page's command stream, as the key dispatcher sends it; @@ -64,6 +65,7 @@ enum DebugPages { if case .object(var members) = state { members.merge(identity(page)) { _, new in new } members["subscriptions"] = .number(Double(page.router.subscriptionCount)) + members["in_flight"] = .array(page.router.inFlightCalls()) // The first frame of this document (preflights wait on it with a deadline). members["painted"] = .bool(page.hasPainted) members["painted_uptime"] = page.paintedUptime.map { .number($0) } ?? .null diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift index c9de4c18bb6a..203a4cfad0f0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarBridge.swift @@ -16,8 +16,8 @@ final class SidebarBridge { unowned let services: AppServices /// Weak: a daemon command's `Task` can outlive the window. weak var state: WindowState? - private var observation: Task? - private var selectionObservation: Task? + /// The rows and selection observations, also run in the turn the window shows a workspace. + private(set) lazy var sameTurn = SidebarSameTurn(view: container.sidebarView) private var widthObservation: Task? /// Item presentation for sidebar sections (SidebarBridge+Sections). var sectionsObservation: Task? @@ -91,9 +91,8 @@ final class SidebarBridge { } func teardown() { - observation?.cancel() + sameTurn.cancel() rowFeed.reset() - selectionObservation?.cancel() widthObservation?.cancel() sectionsObservation?.cancel() appSections?.releaseAll() @@ -118,39 +117,38 @@ final class SidebarBridge { let decorations = customization.rowDecorations self?.rows.patch(changed.map { $0.decorated(by: decorations) }) } - observation = Task { [weak self] in - // `state.id` is read inside: the launch window adopts a saved id. - // The layout too: removing the Home item lists the home workspace. - // And the New Tab pages (a chat lists as a chat) and the muted set. - // The spaces and the current space come from the same observation - // as the rows (cx-5k3r): a space switch reaches the sidebar as one - // change (new space and its rows together), so the sidebar runs - // one slide, not a slide of the old rows and then a row reload. - // The band's layout comes with the rows it projects too (cx-odqn): - // a pinned workspace leaves the list in the turn it joins the band. - for await ((sections, decorations), launching, failed, profiles, active, shown, creating) in ObservationStream({ - // Rows come from the feed: this pass reads no row's status; a row reads its tab selection itself. - let pages = Self.newTabPages(agentTabs, in: machines.local.store), muted = notifications.preferences.mutedWorkspaces - feed.beginPass(inputs: [pages, muted, DesignSettings.shared.attention.showsOnSidebar] as [AnyHashable]) - let (sections, launching, failed) = Self.liveSections( - machines, registry: registry, window: windowState, creations: creations, hidesHome: Self.hidesHome(layout.document), - newTabPages: pages, muted: muted, - top: .make(layout, machines: machines, room: windowState.profileID.rawValue), pageFace: services.sidebarPageFace, - tabTitle: Self.tabTitle(services), rows: feed.rowSource) - return ((sections, services.sidebarCustomization.rowDecorations), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), - SidebarRows.visibleLayout(layout.document), PendingWorkspaceRows.shared.rows(in: windowState.id)) - }) { - guard let self else { return } - self.showProfiles(profiles, active: active, launching: launching) - self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: creating) - // A workspace the window lists shows its own row from here on (cx-9a7wx). - let membership = WindowProfiles.membership(machines) - PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue))) { id in - if let owner = registry.value.owner(of: id), owner != windowState.id { return true } - guard let workspace = WindowProfiles.qualified(id, machines: machines), - let pin = membership?.pins[workspace] else { return false } - return pin != windowState.profileID - } + // `state.id` is read inside: the launch window adopts a saved id. + // The layout too: removing the Home item lists the home workspace. + // And the New Tab pages (a chat lists as a chat) and the muted set. + // The spaces and the current space come from the same observation + // as the rows (cx-5k3r): a space switch reaches the sidebar as one + // change (new space and its rows together), so the sidebar runs + // one slide, not a slide of the old rows and then a row reload. + // The band's layout comes with the rows it projects too (cx-odqn): + // a pinned workspace leaves the list in the turn it joins the band. + sameTurn.observe(listsRows: true, { + // Rows come from the feed: this pass reads no row's status; a row reads its tab selection itself. + let pages = Self.newTabPages(agentTabs, in: machines.local.store), muted = notifications.preferences.mutedWorkspaces + feed.beginPass(inputs: [pages, muted, DesignSettings.shared.attention.showsOnSidebar] as [AnyHashable]) + let (sections, launching, failed) = Self.liveSections( + machines, registry: registry, window: windowState, creations: creations, hidesHome: Self.hidesHome(layout.document), + newTabPages: pages, muted: muted, + top: .make(layout, machines: machines, room: windowState.profileID.rawValue), pageFace: services.sidebarPageFace, + tabTitle: Self.tabTitle(services), rows: feed.rowSource) + return ((sections, services.sidebarCustomization.rowDecorations), launching, failed, SidebarSpaceBar.profiles(machines.local), SidebarProfileKey(windowState.profileID.rawValue), + SidebarRows.visibleLayout(layout.document), PendingWorkspaceRows.shared.rows(in: windowState.id)) + }) { [weak self] value in + let ((sections, decorations), launching, failed, profiles, active, shown, creating) = value + guard let self else { return } + self.showProfiles(profiles, active: active, launching: launching) + self.show(SidebarSection.decorated(feed.fill(sections), decorations), layout: shown, launching: launching, failed: failed, creating: creating) + // A workspace the window lists shows its own row from here on (cx-9a7wx). + let membership = WindowProfiles.membership(machines) + PendingWorkspaceRows.shared.forget(in: windowState.id, listed: Set(sections.flatMap(\.workspaces).map(\.id.rawValue))) { id in + if let owner = registry.value.owner(of: id), owner != windowState.id { return true } + guard let workspace = WindowProfiles.qualified(id, machines: machines), + let pin = membership?.pins[workspace] else { return false } + return pin != windowState.profileID } } // R99: the rows of another space, for the page beside the current one during a swipe. @@ -174,15 +172,12 @@ final class SidebarBridge { self.services.windows.recordSaver.stateDidChange(state) } } - selectionObservation = Task { [weak self] in - // One selection: the shown page's top item, else the shown workspace. - for await selected in ObservationStream({ SidebarNavigation.selectedItem(page: state.page, workspace: state.workspaceID, - creationRow: state.cloudCreation.flatMap { creations.creation($0)?.rowID }, - layout: layout.document, room: state.profileID.rawValue, - refs: WorkspaceLayoutRefs(machines: machines), - home: services.home.dockedHomeID) }) { - self?.selectionHold.offer(selected) // a new workspace's waits for its row (cx-8g3sn) - } + // One selection: the shown page's top item, else the shown workspace. + sameTurn.observe({ SidebarNavigation.selectedItem(page: state.page, workspace: state.workspaceID, + creationRow: state.cloudCreation.flatMap { creations.creation($0)?.rowID }, + layout: layout.document, room: state.profileID.rawValue, + refs: WorkspaceLayoutRefs(machines: machines), home: services.home.dockedHomeID) }) { [weak self] selected in + self?.selectionHold.offer(selected) // a new workspace's waits for its row (cx-8g3sn) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarSameTurn.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarSameTurn.swift new file mode 100644 index 000000000000..4fdf5ef22860 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/SidebarSameTurn.swift @@ -0,0 +1,42 @@ +import CmuxNextCompat +import CmuxNextSidebar + +/// The sidebar's rows and selection passes, each observed and also run on +/// demand. The window swaps a workspace's content in on its own observation +/// hop; the bridge reached `SidebarModel` a hop later and the view drew it a +/// hop after that, so the first frame of a new workspace (Cmd-N) showed its +/// content beside the old row selected and no new row. The window runs +/// `syncNow` before the swap: rows, selection and the drawn list land in the +/// same turn. The streams' next values are then equal and skipped (the +/// model's setters, the selection hold, the view's render). A helper beside +/// SidebarBridge (its type is at its size limit). +@MainActor +final class SidebarSameTurn { + private let view: SidebarView + private var passes: [(listsRows: Bool, run: @MainActor () -> Void)] = [] + private var tasks: [Task] = [] + + init(view: SidebarView) { + self.view = view + } + + /// Observes `compute`, handing each value to `apply`; `syncNow` runs it too. `listsRows` + /// marks the rows pass, which walks every workspace. + func observe(listsRows: Bool = false, _ compute: @escaping @MainActor @Sendable () -> Value, + apply: @escaping @MainActor @Sendable (Value) -> Void) { + passes.append((listsRows, { apply(compute()) })) + tasks.append(Task { for await value in ObservationStream(compute) { apply(value) } }) + } + + /// Runs the passes in order (rows, then selection) and draws the sidebar now. `rows` false + /// skips the rows pass: a workspace shown before is listed already, and a switch to it + /// should not pay a walk of every workspace. + func syncNow(rows: Bool) { + for pass in passes where rows || !pass.listsRows { pass.run() } + view.renderNow() + } + + func cancel() { + for task in tasks { task.cancel() } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift index 4aca4aafad71..11821f581195 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/AppTabSearchSource.swift @@ -44,7 +44,10 @@ final class AppTabSearchSource: TabSearchSource { func forgetClosedTab(id: String) { guard let entry = DaemonClosedHistory.entry(DaemonClosedHistory.daemonID(fromHistoryID: id) ?? id, in: services) else { return } - DaemonClosedHistory.delete(entry, services: services) + let closed = DaemonClosedHistory.entries([.tab], in: services) + for close in TabSearchFactsBuilder.forgotten(entry, among: closed, { ($0.item, $0.daemon.machineID) }) { + DaemonClosedHistory.delete(close, services: services) + } } /// An open browser tab's favicon on this Mac: its live page's, else its record's (never an diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift index 8314a634a0c3..25174388c0cb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabSearch/TabSearchFactsBuilder.swift @@ -1,4 +1,5 @@ import CmuxNextControl +import CmuxNextDaemon import Foundation /// Builds the control snapshot's Search Tabs facts from the App's owners: @@ -43,8 +44,7 @@ enum TabSearchFactsBuilder { private static func closed(_ services: AppServices) -> [ControlClosedTab] { // Read inside the publisher's tracking: a close republishes. _ = services.closedTabs?.changes.revision - // Untouched New Tab pages are left out, as in Recently Closed (HistoryService.isWorthReopening). - return DaemonClosedHistory.entries([.tab], in: services).filter { HistoryService.isWorthReopening($0.item) }.map { entry in + return listed(DaemonClosedHistory.entries([.tab], in: services)) { ($0.item, $0.daemon.machineID) }.map { entry in let item = entry.item, tab = HistoryService.shownTab(item), daemon = entry.daemon let workspaceTitle = item.workspaceID.flatMap { daemon.store.workspace(resourceID: $0)?.displayName } let local = daemon.machineID == MachineRegistry.localID @@ -60,4 +60,18 @@ enum TabSearchFactsBuilder { closedAt: Date(timeIntervalSince1970: Double(item.closedAtMs) / 1000), isAvailable: daemon.connection != nil) } } + + /// The closed tab items Search Tabs lists, of `closed` (newest first, each as its item and + /// machine), as Recently Closed lists them: untouched New Tab pages left out + /// (HistoryService.isWorthReopening), a page closed again once, at its newest close + /// (``DaemonClosedHistory/newestOnce(_:_:)``). + static func listed(_ closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + DaemonClosedHistory.newestOnce(closed.filter { HistoryService.isWorthReopening(item($0).item) }, item) + } + + /// The closed items Forget deletes for the row of `target`, among `closed`: for a page every + /// close of it, so no older one takes its row (``DaemonClosedHistory/closes(of:among:_:)``). + static func forgotten(_ target: T, among closed: [T], _ item: (T) -> (item: ClosedItem, machine: String)) -> [T] { + DaemonClosedHistory.closes(of: target, among: closed, item) + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift index dd2b0439799e..5f0779568c37 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/WindowRootView+MainPane.swift @@ -63,7 +63,8 @@ extension WindowRootView { /// Insets the content host by the gutter on every side (beside the /// sidebar, under the titlebar, from the window edges, also with the - /// sidebar hidden or in fullscreen), rounds it, and reshades the chrome. + /// sidebar hidden or in fullscreen; 0, the flush opt-in, meets them), + /// rounds it, and reshades the chrome. // @intent familiar first (Lawrence 2026-10-14): a margin on every side of the main pane, as in // Helium, the sidebar's side too. func applyMainPane() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift index 2f4e6bc6358f..e42e8f625136 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Windows/WindowController.swift @@ -205,16 +205,17 @@ final class WindowController: NSWindowController, NSWindowDelegate { if state.machineID != daemon.machineID { state.machineID = daemon.machineID } guard content?.workspace !== workspace else { return } if let current = content { park(current) } - let controller: WorkspaceContentController - if let index = parked.firstIndex(where: { $0.workspace === workspace && $0.daemon === daemon }) { + let controller: WorkspaceContentController, index = parked.firstIndex { $0.workspace === workspace && $0.daemon === daemon } + if let index { controller = parked.remove(at: index) controller.unpark() } else { controller = WorkspaceContentController(workspace: workspace, daemon: daemon, services: services, state: state) } content = controller - // One synchronous swap: the old view leaves (its panes stay mounted, - // paused) and the new one draws in the same frame. + // One synchronous swap: the old view leaves (its panes stay mounted, paused) and the new + // one draws in the same frame, its sidebar row already listed (a parked one's is) and selected. + sidebar.sameTurn.syncNow(rows: index == nil) root.show(controller.contentView) // The workspace's theme first, so the chrome repaints once; no // crossfade, which would fade the swap itself. @@ -228,8 +229,7 @@ final class WindowController: NSWindowController, NSWindowDelegate { titleObservation = Task { [weak self] in for await title in ObservationStream({ workspace.displayName }) { self?.root.titlebar.title = title } } - // The new workspace's panes: the coordinator restores its pane and, - // now that the content is installed, re-applies it. + // The new workspace's panes: the coordinator restores its pane and, now that the content is installed, re-applies it. controller.sendTopology() if let pane = focus.state.pane { focus.send(.contentPresented(pane: pane)) } services.windows.recordSaver.stateDidChange(state) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json index 52ec70c96812..272f7a71cc5c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApps/Resources/AppPlatform/scopes.json @@ -209,6 +209,10 @@ "scope": "chief:read", "class": "read" }, + "chief.profile.adopt": { + "scope": "chief:write", + "class": "mutation" + }, "chief.profile.avatar.get": { "scope": "chief:read", "class": "read" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift index a7307ce74fe9..65566fe38351 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFProfileStorage.swift @@ -80,4 +80,22 @@ public nonisolated struct CEFProfileStorage: Hashable, Sendable { } public var logFile: URL { root.appending(path: "cef.log") } + + /// The previous run's `cef.log` (plans/cmux-next/crash-elimination.md + /// section 4). Chromium starts `cef.log` afresh at each launch, which + /// would lose the FATAL line of a run that crashed. + public var previousLogFile: URL { root.appending(path: "cef.previous.log") } + + /// Moves `cef.log` to `cef.previous.log`, replacing an older one, before + /// Chromium starts. One rename: false when there was no log to keep. + @discardableResult + public func keepPreviousLog() -> Bool { + rename(logFile.path, previousLogFile.path) == 0 + } + + /// Right before Chromium starts: the root exists and the last run's log is kept. + public func prepareForLaunch() { + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + keepPreviousLog() + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift index 35fbddeb42f7..0a1b2c9e498f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextBrowser/CEF/CEFRuntime.swift @@ -302,7 +302,7 @@ final class CEFRuntime { self.pump = pump pump.start() - try? FileManager.default.createDirectory(at: storage.root, withIntermediateDirectories: true) + storage.prepareForLaunch() let switchSet = CEFSwitches.current( forkAPIVersion: shim.forkAPIVersion(), bundleIdentifier: Bundle.main.bundleIdentifier, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift index a731ab10c5d4..1dabef2d402e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/DesignSettings.swift @@ -41,6 +41,11 @@ public final class DesignSettings { NotificationCenter.default.post(name: Motion.speedDidChange, object: nil) } } + /// `ui.animations.bounce`: structural springs are critically damped or bounce subtly. + public var animationBounce: MotionBounce = .none + /// The `ui.animations.{newTab,newWorkspace}` kinds on: new tabs by default + /// (`AnimationKindsSetting.defaultEnabled`), new workspaces off. + public var enabledAnimations: Set = [.newTab] /// `ui.paneAnimations`: pane layout changes spring when true (see /// `Motion.animatesPaneLayout`); off by default. public var paneAnimations = false diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift index 4db5acf44e77..031ee7ebff51 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/MainPane.swift @@ -3,8 +3,10 @@ import AppKit public import Foundation /// The curved main pane (cx-rkgu, Leo 2026-10-10): the workspace content -/// sits inset in the window chrome as one rounded card, as in Edge, instead -/// of reaching the sidebar and the window edges. Under +/// sits inset in the window chrome as one rounded card, as in Edge. A +/// gutter of 0 (Debug Settings, 2026-10-14) is the flush opt-in: the pane +/// meets the window's edges and takes the window's own corners, so one +/// outline shows there instead of the gutter's second one. Under /// `appearance.sidebarSurface` separate, the chrome around the card /// (sidebar, gutter, titlebar) is shaded a little darker so the curve reads /// on the one surface; by default it shares the card's surface @@ -18,11 +20,11 @@ nonisolated extension ChromeTunables { default: true, code: "ChromeTunables.mainPaneCurved") public static let mainPaneCornerRadius = Tunable.number( "chrome.mainPane.cornerRadius", .sidebar, "Main pane corner radius", - help: "Corner radius of the curved main pane. Unset: the window's corner radius less the gutter (8 in fullscreen).", default: 8, range: 0...24, step: 0.5, unit: .points, + help: "Corner radius of the curved main pane. Unset: the window's corner radius less the gutter (square in fullscreen without a gutter, else 8).", default: 8, range: 0...24, step: 0.5, unit: .points, code: "ChromeTunables.mainPaneCornerRadius") public static let mainPaneGutter = Tunable.number( "chrome.mainPane.gutter", .sidebar, "Main pane gutter", - help: "Space between the curved main pane and the sidebar, the titlebar and the window edges.", + help: "Space between the curved main pane and the sidebar, the titlebar and the window edges. 0 (flush): the pane meets the window edge and takes its corners.", default: 6, range: 0...24, step: 0.5, unit: .points, code: "ChromeTunables.mainPaneGutter") public static let mainPaneShadeLight = Tunable.number( "chrome.mainPane.shadeLight", .sidebar, "Main pane chrome shade (light)", @@ -56,7 +58,9 @@ extension Metrics { floatingPanes && !NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast } /// Inset of the main pane from the sidebar, titlebar and window edges; - /// 0 when the pane is edge to edge. Floating panes: the pane padding + /// 0 when the pane is edge to edge, or the flush opt-in (a Debug + /// Settings gutter of 0), where the pane meets the window edge and one + /// outline shows there. Floating panes: the pane padding /// (unless Debug Settings set the gutter), so the gap at the edges and /// beside the sidebar (gutter plus padding) equals the gap between two /// panes (twice the padding; the layout leaves no divider gap then). @@ -73,14 +77,19 @@ extension Metrics { } /// Corner radius of the main pane in a window whose own corners have /// `windowCornerRadius`: concentric with them (the window radius less - /// the gutter), so the gutter keeps one width around each window corner. - /// A Debug Settings override wins; without window corners (fullscreen) - /// it is `mainPaneCornerRadius`. + /// the gutter). Without a gutter the pane's corners at the window's are + /// the window's own, so the two are one outline; a square window + /// (fullscreen) gets a square pane, rounding only at real edges. Under a + /// standard titlebar strip the pane's top corners sit below it, against + /// the window's straight side, so the pane is square too (the window + /// still rounds its own corners). A Debug + /// Settings override wins; with a gutter in fullscreen it is + /// `mainPaneCornerRadius`. public static func mainPaneCornerRadius(windowCornerRadius: CGFloat) -> CGFloat { - guard mainPaneCurved, !floatingPanes, ChromeTunables.mainPaneCornerRadius.override == nil, windowCornerRadius > 0 else { - return mainPaneCornerRadius - } - return max(0, windowCornerRadius - mainPaneGutter) + guard mainPaneCurved, !floatingPanes, ChromeTunables.mainPaneCornerRadius.override == nil else { return mainPaneCornerRadius } + let gutter = mainPaneGutter + if gutter == 0 { return DesignSettings.shared.titlebar == .standard ? 0 : windowCornerRadius } + return windowCornerRadius > 0 ? max(0, windowCornerRadius - gutter) : mainPaneCornerRadius } /// Black's alpha over the chrome around the main pane: the shade of the /// separate look only under `appearance.sidebarSurface` separate (the diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift index 03fbaa0bca9c..1e2d9cab7f45 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion.swift @@ -36,7 +36,16 @@ public struct Motion { public static var speed: MotionSpeed { DesignSettings.shared.animationSpeed } - public static var policy: MotionPolicy { MotionPolicy(speed: speed, reduceMotion: reduceMotion, liveResize: isLiveResizing) } + public static var policy: MotionPolicy { + let design = DesignSettings.shared + return MotionPolicy(speed: speed, reduceMotion: reduceMotion, bounce: design.animationBounce, + enabledKinds: design.enabledAnimations, liveResize: isLiveResizing) + } + + /// `kind` animates: movement animates (not `off`, not Reduce Motion, not a + /// live resize) and its `ui.animations.*` switch is on. When false the + /// change applies in one frame, with no crossfade. + public static func animates(_ kind: MotionKind) -> Bool { policy.animates(kind) } /// Position, size, scale and scroll animate. When false, snap. public static var animatesMovement: Bool { policy.animatesMovement } @@ -55,6 +64,12 @@ public struct Motion { public static func duration(_ token: MotionSpring) -> TimeInterval { policy.duration(token) } public static func period(_ loop: MotionLoop) -> TimeInterval? { policy.period(loop) } + /// Seconds of a released divider's velocity projected to find a snap; 0 + /// when movement does not animate. + public static var dividerReleaseProjection: Double { + animatesMovement ? MotionTunables.dividerReleaseProjection.value * speed.timeScale : 0 + } + /// The standard ease-out curve for timed fades. public static var fadeCurve: CAMediaTimingFunction { CAMediaTimingFunction(name: .easeOut) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift new file mode 100644 index 000000000000..9dad465b84ba --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionClock.swift @@ -0,0 +1,122 @@ +public import AppKit +import CmuxNextWakeups + +/// Something whose springs a ``MotionClock`` steps: the tab strip, the +/// layout's panes and dividers, the sidebar rows. +@MainActor +public protocol MotionAnimating: AnyObject { + /// Advances every spring by `dt` seconds and applies the presented + /// values (layer frames). Returns true while anything still moves. + func motionStep(_ dt: Double) -> Bool +} + +/// The one chrome animation clock of a window (motion.md, DESIGN P4): every +/// spring of the window steps on the same frame with the same `dt`, so a +/// tab growing in and its neighbours sliding, or a pane growing and the +/// divider beside it, can never drift apart. It is one client of the +/// window's ``FrameScheduler`` and goes idle when the last animator settles, +/// so a window at rest has no display link at all (0% idle CPU). +@MainActor +public final class MotionClock { + private struct Entry { + weak var animator: (any MotionAnimating)? + var trace: String + } + + private var entries: [ObjectIdentifier: Entry] = [:] + private var order: [ObjectIdentifier] = [] + private lazy var client = FrameClient(owner: "Motion.clock", scheduler: { [weak self] in + self?.window.map(FrameScheduler.forWindow) ?? .app + }) { [weak self] tick in + self?.tick(tick.elapsed) ?? false + } + /// The window whose scheduler the clock ticks on (nil: the app clock). + private weak var window: NSWindow? + /// The animator whose `motionStep` runs now, and whether it called `run` on itself + /// meanwhile (new targets): it stays on the clock even when that step reports rest. + private var steppingID: ObjectIdentifier? + private var steppingRearmed = false + /// Frames delivered while something moved (debug). + public private(set) var frames: UInt64 = 0 + + init(window: NSWindow?) { + self.window = window + } + + private static var associationKey: UInt8 = 0 + private static let app = MotionClock(window: nil) + + /// `window`'s clock, created on first use and kept by the window. + public static func forWindow(_ window: NSWindow) -> MotionClock { + if let clock = objc_getAssociatedObject(window, &associationKey) as? MotionClock { return clock } + let clock = MotionClock(window: window) + objc_setAssociatedObject(window, &associationKey, clock, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return clock + } + + /// The clock of `view`'s window; window-less views share the app clock. + public static func forView(_ view: NSView) -> MotionClock { + view.window.map(forWindow) ?? app + } + + /// Steps `animator` every frame until it reports rest. `trace` names its + /// `debug.motion` span (first frame to rest). No effect while running. + public func run(_ animator: some MotionAnimating, trace: String) { + let id = ObjectIdentifier(animator) + // A scheduler that dropped the client (its window closed) left stale entries behind. + if !client.isActive, !entries.isEmpty { + entries.removeAll() + order.removeAll() + } + guard entries[id] == nil else { + if id == steppingID { steppingRearmed = true } + return + } + entries[id] = Entry(animator: animator, trace: trace) + if !order.contains(id) { order.append(id) } + MotionTrace.begin(trace, id: id) + client.activate() + } + + /// Stops stepping `animator` (it left its window). + public func stop(_ animator: some MotionAnimating) { + let id = ObjectIdentifier(animator) + guard let entry = entries.removeValue(forKey: id) else { return } + order.removeAll { $0 == id } + MotionTrace.end(entry.trace, id: id) + if entries.isEmpty { client.deactivate() } + } + + public func isRunning(_ animator: some MotionAnimating) -> Bool { + entries[ObjectIdentifier(animator)] != nil + } + + /// Animators being stepped now (debug). + public var running: [String] { order.compactMap { entries[$0]?.trace } } + + /// One frame. While `debug.motion` records, the main-thread work of the whole frame + /// (`clock`) and of each animator (its trace name) is timed (`MotionTrace.measure`). + private func tick(_ dt: Double) -> Bool { + MotionTrace.measure("clock") { step(dt) } + } + + private func step(_ dt: Double) -> Bool { + frames &+= 1 + if MotionTrace.isEnabled { MotionTrace.sample("clock.dt_ms", dt * 1000) } + // Animators started during this frame step from the next one. + for id in order { + guard let entry = entries[id] else { continue } + (steppingID, steppingRearmed) = (id, false) + let moving = entry.animator.map { animator in MotionTrace.measure(entry.trace) { animator.motionStep(dt) } } + steppingID = nil + // Rest, unless the step re-armed itself (`run` during its own step): its new targets move next frame. + guard moving == true || (moving != nil && steppingRearmed) else { + entries[id] = nil + MotionTrace.end(entry.trace, id: id) + continue + } + } + order.removeAll { entries[$0] == nil } + return !entries.isEmpty + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift index 179655b94b48..6b6b1578dedf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionDebug.swift @@ -1,4 +1,5 @@ public import AppKit +import CmuxNextWakeups /// `debug.motion` (DEBUG builds register it; `DebugMotion` in CmuxNextApp is its wiring): /// `start` begins recording animation spans, `read` returns them, `stop` returns them and @@ -8,7 +9,16 @@ public import AppKit /// load restores the file's value. `reduce` with `value` true or false overrides Reduce /// Motion in memory (null follows the system setting again), so a bench records reduced /// motion without the system setting. `work` is each timed animation's main-thread work per -/// frame (`MotionTrace.measure`). `trace_start`, `trace_read` and `trace_stop` sample +/// frame (`MotionTrace.measure`). `bounce` with `value` ("none", "subtle") and `kind` with +/// `name` ("paneAnimations", "newTab", "newWorkspace") and `value` true or false set +/// `ui.animations.bounce`, `ui.paneAnimations` and `ui.animations.*` in memory the same way. +/// The report lists the display links alive (`display_links`: 0 once every spring rests, so +/// the window idles). Spans carry `at_ms` (from `start`); `samples` lists per-frame values +/// (`clock.dt_ms`, and `tabs.travel`, `layout.travel`, `sidebar.open`: the points an +/// animator has left to move); `notes` lists what an animator decided. `trajectory` (answered +/// by the app's wiring) returns the port's motion parity corpus (`MotionParityCorpus`), the +/// document cmux-motion's `motion_trajectories` example prints; scripts/cmux-next/motion-parity.py +/// compares the two. `trace_start`, `trace_read` and `trace_stop` sample /// animated geometry on every display frame instead (DEBUG builds, ``MotionFrameTrace``). /// Takes and returns `JSONSerialization` object graphs. public struct MotionDebug { @@ -30,6 +40,18 @@ public struct MotionDebug { } case "reduce": Motion.reduceMotionOverride = params["value"] as? Bool + case "bounce": + if let value = params["value"] as? String, let bounce = MotionBounce(rawValue: value) { + DesignSettings.shared.animationBounce = bounce + } + case "kind": + if let name = params["name"] as? String, let on = params["value"] as? Bool { + if name == "paneAnimations" { + DesignSettings.shared.paneAnimations = on + } else if let kind = MotionKind(rawValue: name) { + if on { DesignSettings.shared.enabledAnimations.insert(kind) } else { DesignSettings.shared.enabledAnimations.remove(kind) } + } + } case "stop": let report = self.report MotionTrace.stop() @@ -46,8 +68,23 @@ public struct MotionDebug { "recording": MotionTrace.isEnabled, "speed": policy.speed.rawValue, "reduce_motion": policy.reduceMotion, + "bounce": policy.bounce.rawValue, + "enabled_kinds": policy.enabledKinds.map(\.rawValue).sorted(), + "pane_animations": DesignSettings.shared.paneAnimations, + "display_links": FrameScheduler.all.filter(\.hasLink).count, + "active_clients": FrameScheduler.all.flatMap(\.activeClients), "spans": MotionTrace.spans.map { span -> [String: Any] in - ["name": span.name, "ms": (span.milliseconds * 10).rounded() / 10] + ["name": span.name, "ms": (span.milliseconds * 10).rounded() / 10, + "at_ms": ((span.start - MotionTrace.startedAt) * 10000).rounded() / 10] + }, + // Per-frame values (clock dt, travel left per animator), ms from `start`. + "samples": MotionTrace.samples.map { sample -> [String: Any] in + ["name": sample.name, "t_ms": ((sample.time - MotionTrace.startedAt) * 10000).rounded() / 10, + "v": (sample.value * 100).rounded() / 100] + }, + // What an animator decided, ms from `start` (the tab strip's syncs: added, removed, animated). + "notes": MotionTrace.notes.map { note -> [String: Any] in + ["t_ms": ((note.time - MotionTrace.startedAt) * 10000).rounded() / 10, "text": note.text] }, "work": MotionTrace.work.mapValues { frames -> [String: Any] in let sorted = frames.sorted() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFrameProgress.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFrameProgress.swift new file mode 100644 index 000000000000..0f17a5cbc051 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFrameProgress.swift @@ -0,0 +1,44 @@ +#if DEBUG +/// How a `debug.new_tab {action: "frame_strip"}` recording moved outside the content and the +/// tab strip (motion proofs, scripts/cmux-next/motion-proof.py, hq-1d). Each frame's `progress` +/// (0 = before, 1 = final, from its pixel shares) never goes back (`reversals`) or leaps (`jumps` +/// over `jump`, default 0.6; `maxStep`). The proof's other knobs: `gesture: "plus"` presses the +/// focused strip's + (its intent) instead of a key, `gesture: "drag_new"` drags the strip's first +/// tab while a CLI new tab lands, `content: "none"` (splits) takes no content area. Tab motion is +/// judged from the strip's own `tabs.travel` samples while `debug.motion` records +/// (``MotionTravelSummary``): a sliding cell is not monotonic in pixels. +public nonisolated struct MotionFrameProgress: Sendable, Equatable { + public var reversals = 0 + public var jumps = 0 + public var maxStep = 0.0 + private var previous: Double? + private var peak = 0.0 + private let jump: Double + + public init(jump: Double?) { + self.jump = jump ?? 0.6 + } + + /// One frame, from its pixel shares outside the content and the strip that match the frame + /// before the gesture and the final frame. + public mutating func add(outsideBefore: Double, outsideFinal: Double) { + let total = outsideBefore + outsideFinal + let progress = total > 0 ? outsideBefore / total : (outsideBefore > 0 ? 1 : 0) + if let previous { + maxStep = max(maxStep, progress - previous) + if progress - previous > jump, previous < 0.05 { jumps += 1 } + if progress < peak - 0.05 { reversals += 1 } + } + (peak, previous) = (max(peak, progress), progress) + } + + /// The report fields: `moving` (frames between before and final), `reversals`, `jumps`, + /// `max_step`, and the strip's `tabs_moving_frames`, `tabs_restarts` (a second motion in one + /// gesture) and `tabs_peak_travel`. + public func fields(moving: Int, tabs: MotionTravelSummary) -> [String: Double] { + ["moving": Double(moving), "reversals": Double(reversals), "jumps": Double(jumps), + "max_step": (maxStep * 1000).rounded() / 1000, "tabs_moving_frames": Double(tabs.movingFrames), + "tabs_restarts": Double(tabs.restarts), "tabs_peak_travel": tabs.peak.rounded()] + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift new file mode 100644 index 000000000000..841bf3b91e63 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift @@ -0,0 +1,33 @@ +public import Foundation + +/// How springy structural motion is (`ui.animations.bounce` in cmux.json). +/// Structural springs (`move`, `appear`, `disappear`) are critically damped +/// by default: they never pass their target. `subtle` gives them SwiftUI's +/// bounce `MotionTunables.subtleBounce` (0.15). +public nonisolated enum MotionBounce: String, Sendable, CaseIterable, Codable { + case none + case subtle +} + +/// The structural changes a user can turn animation on or off one by one +/// (`ui.animations.newTab`, on by default; `ui.animations.newWorkspace`, off +/// by default: it applies in one frame), under `ui.animationSpeed`. Pane layout +/// (splits, closes, zoom, divider release) is `ui.paneAnimations` +/// (`Motion.animatesPaneLayout`). +public nonisolated enum MotionKind: String, Sendable, CaseIterable, Codable { + /// A new tab growing into its slot (Cmd-T, +, New Terminal Tab, socket). + case newTab + /// A new workspace row opening in the sidebar (Cmd-N). + case newWorkspace +} + +nonisolated extension MotionSpring { + /// Structure: things that take or give up room. These follow the bounce + /// setting; release, scroll and overlay springs keep their own damping. + public var isStructural: Bool { + switch self { + case .move, .appear, .disappear, .tabOpen: true + default: false + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift index 4f88456c1bc2..8be81977244d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionPolicy.swift @@ -14,16 +14,26 @@ public import Foundation public nonisolated struct MotionPolicy: Sendable, Equatable { public var speed: MotionSpeed public var reduceMotion: Bool + /// `ui.animations.bounce`: structural springs bounce or not. + public var bounce: MotionBounce + /// The kinds turned on one by one (`ui.animations.*` true). + public var enabledKinds: Set public var liveResize: Bool - public init(speed: MotionSpeed, reduceMotion: Bool, liveResize: Bool = false) { + public init(speed: MotionSpeed, reduceMotion: Bool, bounce: MotionBounce = .none, + enabledKinds: Set = [], liveResize: Bool = false) { self.speed = speed self.reduceMotion = reduceMotion + self.bounce = bounce + self.enabledKinds = enabledKinds self.liveResize = liveResize } /// Position, size, scale and scroll animate. When false, snap. public var animatesMovement: Bool { speed != .off && !reduceMotion && !liveResize } + /// `kind` moves: movement animates and its switch is on. Off means the + /// change applies in one frame (Reduce Motion: no crossfade either). + public func animates(_ kind: MotionKind) -> Bool { animatesMovement && enabledKinds.contains(kind) } /// Opacity and color changes animate. When false, apply at once. public var animatesFades: Bool { speed != .off } /// Repeating indicators (spinner, pulse) run. @@ -33,7 +43,13 @@ public nonisolated struct MotionPolicy: Sendable, Equatable { /// `animatesMovement` is false; `off` returns the `fast` spring so a /// caller that steps anyway never divides by zero. public func spring(_ token: MotionSpring) -> SpringParameters { - speed == .off ? token.base : token.base.scaled(by: speed.timeScale) + var base = token.base + if token.isStructural { + // Bounce none: critically damped (never past the target); subtle: SwiftUI bounce. + base = bounce == .subtle ? base.withBounce(MotionTunables.subtleBounce.value) + : SpringParameters(response: base.response, dampingFraction: max(base.dampingFraction, 1)) + } + return speed == .off ? base : base.scaled(by: speed.timeScale) } /// Seconds for `token`; 0 means apply at once. Under Reduce Motion a fade diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift index 9bd01a79b5bb..c1592b681f8d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTokens.swift @@ -21,19 +21,18 @@ public nonisolated enum MotionSpeed: String, Sendable, CaseIterable, Codable { /// Spring tokens. Each one is a response (seconds for one undamped period) /// and a damping fraction (1 = critically damped), as in SwiftUI -/// `.spring(response:dampingFraction:)`. Damping 0.9 overshoots 0.05% (0.1 -/// pt on a 200 pt move, invisible) and ends visibly ~30% sooner than a -/// critically damped spring's long tail. Values and rationale are in -/// plans/cmux-next/motion.md; change them only there and here. +/// `.spring(response:dampingFraction:)`. The structural ones (`move`, +/// `appear`, `disappear`) are critically damped with a short response +/// (they never pass the target; `ui.animations.bounce` subtle opts into a +/// SwiftUI bounce). The values are the spec's design-tokens.json `motion` +/// (cmux-motion reads the same file; scripts/cmux-next/motion-parity.py +/// compares this port's paths with cmux-motion's). The cases are declared +/// in cmux-motion's `MotionSpring::ALL` order, the token index of its C ABI +/// (`cmux_motion_spring_base`): append new tokens on both sides. public nonisolated enum MotionSpring: String, Sendable, CaseIterable { /// An existing item moves or resizes: tab reflow and reorder, sidebar /// rows, pane and divider frames, pane unzoom, toolbar height. case move - /// A pane zooming to fill its screen: it owns the motion (Leo 2026-10-13). - case zoom - /// The panes a zoom leaves fade out, faster than anything arrives - /// (`Motion.exit(nearness:)`: the farthest first). - case exit /// Something appears or expands: tab grow-in, group expand, row insert, /// sidebar show. case appear @@ -56,6 +55,17 @@ public nonisolated enum MotionSpring: String, Sendable, CaseIterable { case selection /// Floating panel open: palette scale-in, hover card slide. case panel + /// Home list pin drag: tiles making room, rows below the grid, a dropped + /// tile landing (MessagesLab `SidebarPinDragging.spring`). + case pinDrag + /// A pane zooming to fill its screen: it owns the motion (Leo 2026-10-13). + case zoom + /// The panes a zoom leaves fade out, faster than anything arrives + /// (`Motion.exit(nearness:)`: the farthest first). + case exit + /// A new tab growing in at its final slot while the tabs right of it make room, on one + /// spring (`ui.animations.newTab`, on by default). Structural. + case tabOpen /// Values at `MotionSpeed.fast`: the code default (`MotionTunables`), /// or its Debug Settings override in DEV and NIGHTLY builds. @@ -63,3 +73,4 @@ public nonisolated enum MotionSpring: String, Sendable, CaseIterable { MotionTunables.springs[self]?.value ?? SpringParameters(response: 0.2, dampingFraction: 0.9) } } + diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift index 3a8e35a9a8a1..8410fdc30e51 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTrace.swift @@ -3,7 +3,11 @@ public import QuartzCore /// Measured animation spans for `debug.motion` (plans/cmux-next/motion.md). /// Off by default: every hook is one Bool check. While on, a span opens at /// the first start of a named animation and closes when it settles, so a -/// retargeted animation counts once, from first frame to rest. +/// retargeted animation counts once, from first frame to rest. Two +/// animators with one name (two tab strips) keep separate spans (`id`). +/// `sample` records one value per frame (the clock's dt, the travel a strip +/// or layout has left), so a bench reads the spring's path, not only its +/// length. public struct MotionTrace { public init() {} public struct Span: Sendable, Equatable { @@ -12,18 +16,33 @@ public struct MotionTrace { public var milliseconds: Double } + public struct Sample: Sendable, Equatable { + public var name: String + public var time: CFTimeInterval + public var value: Double + } + public private(set) static var isEnabled = false private static var open: [String: CFTimeInterval] = [:] public private(set) static var spans: [Span] = [] /// Main-thread work per frame of each animation `measure` times, in ms. public private(set) static var work: [String: [Double]] = [:] + public private(set) static var samples: [Sample] = [] + /// What an animator decided at a time (`note`), for a bench that must tell why something moved. + public private(set) static var notes: [(time: CFTimeInterval, text: String)] = [] + /// When recording started (sample times are reported from here). + public private(set) static var startedAt: CFTimeInterval = 0 static let capacity = 1024 + static let sampleCapacity = 8192 public static func start() { isEnabled = true open.removeAll() spans.removeAll() work.removeAll() + samples.removeAll() + notes.removeAll() + startedAt = CACurrentMediaTime() } public static func stop() { @@ -31,9 +50,10 @@ public struct MotionTrace { open.removeAll() } - public static func begin(_ name: String) { - guard isEnabled, open[name] == nil else { return } - open[name] = CACurrentMediaTime() + public static func begin(_ name: String, id: ObjectIdentifier? = nil) { + let key = key(name, id) + guard isEnabled, open[key] == nil else { return } + open[key] = CACurrentMediaTime() } /// Runs one frame of the animation `name`; while on, records how long it took. @@ -45,8 +65,24 @@ public struct MotionTrace { return result } - public static func end(_ name: String) { - guard isEnabled, let start = open.removeValue(forKey: name), spans.count < capacity else { return } + public static func end(_ name: String, id: ObjectIdentifier? = nil) { + guard isEnabled, let start = open.removeValue(forKey: key(name, id)), spans.count < capacity else { return } spans.append(Span(name: name, start: start, milliseconds: (CACurrentMediaTime() - start) * 1000)) } + + /// One value of `name` at this frame (only while recording). + public static func sample(_ name: String, _ value: Double) { + guard isEnabled, samples.count < sampleCapacity else { return } + samples.append(Sample(name: name, time: CACurrentMediaTime(), value: value)) + } + + /// One decision of an animator (only while recording; the text is built only then). + public static func note(_ text: @autoclosure () -> String) { + guard isEnabled, notes.count < capacity else { return } + notes.append((CACurrentMediaTime(), text())) + } + + private static func key(_ name: String, _ id: ObjectIdentifier?) -> String { + id.map { "\(name)#\($0.hashValue)" } ?? name + } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTravelSummary.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTravelSummary.swift new file mode 100644 index 000000000000..02b388fe32fa --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTravelSummary.swift @@ -0,0 +1,31 @@ +#if DEBUG +public import QuartzCore + +/// How one animator's `MotionTrace` travel sample (`tabs.travel`: the points its springs have +/// left to move) ran after a time. Pixel shares cannot judge a moving tab (a cell sliding over +/// its neighbours is not monotonic in pixels); its own travel is: a spring only ever closes it. +public nonisolated struct MotionTravelSummary: Sendable, Equatable { + /// Frames with travel left (more than half a point). + public var movingFrames = 0 + /// Frames where the travel grew after it had started to fall: a second motion inside the + /// gesture (or a retarget away), which one motion per gesture never has. + public var restarts = 0 + /// The most travel any frame had left. + public var peak = 0.0 + + @MainActor public init(_ name: String, after time: CFTimeInterval) { + var previous = 0.0, falling = false + for sample in MotionTrace.samples where sample.name == name && sample.time >= time { + if sample.value > 0.5 { movingFrames += 1 } + peak = Swift.max(peak, sample.value) + // Falling from a real motion (a few points' tail of an earlier gesture is not one). + if peak > 4, sample.value < previous - 0.5 { falling = true } + if falling, sample.value > previous + Swift.max(2, peak * 0.05) { + restarts += 1 + falling = false + } + previous = sample.value + } + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift index 281d96264b1d..cd39fc73d8fa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift @@ -8,17 +8,22 @@ public nonisolated enum MotionTunables { // MARK: Springs (speed "fast") static let springDefaults: [MotionSpring: (SpringParameters, String)] = [ - .move: (SpringParameters(response: 0.2, dampingFraction: 0.9), "Tab reflow and reorder, sidebar row moves, pane and divider frames."), + // Structural (design-tokens.json motion.springs, motion.structural): critically damped by + // default, response short enough that a move ends visibly (within 0.5%) by 0.18 s (hq-1d D1). + .move: (SpringParameters(response: 0.15, dampingFraction: 1), "Tab reflow and reorder, sidebar row moves, pane and divider frames."), .zoom: (SpringParameters(response: 0.32, dampingFraction: 0.9), "A pane zooming to fill its screen."), .exit: (SpringParameters(response: 0.12, dampingFraction: 1), "The panes a zoom leaves fading out, the farthest first."), - .appear: (SpringParameters(response: 0.18, dampingFraction: 0.9), "Palette scale-in, tab grow-in, group expand, row insert, sidebar show."), - .disappear: (SpringParameters(response: 0.15, dampingFraction: 0.9), "Tab close, group collapse, row removal, sidebar hide."), + .tabOpen: (SpringParameters(response: 0.1, dampingFraction: 1), "A new tab growing in at its slot and the tabs right of it making room."), + .appear: (SpringParameters(response: 0.135, dampingFraction: 1), "Palette scale-in, tab grow-in, group expand, row insert, sidebar show."), + .disappear: (SpringParameters(response: 0.11, dampingFraction: 1), "Tab close, group collapse, row removal, sidebar hide."), .settle: (SpringParameters(response: 0.22, dampingFraction: 0.85), "Release after a drag: tab drop, ghost landing, sidebar row drop."), .scroll: (SpringParameters(response: 0.22, dampingFraction: 0.9), "Tab strip reveal, strip column reveal, wheel notch, fling snap."), .screen: (SpringParameters(response: 0.22, dampingFraction: 0.9), "Screen switch slide."), .track: (SpringParameters(response: 0.12, dampingFraction: 0.9), "Drop-zone overlay and the drag ghost jumping between targets."), .selection: (SpringParameters(response: 0.15, dampingFraction: 0.9), "Page info toggle state change. The sidebar selection does not animate."), .panel: (SpringParameters(response: 0.18, dampingFraction: 0.85), "Hover card slide."), + .pinDrag: (SpringParameters(response: 0.3512407365520363, dampingFraction: 0.8385254915624211), + "Home list pin drag: tiles making room, rows below the grid, a dropped tile landing."), ] static let springs: [MotionSpring: Tunable] = Dictionary(uniqueKeysWithValues: MotionSpring.allCases.map { token in @@ -27,6 +32,22 @@ public nonisolated enum MotionTunables { default: entry.0, code: "MotionTunables.springDefaults[.\(token.rawValue)]")) }) + /// SwiftUI bounce of the structural springs (`move`, `appear`, + /// `disappear`) when `ui.animations.bounce` is "subtle". With "none" + /// (the default) they are critically damped and never pass the target. + public static let subtleBounce = Tunable.number( + "motion.spring.subtleBounce", .springs, "Subtle bounce", + help: "Bounce of tab, row, pane and divider springs when Animations Bounce is Subtle (0 is critically damped).", + default: 0.15, range: 0...0.5, step: 0.01, unit: .multiplier, code: "MotionTunables.subtleBounce") + + /// How far ahead a released divider's velocity is projected to find a + /// snap (halves, thirds) it is flung onto; it then springs there from + /// that velocity. + public static let dividerReleaseProjection = Tunable.number( + "motion.divider.releaseProjection", .springs, "Divider fling projection", + help: "Seconds of release velocity a divider looks ahead for a snap point.", + default: 0.05, range: 0...0.3, step: 0.01, unit: .seconds, code: "MotionTunables.dividerReleaseProjection") + // MARK: Fades and loops static let fadeDefaults: [MotionFade: (TimeInterval, String)] = [ @@ -120,7 +141,7 @@ public nonisolated enum MotionTunables { /// Every Motion tunable, for the catalog. public static var all: [TunableDescriptor] { - MotionSpring.allCases.compactMap { springs[$0]?.descriptor } + MotionSpring.allCases.compactMap { springs[$0]?.descriptor } + [subtleBounce.descriptor, dividerReleaseProjection.descriptor] + MotionFade.allCases.compactMap { fades[$0]?.descriptor } + MotionLoop.allCases.compactMap { loops[$0]?.descriptor } + [marqueeDelay, marqueeSpeed, marqueeMinimumScroll, marqueeHold, marqueeMinimumTravel, hoverCardDwell, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift index 839787079aca..4780bef0bfaa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift @@ -14,6 +14,24 @@ public nonisolated struct SpringParameters: Hashable, Sendable { self.dampingFraction = dampingFraction } + /// SwiftUI `Spring(duration:bounce:)`: `duration` is the perceptual + /// duration (the response), `bounce` 0 is critically damped, above 0 + /// overshoots (0.15 is a subtle bounce), below 0 is over-damped. + public init(duration: Double, bounce: Double) { + self.response = duration + self.dampingFraction = bounce >= 0 ? 1 - bounce : 1 / (1 + bounce) + } + + /// SwiftUI's bounce for this damping fraction (0 critically damped). + public var bounce: Double { + dampingFraction <= 1 ? 1 - dampingFraction : 1 / dampingFraction - 1 + } + + /// The same response with `bounce` (SwiftUI semantics). + public func withBounce(_ bounce: Double) -> SpringParameters { + SpringParameters(duration: response, bounce: bounce) + } + /// Angular frequency (rad/s). public var omega: Double { 2 * .pi / max(response, 0.001) } /// `CASpringAnimation.stiffness` for mass 1. @@ -73,26 +91,50 @@ public nonisolated struct SpringValue: Hashable, Sendable { self.target = value } - /// Advances by `dt` seconds using fixed substeps (semi-implicit Euler), - /// stable for every frame rate up to 120 Hz and beyond. `dt` is capped - /// at 0.1 s so a stalled frame never teleports. + /// Advances by `dt` seconds with the closed-form solution of the damped + /// harmonic oscillator (under-, critically or over-damped), so the path + /// is exact and identical at every frame rate: two 60 Hz frames land + /// where four 120 Hz frames do. `dt` is capped at 0.1 s so a stalled + /// frame never teleports. public mutating func step(_ dt: Double, parameters: SpringParameters) { - let stiffness = parameters.stiffness - let damping = parameters.damping - var remaining = min(max(dt, 0), 0.1) - let maxStep = 1.0 / 480.0 - var x = Double(value) - var v = Double(velocity) - let t = Double(target) - while remaining > 0 { - let h = min(remaining, maxStep) - let acceleration = -stiffness * (x - t) - damping * v - v += acceleration * h - x += v * h - remaining -= h + let t = min(max(dt, 0), 0.1) + guard t > 0 else { return } + let state = SpringValue.solve(displacement: Double(value - target), velocity: Double(velocity), time: t, parameters: parameters) + value = target + CGFloat(state.displacement) + velocity = CGFloat(state.velocity) + } + + /// Displacement from the target and velocity `time` seconds after a + /// state, for `parameters` (mass 1). + public static func solve(displacement x0: Double, velocity v0: Double, time t: Double, + parameters given: SpringParameters) -> (displacement: Double, velocity: Double) { + // A non-finite tuning takes `move`'s (cmux-motion `finite_or`): NaN damping would never settle. + var parameters = given + if !(given.response.isFinite && given.response > 0 && given.dampingFraction.isFinite) { + let fallback = MotionSpring.move.base + parameters = SpringParameters( + response: given.response.isFinite && given.response > 0 ? given.response : fallback.response, + dampingFraction: given.dampingFraction.isFinite ? given.dampingFraction : fallback.dampingFraction) + } + let w0 = parameters.omega + let zeta = max(parameters.dampingFraction, 0) + if abs(zeta - 1) < 1e-4 { + let decay = exp(-w0 * t) + let b = v0 + w0 * x0 + return ((x0 + b * t) * decay, (v0 - w0 * b * t) * decay) + } + if zeta < 1 { + let wd = w0 * (1 - zeta * zeta).squareRoot() + let decay = exp(-zeta * w0 * t) + let c = cos(wd * t), s = sin(wd * t) + let b = (v0 + zeta * w0 * x0) / wd + return (decay * (x0 * c + b * s), decay * (v0 * c - (w0 * w0 * x0 + zeta * w0 * v0) / wd * s)) } - value = CGFloat(x) - velocity = CGFloat(v) + let root = (zeta * zeta - 1).squareRoot() + let r1 = -w0 * (zeta - root), r2 = -w0 * (zeta + root) + let c2 = (v0 - r1 * x0) / (r2 - r1), c1 = x0 - c2 + let e1 = exp(r1 * t), e2 = exp(r2 * t) + return (c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2) } public func isSettled(epsilon: CGFloat) -> Bool { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WindowFrameStrip.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WindowFrameStrip.swift new file mode 100644 index 000000000000..d00025fa44ae --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WindowFrameStrip.swift @@ -0,0 +1,87 @@ +#if DEBUG +public import AppKit +import Foundation + +/// The analysis half of `debug.new_tab {action: "frame_strip"}` (DEBUG builds): the app sends the +/// key or gesture and records the window (``WindowFrame``); this compares the frames and writes them. +/// +/// A paint-stable opening changes only the content area of the pane that shows the new page (its +/// `contentHost`), and that in one step: every frame outside it equals the frame before the key or +/// the last frame (`intermediate` counts the ones that equal neither), and no frame shows the content +/// area blank, one flat color that was not there before the key (`blank`). PNGs of every frame and one +/// strip (`strip.png`, the frames side by side) go to `dir`. +/// +/// In the content area `settled_ms` is the first frame equal to the last for good, `first_change_ms` +/// the first unlike before the key, and `content_intermediate` counts frames equal to neither. The +/// pane's strip (its tabs slide) is left out of those and of `intermediate`; `strip_intermediate` +/// counts its frames equal to neither, and `desync` those where one of strip and content shows the +/// new tab and the other the old one. Motion proof knobs and fields: ``MotionFrameProgress``. +extension NSWindow { + /// The report fields (Foundation values) of `frames` recorded from this window, the key sent at + /// `sentAt` ms (`sentTime` in media time); nil when no frame came before the key. + public func frameStripReport(_ frames: [WindowFrame], sentAt: Double, sentTime: CFTimeInterval, + content contentHost: NSView?, strip stripView: NSView?, tolerance: Double, jump: Double?, + dir: String) async -> [String: Any]? { + guard let first = frames.first, let last = frames.last, let before = frames.last(where: { $0.milliseconds < sentAt }) else { return nil } + let content = contentHost.flatMap { pixelRect(of: $0, frame: first) } + // The pane's strip, apart: its own motion (tabs sliding over) is not content. + let strip = stripView.flatMap { $0.isHiddenOrHasHiddenAncestor ? nil : pixelRect(of: $0, frame: first) } + try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + var rows: [[String: Any]] = [] + var intermediate = 0, blank = 0, contentIntermediate = 0, desync = 0, stripIntermediate = 0 + var settledAt: Double?, firstChange: Double? + var progress = MotionFrameProgress(jump: jump) + let shown = frames.firstIndex { $0.milliseconds >= before.milliseconds } ?? frames.endIndex + // Off the main actor: six comparisons a frame stalled it past the watchdog in a Debug build. + let measures = await WindowFrame.measure(Array(frames[shown...]), before: before, last: last, content: content, strip: strip) + for (offset, m) in measures.enumerated() { + let index = shown + offset, frame = frames[index] + let (insideBefore, insideFinal, stripBefore, stripFinal) = (m.insideBefore, m.insideFinal, m.stripBefore, m.stripFinal) + let isIntermediate = m.outsideBefore > tolerance && m.outsideFinal > tolerance + // One of the strip and the content at the new tab, the other still at the old one (L4). + let isDesync = strip != nil + && ((stripFinal <= tolerance && stripBefore > tolerance && insideBefore <= tolerance && insideFinal > tolerance) + || (insideFinal <= tolerance && insideBefore > tolerance && stripBefore <= tolerance && stripFinal > tolerance)) + // Neither the content before the key nor the settled one; blank: flat too (an empty terminal is flat). + let isBlank = content != nil && m.flatness > 0.995 && insideBefore > tolerance && insideFinal > tolerance + let isContentIntermediate = frame.milliseconds > sentAt && insideBefore > tolerance && insideFinal > tolerance + if isIntermediate { intermediate += 1 } + progress.add(outsideBefore: m.outsideBefore, outsideFinal: m.outsideFinal) + if isBlank { blank += 1 } + if isContentIntermediate { contentIntermediate += 1 } + if frame.milliseconds > sentAt { + if stripBefore > tolerance && stripFinal > tolerance { stripIntermediate += 1 } + if isDesync { desync += 1 } + if firstChange == nil, insideBefore > tolerance { firstChange = frame.milliseconds - sentAt } + if insideFinal > tolerance { settledAt = nil } else if settledAt == nil { settledAt = frame.milliseconds - sentAt } + } + let name = String(format: "frame-%03d.png", index) + frame.write(to: (dir as NSString).appendingPathComponent(name)) + rows.append([ + "ms": (frame.milliseconds - sentAt).rounded(), "file": name, + "outside_vs_before": m.outsideBefore, "outside_vs_final": m.outsideFinal, "intermediate": isIntermediate, + "blank": isBlank, "desync": isDesync, "content_intermediate": isContentIntermediate, + ]) + } + WindowFrame.writeStrip(frames.filter { $0.milliseconds >= before.milliseconds }, to: (dir as NSString).appendingPathComponent("strip.png")) + let motion = progress.fields(moving: intermediate, tabs: MotionTravelSummary("tabs.travel", after: sentTime)) + let report: [String: Any] = [ + "dir": dir, "frames": rows, "intermediate": Double(intermediate), "blank": Double(blank), "frame_count": Double(rows.count), + "content_intermediate": Double(contentIntermediate), "desync": Double(desync), "strip_intermediate": Double(stripIntermediate), + "settled_ms": settledAt.map { $0.rounded() as Any } ?? NSNull(), "first_change_ms": firstChange.map { $0.rounded() as Any } ?? NSNull(), + "mean_interval_ms": frames.count > 1 ? (last.milliseconds - first.milliseconds) / Double(frames.count - 1) : 0, + ] + return report.merging(motion.mapValues { $0 as Any }) { first, _ in first } + } + + /// `host`'s bounds in `frame` pixels (top-left origin). + private func pixelRect(of host: NSView, frame: WindowFrame) -> CGRect? { + guard host.window === self, + let windowHeight = contentView?.superview?.bounds.height ?? contentView?.bounds.height + else { return nil } + let rect = host.convert(host.bounds, to: nil) + let sx = Double(frame.width) / self.frame.width, sy = Double(frame.height) / windowHeight + return CGRect(x: rect.minX * sx, y: (windowHeight - rect.maxY) * sy, width: rect.width * sx, height: rect.height * sy) + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift index b5f9fce04d34..0d29a4c90376 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/ChiefProfilePageRelay.swift @@ -1,5 +1,6 @@ public import CmuxNextDaemon public import Foundation +import os /// The home-channels page's Chief profile calls (`cmux.chief.profile.*`, hq-ec's New Chief, edit /// and archive sheets; chief-profile-ws-v1) go to this build's Chief OWNER, where the Chiefs live, @@ -37,15 +38,38 @@ public final class ChiefProfilePageRelay { } private weak var owner: ChiefConversationOwner? - private let identity = PageRelayIdentity(page: "home-channels") private let wake = RetryWake(owner: "ChiefProfilePageRelay") - private var relay: (connection: DaemonConnection, socket: String)? - private var starting: (task: Task, socket: String)? + private let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "chief-profile-relay") + private let clock: any Clock + /// Every page call answers within this, also when a step below it never does: the page's + /// sheet then clears busy and shows the error (`cmux.protocol.timeout`). Each step has the + /// 2 s request deadline: a connect (identify, hello, subscribe: 6 s), a confirmation and the + /// call (4 s), so 10 s per attempt and 16 s leave room for the one reconnect's connect. + nonisolated static let callDeadline: Duration = .seconds(16) + + /// The owner process a relay connection serves: a restarted owner (same socket, new process) + /// is another owner, and its page calls go on a connection to it. + struct OwnerKey: Equatable { + var socket: String + var pid: Int32 + var generation: DaemonGeneration + } + + /// One page relay connection with its own `client-hello` id (a token is bound to it). + private struct Relay { + var connection: DaemonConnection + var owner: OwnerKey + var identity: PageRelayIdentity + } + + private var relay: Relay? + private var starting: (task: Task, owner: OwnerKey)? /// The event uptime of the last gesture a call spent. private var spentGesture: TimeInterval? - init(owner: ChiefConversationOwner) { + init(owner: ChiefConversationOwner, clock: any Clock = ContinuousClock()) { self.owner = owner + self.clock = clock } /// Whether the owner takes `operation` with `params` from the page only on a gesture. @@ -58,47 +82,124 @@ public final class ChiefProfilePageRelay { } /// One page call to the owner. `gestureEvent`: the uptime of the real key or pointer event - /// that backs this call (`PageCallContext.gestureEvent`), nil without one. + /// that backs this call (`PageCallContext.gestureEvent`), nil without one. It always answers + /// within ``callDeadline``: the owner's result, its refusal, or a typed error. public func send(operation: String, params: [String: JSONValue], idempotencyKey: String?, gestureEvent: TimeInterval?) async throws -> JSONValue { guard Self.operations.contains(operation) else { throw DaemonError.command(cmd: operation, message: "not a Chief profile operation", code: "unknown_op") } - guard let owner, let main = owner.connection, let socket = await owner.socketPath() else { throw DaemonError.notConnected } - let relay = try await relayConnection(socket: socket) - var origin = JSONValue.object(["claim": .string("page")]) - if Self.needsGesture(operation, params: params), let event = gestureEvent, event != spentGesture { - spentGesture = event - let token = try await confirmationToken(main: main, operation: operation, params: params) - origin = .object(["claim": .string("user"), "confirmation": .string(token)]) + // One gesture backs one call; a reconnect inside this call may use it again. + let gesture = Self.needsGesture(operation, params: params) && gestureEvent != nil && gestureEvent != spentGesture + if gesture { spentGesture = gestureEvent } + let started = ContinuousClock.now + let elapsed = { Int((ContinuousClock.now - started) / .milliseconds(1)) } + logger.info("\(operation, privacy: .public): start, gesture \(gesture)") + do { + let result = try await PageCallDeadline.answer(within: Self.callDeadline, clock: clock, onTimeout: { [weak self] in + // A step that never answered: the next call starts on a new connection. + self?.dropRelay() + return DaemonError.timedOut("\(operation) (no answer from the Chief owner within \(Self.callDeadline))") + }) { [self] in + try await call(operation: operation, params: params, idempotencyKey: idempotencyKey, gesture: gesture) + } + logger.info("\(operation, privacy: .public): answered in \(elapsed()) ms") + return result + } catch { + logger.info("\(operation, privacy: .public): failed in \(elapsed()) ms: \(String(describing: error), privacy: .public)") + throw error + } + } + + /// The call on the relay connection to the current owner; once more on a new connection when + /// that connection was closed under it (the owner restarted). A mutation carries its + /// idempotency key, so the owner applies it once. + private func call(operation: String, params: [String: JSONValue], idempotencyKey: String?, gesture: Bool) async throws -> JSONValue { + // Two attempts at most: the second only after the first lost its connection. + for attempt in 1...2 { + guard let owner, let main = owner.connection, let socket = await owner.socketPath(), + let identity = owner.identity else { throw DaemonError.notConnected } + var used: DaemonConnection? + do { + let relay = try await relayConnection(OwnerKey(socket: socket, pid: identity.pid, generation: identity.generation)) + used = relay.connection + // A call the deadline already answered sends nothing more: a late create after the + // page showed the timeout would make a Chief the person was told failed. + try Task.checkCancellation() + var origin = JSONValue.object(["claim": .string("page")]) + if gesture { + let token = try await confirmationToken(main: main, relay: relay, operation: operation, params: params) + origin = .object(["claim": .string("user"), "confirmation": .string(token)]) + } + try Task.checkCancellation() + return try await ResourceRelayClient(connection: relay.connection).send( + operation: operation, params: params, idempotencyKey: idempotencyKey, origin: origin) + } catch let error as DaemonError where attempt == 1 && Self.isConnectionLost(error) { + logger.info("\(operation, privacy: .public): relay connection lost (\(error.description, privacy: .public)); again on a new one") + if let used, self.relay?.connection === used { dropRelay() } + } } - return try await ResourceRelayClient(connection: relay).send( - operation: operation, params: params, idempotencyKey: idempotencyKey, origin: origin) + // Not reached: the second attempt returns or throws. + throw DaemonError.notConnected } - /// The page relay connection to the owner at `socket`; a new one when the owner moved. - private func relayConnection(socket: String) async throws -> DaemonConnection { - if let relay, relay.socket == socket, await relay.connection.isReady { return relay.connection } - // A connect to the same owner in flight is shared; one to an owner that moved is not. - if let starting, starting.socket == socket { return try await starting.task.value } + nonisolated static func isConnectionLost(_ error: DaemonError) -> Bool { + switch error { + case .notConnected, .connectionClosed, .daemonShutdown: true + default: false + } + } + + /// Closes the relay connection; the next call connects again. + private func dropRelay() { + guard let relay else { return } + self.relay = nil + // task-owner: one close of a connection no call uses any more + Task { await relay.connection.close() } + } + + /// The page relay connection to `owner`, the owner process the app's main connection is + /// attached to now; a new one when that owner restarted or the connection dropped. + private func relayConnection(_ owner: OwnerKey) async throws -> Relay { + if let relay, relay.owner == owner, await relay.connection.isReady, await relay.connection.identity.map({ + $0.pid == owner.pid && $0.generation == owner.generation + }) == true { + return relay + } + // A connect to the same owner in flight is shared; one to another owner is not. + if let starting, starting.owner == owner { return try await starting.task.value } + let identity = PageRelayIdentity(page: "home-channels") let configuration = DaemonConnection.Configuration( clientName: "cmux-next-chief-profile-relay", requiredCapabilities: [], retryWake: wake, terminalEnvironment: nil, role: .pageRelay, relayIdentity: identity) - // task-owner: one connect; ends with its handshake - let task = Task { () async throws -> DaemonConnection in - let connection = DaemonConnection(endpoint: DaemonEndpoint(socketPath: socket), configuration: configuration) - try await connection.start() - return connection + // task-owner: one connect; ends with its handshake (each line has the request deadline) + let task = Task { () async throws -> Relay in + let connection = DaemonConnection(endpoint: DaemonEndpoint(socketPath: owner.socket), configuration: configuration) + let reached = try await connection.start() + // The socket may already serve another owner process than the one the app's main + // connection is attached to (it restarted in between): that is not this owner. + guard reached.pid == owner.pid, reached.generation == owner.generation else { + await connection.close() + throw DaemonError.notConnected + } + return Relay(connection: connection, owner: owner, identity: identity) + } + starting = (task, owner) + defer { if starting?.owner == owner { starting = nil } } + let made = try await task.value + // A connect to an older owner that finished after a newer one started is not used: the + // call goes once more, to the owner the main connection is attached to now. + guard starting?.owner == owner else { + await made.connection.close() + throw DaemonError.notConnected } - starting = (task, socket) - defer { if starting?.socket == socket { starting = nil } } - let connection = try await task.value - await relay?.connection.close() - relay = (connection, socket) - return connection + let old = relay + relay = made + if let old, old.connection !== made.connection { await old.connection.close() } + return made } /// `origin.confirmation.issue` on the app's trusted main connection to the owner. - private func confirmationToken(main: DaemonConnection, operation: String, params: [String: JSONValue]) async throws -> String { - guard let relayID = identity.connectionID else { throw DaemonError.notConnected } + private func confirmationToken(main: DaemonConnection, relay: Relay, operation: String, params: [String: JSONValue]) async throws -> String { + guard let relayID = relay.identity.connectionID else { throw DaemonError.notConnected } let issued = try await ResourceRelayClient(connection: main).send( operation: "origin.confirmation.issue", params: ["operation": .string(operation), "params_sha256": .string(try ResourceRelayClient.paramsDigest(params)), diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift index b4be5b990965..666e7ea9d7c6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeBrainHost.swift @@ -46,16 +46,25 @@ public nonisolated struct HomeBrainHost: Sendable { bundledBinDirectory: URL? = Bundle.main.resourceURL?.appendingPathComponent("bin", isDirectory: true), bundledChiefAllowed: Bool = HomeBrainHost.bundledChiefAllowed(bundleID: Bundle.main.bundleIdentifier, isDebugBuild: DevTools.isDebugBuild)) -> Bool { + // The build-session kill switch: no Chief host, so no brain starts (cx-9ettq). + if AcpmuxEnvironment.agentLaunchDisabled(environment) { return false } let override = environment["CMUX_NEXT_MUX_HOST"].flatMap { $0.isEmpty ? nil : $0 } let bundled = bundledChiefAllowed ? bundledBinDirectory?.appendingPathComponent(bundledChiefName).path : nil return [override, bundled].compactMap { $0 }.contains { FileManager.default.isExecutableFile(atPath: $0) } } + /// Home's notice on this Mac's Chief while the build-session kill switch + /// is on (cx-tgf4h): no host starts, so the Chief cannot answer; nil else. + public static func agentsDisabledNotice(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + AcpmuxEnvironment.agentLaunchDisabled(environment) ? HomeStrings.chiefAgentsDisabled : nil + } + public static func resolve(daemonSocket: String, controlSocket: String, home: ChiefHome, environment: [String: String] = ProcessInfo.processInfo.environment, bundledBinDirectory: URL? = Bundle.main.resourceURL?.appendingPathComponent("bin", isDirectory: true), bundledChiefAllowed: Bool = HomeBrainHost.bundledChiefAllowed(bundleID: Bundle.main.bundleIdentifier, isDebugBuild: DevTools.isDebugBuild)) -> HomeBrainHost? { + if AcpmuxEnvironment.agentLaunchDisabled(environment) { return nil } let override = environment["CMUX_NEXT_MUX_HOST"].flatMap { $0.isEmpty ? nil : $0 } let bundled = bundledChiefAllowed ? bundledBinDirectory?.appendingPathComponent(bundledChiefName).path : nil guard let path = [override, bundled].compactMap({ $0 }).first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift index 01cd457ec4be..4b8a5339cfc8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeChiefEngineError.swift @@ -12,6 +12,8 @@ public nonisolated enum HomeChiefEngineError: Error, Equatable, Sendable { case forbidden case notConfigured case unsupported + /// Agents are off in this build session (`agent_launch_disabled`). + case agentsDisabled case refused(reason: String, message: String) case other(String) @@ -21,6 +23,8 @@ public nonisolated enum HomeChiefEngineError: Error, Equatable, Sendable { case .forbidden: self = .forbidden case .notConfigured: self = .notConfigured case .unsupported: self = .unsupported + // cx-tgf4h: the daemon's typed refusal while agents are off. + case .refused(let reason, _) where reason == "agent_launch_disabled": self = .agentsDisabled case .refused(let reason, let message): self = .refused(reason: reason, message: message) case .other(let text): self = .other(text) } @@ -34,6 +38,7 @@ public nonisolated enum HomeChiefEngineError: Error, Equatable, Sendable { case .forbidden: HomeEngineStrings.errorForbidden case .notConfigured: HomeEngineStrings.errorNotConfigured case .unsupported: HomeEngineStrings.errorUnsupported + case .agentsDisabled: HomeStrings.chiefAgentsDisabled case .refused(_, let message), .other(let message): String(format: HomeEngineStrings.errorRefusedFormat, message) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift index 53664b388c92..19885bde2c23 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeDock.swift @@ -24,8 +24,12 @@ public final class HomeDock { public static let page = "home-channels" /// Classic Home for one release (`labs.classicHome`): the top page, with the Chief tab in the /// hidden home workspace. `CMUX_NEXT_CLASSIC_HOME=1` at launch selects it too (GUI tests of the - /// native Home, until the setting lands). - public static var classic: Bool { DesignSettings.shared.classicHome || classicAtLaunch } + /// native Home). Both are read once, at the first read after launch (the settings file is applied + /// before any window or Home starts): a change takes effect after cmux restarts, so Home is + /// never half docked (the dock ensure runs only on connects; a live flip would leave the old + /// form's tabs and refuse Home's actions). + public static var classic: Bool { latched } + private static let latched: Bool = DesignSettings.shared.classicHome || classicAtLaunch /// `CMUX_NEXT_CLASSIC_HOME=1` in the app's launch environment, read once. static let classicAtLaunch = ProcessInfo.processInfo.environment["CMUX_NEXT_CLASSIC_HOME"] == "1" public var isClassic: Bool { Self.classic } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeStrings.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeStrings.swift index 6858b15dcbc3..098641c9a8c3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeStrings.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/HomeStrings.swift @@ -15,6 +15,12 @@ public nonisolated enum HomeStrings { static var chiefMergeBlocked: String { String(localized: "home.chief.mergeBlocked", defaultValue: "Quit older cmux DEV builds to merge Chief history", table: "Home", bundle: .module) } + /// The Chief's notice while agents are off in this build session (cx-tgf4h). + public static var chiefAgentsDisabled: String { + String(localized: "home.chief.agentsDisabled", + defaultValue: "Agents are disabled in this build session. The Chief does not answer.", + table: "Home", bundle: .module) + } /// The Chief conversation's notice while a turn waits for the compactor. static func chiefOrganizing(built: Int, total: Int) -> String { String(format: String(localized: "home.chief.organizing", defaultValue: "Organizing Chief history: %1$lld of %2$lld", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift new file mode 100644 index 000000000000..21056635b6ae --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/PageCallDeadline.swift @@ -0,0 +1,58 @@ +import CmuxNextWakeups +import Foundation + +/// A page call that answers by a deadline, also when its work never ends: the first of the work's +/// end and the deadline answers (once). The deadline is a ``DemandTimer`` on the injected clock, so +/// a test moves time without waiting. The work's task is cancelled when the deadline or the +/// caller's cancel answers; a late result is dropped. +@MainActor +enum PageCallDeadline { + static func answer(within deadline: Duration, clock: any Clock, + onTimeout: @escaping @MainActor @Sendable () -> any Error, + _ work: @escaping @MainActor @Sendable () async throws -> T) async throws -> T { + let once = Once() + let timer = DemandTimer(owner: "PageCallDeadline", clock: clock) + defer { timer.cancel() } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + // Cancelled before it started: the cancel handler found nothing to answer. + guard !Task.isCancelled else { return continuation.resume(throwing: CancellationError()) } + once.continuation = continuation + // task-owner: the call's work; cancelled when the deadline or the caller's cancel answers first + once.task = Task { @MainActor in + let result: Result + do { result = .success(try await work()) } catch { result = .failure(error) } + once.finish(result) + } + timer.schedule(after: deadline) { @MainActor in + guard once.isWaiting else { return } + once.finish(.failure(onTimeout())) + } + } + } onCancel: { + // The page cancelled the call (navigation, tab close): its work stops sending too. + // task-owner: one hop onto the main actor to answer and cancel the work + Task { @MainActor in once.finish(.failure(CancellationError())) } + } + } + + /// The continuation and the work's task; the first ``finish(_:)`` resumes, later ones are dropped. + @MainActor + private final class Once { + var continuation: CheckedContinuation? + var task: Task? + + var isWaiting: Bool { continuation != nil } + + // A main-actor generic class's isolated deinit crashes swift-frontend 6.3.3 in a Release + // compile (EarlyPerfInliner, below the macOS 15 back-deploy); this one touches nothing. + nonisolated deinit {} + + func finish(_ result: Result) { + guard let continuation else { return } + self.continuation = nil + task?.cancel() + continuation.resume(with: result) + } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings index 347297146944..40f4939be9c1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextHomeEngine/Resources/Home.xcstrings @@ -4064,6 +4064,137 @@ } } } + }, + "home.chief.agentsDisabled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Agents are disabled in this build session. The Chief does not answer." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このビルドセッションではエージェントが無効です。Chief は返信しません。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "الوكلاء معطّلون في جلسة البناء هذه. لن يرد الـ Chief." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Agenti su isključeni u ovoj sesiji builda. Chief ne odgovara." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Agenter er slået fra i denne build-session. Chief svarer ikke." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Agenten sind in dieser Build-Sitzung deaktiviert. Der Chief antwortet nicht." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Los agentes están desactivados en esta sesión de compilación. El Chief no responde." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Les agents sont désactivés dans cette session de build. Le Chief ne répond pas." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Gli agenti sono disattivati in questa sessione di build. Il Chief non risponde." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ភ្នាក់ងារត្រូវបានបិទនៅក្នុងវគ្គ build នេះ។ Chief មិនឆ្លើយតបទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "이 빌드 세션에서는 에이전트가 꺼져 있습니다. Chief가 답하지 않습니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Agenter er slått av i denne byggeøkten. Chief svarer ikke." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Agenci są wyłączeni w tej sesji kompilacji. Chief nie odpowiada." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Os agentes estão desativados nesta sessão de build. O Chief não responde." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Агенты отключены в этом сеансе сборки. Chief не отвечает." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เอเจนต์ถูกปิดในเซสชันบิลด์นี้ Chief จะไม่ตอบ" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Bu derleme oturumunda ajanlar devre dışı. Chief yanıt vermiyor." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Агентів вимкнено в цьому сеансі збірки. Chief не відповідає." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Các tác tử bị tắt trong phiên build này. Chief không trả lời." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "此构建会话中已停用代理。Chief 不会回复。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "此建置工作階段已停用代理。Chief 不會回覆。" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift index d4a74927ffc2..6f0e451d9edc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/DisplayLinkDriver.swift @@ -1,42 +1,46 @@ import AppKit import CmuxNextDesign -import CmuxNextWakeups -/// Drives spring animations from the window's ``FrameScheduler`` (up to -/// 120 Hz on ProMotion). Goes idle whenever `onFrame` reports nothing is -/// moving, so the window's display link stops with the last animation. -final class DisplayLinkDriver { +/// Steps the layout's springs (panes, dividers, columns, screens, drop +/// zones) on the window's one `MotionClock`, the clock the tab strip and the +/// sidebar rows also step on (up to 120 Hz on ProMotion). Goes idle whenever +/// `onFrame` reports nothing is moving, so the window's display link stops +/// with the last animation. +final class DisplayLinkDriver: MotionAnimating { /// Called once per frame with the elapsed seconds. Return true to keep ticking. var onFrame: ((Double) -> Bool)? /// Tests are the clock: `start` asks for no display-link frames, and the test steps /// `LayoutRootView.advanceFrame` at a fixed dt instead. var isManual = false - private var client: FrameClient? + private weak var view: NSView? + private weak var clock: MotionClock? - var isAttached: Bool { client != nil } + var isAttached: Bool { view != nil } /// An animation is in flight. - var isRunning: Bool { client?.isActive ?? false } + var isRunning: Bool { clock?.isRunning(self) ?? false } func attach(to view: NSView) { detach() - client = FrameClient(owner: "Layout.animation", view: view) { [weak self] tick in - let keepGoing = MotionTrace.measure("layout") { self?.onFrame?(tick.elapsed) ?? false } - if !keepGoing { MotionTrace.end("layout") } - return keepGoing - } + self.view = view } /// Stops ticking; call when leaving the window. func detach() { - if client?.isActive == true { MotionTrace.end("layout") } - client?.deactivate() - client = nil + clock?.stop(self) + clock = nil + view = nil } func start() { - guard let client, !client.isActive, !isManual else { return } - MotionTrace.begin("layout") - client.activate() + guard let view, !isManual else { return } + let target = MotionClock.forView(view) + if let clock, clock !== target { clock.stop(self) } + clock = target + target.run(self, trace: "layout") + } + + func motionStep(_ dt: Double) -> Bool { + onFrame?(dt) ?? false } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift index ad1bc0522eb7..aadd5e491fa2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/PaneHostView.swift @@ -158,7 +158,7 @@ final class PaneHostView: NSView { /// Returns true when the clip changed: a Chromium page in this pane then /// re-reads its clip shape, which carries this mask (`CEFClipShape`). @discardableResult - func setStripClip(_ rect: CGRect?) -> Bool { + func setStripClip(_ rect: CGRect?, cornerRadius: CGFloat = 0) -> Bool { guard let layer else { return false } guard let rect else { guard layer.mask != nil else { return false } @@ -172,10 +172,11 @@ final class PaneHostView: NSView { layer.mask = mask return mask }() - guard mask.frame != rect else { return false } + guard mask.frame != rect || mask.cornerRadius != cornerRadius else { return false } CATransaction.begin() CATransaction.setDisableActions(true) mask.frame = rect + mask.cornerRadius = cornerRadius CATransaction.commit() return true } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift index c244a7fa7caa..2abbd35910f1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+DividerDrag.swift @@ -1,4 +1,6 @@ import AppKit +import CmuxNextDesign +import QuartzCore /// Divider and column-edge drags, emitted as transactional layout intents. extension ScreenContentView { @@ -59,9 +61,14 @@ extension ScreenContentView { model.setGestureActive(true) context.requestFrames() case let .moved(windowPoint): + activeDrag?.sample(contentPoint(fromWindow: windowPoint, kind: kind), at: CACurrentMediaTime()) applyDrag(at: windowPoint, phase: .changed) context.requestFrames() case let .ended(windowPoint): + if case .split = kind, let drag = activeDrag { + // The layout this release makes springs on from the pointer's velocity. + releasedDivider = (kind, drag.releaseVelocity(at: CACurrentMediaTime()), CACurrentMediaTime()) + } applyDrag(at: windowPoint, phase: .ended) activeDrag = nil model.setGestureActive(false) @@ -84,7 +91,13 @@ extension ScreenContentView { let style = context.style switch drag.kind { case let .split(id): - guard let ratio = activeDrag?.splitRatio(at: point, style: style, phase: phase) else { return } + // A fling that would carry the divider onto a snap within the spring's reach lands there. + let reach = phase == .ended && context.animatesPaneLayout ? (releasedDivider?.velocity ?? 0) * CGFloat(Motion.dividerReleaseProjection) : 0 + let projected = drag.axis == .horizontal ? CGPoint(x: point.x + reach, y: point.y) : CGPoint(x: point.x, y: point.y + reach) + var probe = drag + let flung = reach != 0 ? probe.splitRatio(at: projected, style: style, phase: phase) : nil + guard let ratio = flung.flatMap({ drag.snaps.contains($0) ? $0 : nil }) ?? activeDrag?.splitRatio(at: point, style: style, phase: phase) + else { return } context.model.setSplitRatio(id, ratio: ratio, transaction: drag.transaction, phase: phase) case let .columnEdge(id): // Strip widths are shares of the strip's viewport; a side dock's diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift index 2a5076a330bd..a5ff669325d3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Dock.swift @@ -71,9 +71,11 @@ extension ScreenContentView { var visible = revealing.map(host.frame.intersection) ?? host.frame if scrolls, let clip { visible = visible.intersection(clip) } for cover in panes(above: host) { visible = visible.remainder(outside: cover) } + // A split's presented rect is the content itself: rounded as at rest. let changed = visible == host.frame ? host.setStripClip(nil) - : host.setStripClip(visible.isNull ? .zero : visible.offsetBy(dx: -host.frame.minX, dy: -host.frame.minY)) + : host.setStripClip(visible.isNull ? .zero : visible.offsetBy(dx: -host.frame.minX, dy: -host.frame.minY), + cornerRadius: revealing == nil ? 0 : host.cornerRadius) // A clip can change while the pane stays put (a dock growing over it): the window's // Chromium pages re-read their clip shapes, which carry this mask. if changed { context.stripClipsDidChange() } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Visibility.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Visibility.swift new file mode 100644 index 000000000000..7b5cc2e8f8ad --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView+Visibility.swift @@ -0,0 +1,91 @@ +import AppKit +import CmuxNextDesign + +// Pane chrome (focus ring, dim, attention), visibility and hit testing, and +// the travel a split has left (debug.motion), split out of +// ScreenContentView.swift to keep it under its size budget. +extension ScreenContentView { + // MARK: Chrome + + /// Focus ring, inactive dim and attention rings. Overlay-only: no pane + /// frame or inset depends on any of it. + func updateChrome(focused: PaneID?, dimsInactive: Bool, attention: [PaneID: AttentionMark], animated: Bool) { + let multiple = paneFrames.count > 1 + let style = context.style + let ringAllowed = (multiple || style.focusRing.showsForSinglePane) + && style.focusIndicator.marksBorder + // Pointer and scroll focus should not leave a keyboard-only focus ring behind. + && context.model.lastFocusSource != .pointer + && context.model.lastFocusSource != .scroll + let dim = style.inactiveDim(dimsInactive: dimsInactive) + for pane in paneFrames.keys { + guard let host = context.hosts[pane] else { continue } + let isFocused = pane == focused + host.setChrome( + showsRing: ringAllowed && isFocused, + dim: multiple && !isFocused ? dim.amount : 0, dimFill: dim.fill, + focusRing: style.focusRing, + ringAlphaOverride: style.focusRingAlphaOverride, + tabEmphasis: .forPane(isFocused: isFocused, paneCount: paneFrames.count, indicator: style.focusIndicator, + style: style.inactiveTabStyle, strength: style.inactiveTabStrength), + border: PaneOverlayView.Border(shows: style.showsPaneBorder, width: style.paneBorderWidth, color: style.paneBorderColor), + attention: attention[pane], + attentionSettings: style.attention, + animated: animated + ) + } + } + + // MARK: Visibility and hit testing + + /// Panes whose displayed frame intersects the viewport. + func visiblePanes() -> Set { + var result: Set = [] + let uncovered = uncoveredRect + for (pane, frame) in paneFrames { + // A strip pane wholly under a docked column is hidden. + let scrolls = geometry.scrolls(pane: pane) + let displayed = displayedRect(frame.rect, pane: pane) + let overlap = displayed.intersection(scrolls ? uncovered : bounds) + if !overlap.isNull, overlap.width > 0.5, overlap.height > 0.5, frame.alpha.value > 0.01 { + result.insert(pane) + } + } + return result + } + + /// Panes whose displayed frame lies within one viewport width of the + /// viewport on either side (architecture.md 4): visible panes plus the + /// off-screen columns a short scroll brings in. Their content stays + /// alive, paused, so scrolling back shows it at once. + func keepAlivePanes() -> Set { + KeepAliveBand.panes(displayed: paneFrames.reduce(into: [:]) { $0[$1.key] = displayedRect($1.value.rect, pane: $1.key) }, + viewport: bounds) + } + + /// The pane under `localPoint`: a docked column's pane above the strip. + func pane(at localPoint: NSPoint) -> PaneID? { + if geometry.dock.contains(where: { $0.cover.contains(localPoint) }) { + return geometry.panes.first { !geometry.scrolls(pane: $0.key) && $0.value.contains(localPoint) }?.key + } + let content = CGPoint(x: localPoint.x - stripShift, y: localPoint.y) + return geometry.panes.first { geometry.scrolls(pane: $0.key) && $0.value.contains(content) }?.key + } + + /// Displayed frame of `pane` in local coordinates. + func displayedFrame(of pane: PaneID) -> CGRect? { + paneFrames[pane].map { displayedRect($0.rect, pane: pane) } + } + + // MARK: Motion trace + + /// The points every pane and divider still has to travel (all four + /// edges): the layout spring's path, sampled per frame for debug.motion. + var travelLeft: CGFloat { + func left(_ frame: AnimatedFrame) -> CGFloat { + abs(frame.x.target - frame.x.value) + abs(frame.y.target - frame.y.value) + + abs(frame.width.target - frame.width.value) + abs(frame.height.target - frame.height.value) + } + return paneFrames.values.reduce(0) { $0 + left($1) } + dividerFrames.values.reduce(0) { $0 + left($1) } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift index 4a7bfdf3a54e..79b5ef06d197 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenContentView.swift @@ -21,14 +21,21 @@ final class ScreenContentView: NSView { /// `baseGeometry` at the presented row offsets (`shiftingRows`): what /// hit testing, drops, focus and the strip scroll read. var geometry: ScreenGeometry - private var paneFrames: [PaneID: AnimatedFrame] = [:] + private(set) var paneFrames: [PaneID: AnimatedFrame] = [:] private(set) var dividerViews: [DividerHandleView.Kind: DividerHandleView] = [:] - private var dividerFrames: [DividerHandleView.Kind: AnimatedFrame] = [:] + private(set) var dividerFrames: [DividerHandleView.Kind: AnimatedFrame] = [:] /// Panes growing in from a split's edge (`PaneArrival.presentation`), and the dock columns that change added. private var arriving: Set = [], newDockColumns: Set = [] /// A zoom or unzoom: the pane that owns the motion, and the panes a zoom leaves fading out under it. private(set) var zoomMotion = PaneZoomMotion() var covered: Set { Set(zoomMotion.leaving.keys) } + /// Split motion moves layers only (DESIGN P4): while a split or a close springs, each pane that + /// changes size keeps one host frame (`SplitMotion.heldFrame`), so its terminal or page lays + /// out once, and is clipped to the frame the spring presents. + private var heldFrames: [PaneID: CGRect] = [:] + /// A divider released with this pointer velocity (pt/s along its axis): the next layout's + /// spring starts from it (`SplitMotion.seed`). + var releasedDivider: (kind: DividerHandleView.Kind, velocity: CGFloat, time: CFTimeInterval)? /// Column scroll rules and state (`ColumnScrollState.reduce`). var scrollState = ColumnScrollState() @@ -126,14 +133,22 @@ final class ScreenContentView: NSView { baseGeometry = ScreenGeometry.compute(shown, viewport: bounds.size, style: context.style, scale: scale) geometry = baseGeometry.shiftingRows(rowOffsets) let animate = animated && !context.reduceMotion && bounds.width > 0 - let arrivals = animate && structural && !unzooming + // `ui.paneAnimations` (default off): a split, a close and a released divider spring only with it on. + let splits = animate && context.animatesPaneLayout + let arrivals = splits && structural && !unzooming ? PaneArrival.split(paneFrames.mapValues(\.targetRect), from: before, to: baseGeometry, offset: scroll.value) : [:] - let departing = animate && structural && arrivals.isEmpty && PaneArrival.departing(paneFrames.keys, to: baseGeometry.panes.keys) + let departing = (zooming ? animate : splits) && structural && arrivals.isEmpty + && PaneArrival.departing(paneFrames.keys, to: baseGeometry.panes.keys) // An unzoom: the other panes come back in place under the zoomed one, which springs back to its slot. let revealing = animate && structural && unzooming // A zoom: the other panes fade out under the zoomed one (`PaneZoomMotion`), rather than leaving a hole. let covering = animate && structural && zooming if structural { zoomMotion.begin(covering ? baseGeometry.panes.keys.first : revealing ? paneFrames.keys.first { baseGeometry.panes[$0] != nil } : nil, zooming: covering, frames: paneFrames, to: baseGeometry) } + // A split still springing when the next structural update lands (the daemon's echo of the + // split moves the tab into the new pane ~75 ms in) keeps springing from where it shows, + // with its velocity, instead of snapping to the end (hq-1d: Cmd-D measured 4 frames). + let inFlight = splits && structural && arrivals.isEmpty && !departing + && paneFrames.values.contains { $0.rect != $0.targetRect } let carried = !arrivals.isEmpty || departing || revealing // Strip springs run in strip coordinates and fixed ones in the view's: a moved strip origin (a dock // that came or went) or a pane changing sides (a zoom shows its pane fixed) keeps it where it showed. @@ -141,10 +156,12 @@ final class ScreenContentView: NSView { func shift(_ geometry: ScreenGeometry, _ pane: PaneID) -> CGFloat { geometry.scrolls(pane: pane) ? geometry.viewShift(offset: offset) : 0 } let drift = carried ? before.viewShift(offset: offset) - baseGeometry.viewShift(offset: offset) : 0 (carriedShown, carriedDividers) = ([:], [:]) - let animateFrames = animate && (!structural || carried) + // A release seeds only the layout it made (`releasedDivider` from this gesture, not a stale one). + if let released = releasedDivider, CACurrentMediaTime() - released.time > 0.5 { releasedDivider = nil } + let animateFrames = animate && (!structural || carried || inFlight) && (releasedDivider == nil || splits) // Under the zoomed pane, which every pane coming back waits beneath. let revealAnchor = revealing ? paneFrames.keys.compactMap { context.hosts[$0] }.first { $0.superview === self } : nil - if structural { (arriving, newDockColumns) = (Set(arrivals.keys), PaneArrival.newDocks(arrivals, from: before, to: baseGeometry)) } + if structural, !inFlight { (arriving, newDockColumns) = (Set(arrivals.keys), PaneArrival.newDocks(arrivals, from: before, to: baseGeometry)) } // Panes. let style = context.style @@ -156,6 +173,7 @@ final class ScreenContentView: NSView { } existing.setTarget(target, alpha: 1) if !animateFrames { existing.snap() } + heldFrames[pane] = animateFrames ? SplitMotion.heldFrame(shown: existing.rect, target: target) : nil paneFrames[pane] = existing } else { let host = context.host(for: pane) @@ -171,6 +189,7 @@ final class ScreenContentView: NSView { } for pane in paneFrames.keys where baseGeometry.panes[pane] == nil { let frame = paneFrames.removeValue(forKey: pane) + heldFrames[pane] = nil if covering, context.hosts[pane]?.superview === self, let frame { zoomMotion.leave(pane, frame) continue @@ -226,6 +245,14 @@ final class ScreenContentView: NSView { dividerFrames[kind] = nil } + if let released = releasedDivider { + releasedDivider = nil + if animateFrames, splits, let divider = dividerFrames[released.kind] { + SplitMotion.seed(velocity: released.velocity, along: dividerViews[released.kind]?.axis ?? .horizontal, divider: divider, + panes: &paneFrames, dividers: ÷rFrames) + } + } + reconcileDock() // The scroll follows in `syncScroll`, which the root calls with the // focus after every update (ColumnScrollState.reduce). @@ -286,6 +313,7 @@ final class ScreenContentView: NSView { if dividerFrames[key]?.advance(dt, parameters: Motion.spring(.move)) == true { moving = true } } if stepRows(dt) { moving = true } + if MotionTrace.isEnabled { MotionTrace.sample("layout.travel", Double(travelLeft)) } if !isUserScrolling { if scroll.advance(dt, parameters: Motion.spring(.scroll), epsilon: 0.25) { moving = true @@ -311,9 +339,18 @@ final class ScreenContentView: NSView { let scrolls = geometry.scrolls(pane: pane) let shift = CGVector(dx: scrolls ? strip : 0, dy: -rowOffset(of: pane)) if arriving.contains(pane), frame.rect == frame.targetRect { arriving.remove(pane) } - let presented = zoomMotion.presentation(of: pane, frame) ?? (arriving.contains(pane) ? PaneArrival.presentation(shown: frame.rect, target: frame.targetRect) : nil) - let revealing = presented.map { _ in frame.rect.offsetBy(dx: shift.dx, dy: shift.dy) } - host.frame = (presented ?? frame.rect).offsetBy(dx: shift.dx, dy: shift.dy) + if heldFrames[pane] != nil, frame.rect == frame.targetRect { heldFrames[pane] = nil } + let held = heldFrames[pane] + // The zooming pane keeps its full size under its spring rect (`PaneZoomMotion`); an arriving + // pane slides in at its final size; a held one keeps its frame. Each shows only the presented + // rect (a split's: the padded content, rounded as at rest). + let zoomed = zoomMotion.presentation(of: pane, frame) + let revealing = zoomed != nil ? frame.rect.offsetBy(dx: shift.dx, dy: shift.dy) + : arriving.contains(pane) || held != nil + ? SplitMotion.visible(frame.rect, padding: host.padding).offsetBy(dx: shift.dx, dy: shift.dy) : nil + let placed = (zoomed ?? held ?? (arriving.contains(pane) ? PaneArrival.presentation(shown: frame.rect, target: frame.targetRect) : frame.rect)) + .offsetBy(dx: shift.dx, dy: shift.dy) + if host.frame != placed { host.frame = placed } (host.alphaValue, host.isDocked, host.revealRect) = (frame.alpha.value, !scrolls, revealing) clips.append((host, scrolls, revealing)) } @@ -349,78 +386,6 @@ final class ScreenContentView: NSView { var dividerMouseAreas: [LayoutMouseArea] { ScreenDividers(screen: self).mouseAreas } var dividerLineRects: [CGRect] { ScreenDividers(screen: self).lineRects } - // MARK: Chrome - - /// Focus ring, inactive dim and attention rings. Overlay-only: no pane - /// frame or inset depends on any of it. - func updateChrome(focused: PaneID?, dimsInactive: Bool, attention: [PaneID: AttentionMark], animated: Bool) { - let multiple = paneFrames.count > 1 - let style = context.style - let ringAllowed = (multiple || style.focusRing.showsForSinglePane) - && style.focusIndicator.marksBorder - // Pointer and scroll focus should not leave a keyboard-only focus ring behind. - && context.model.lastFocusSource != .pointer - && context.model.lastFocusSource != .scroll - let dim = style.inactiveDim(dimsInactive: dimsInactive) - for pane in paneFrames.keys { - guard let host = context.hosts[pane] else { continue } - let isFocused = pane == focused - host.setChrome( - showsRing: ringAllowed && isFocused, - dim: multiple && !isFocused ? dim.amount : 0, dimFill: dim.fill, - focusRing: style.focusRing, - ringAlphaOverride: style.focusRingAlphaOverride, - tabEmphasis: .forPane(isFocused: isFocused, paneCount: paneFrames.count, indicator: style.focusIndicator, - style: style.inactiveTabStyle, strength: style.inactiveTabStrength), - border: PaneOverlayView.Border(shows: style.showsPaneBorder, width: style.paneBorderWidth, color: style.paneBorderColor), - attention: attention[pane], - attentionSettings: style.attention, - animated: animated - ) - } - } - - // MARK: Visibility and hit testing - - /// Panes whose displayed frame intersects the viewport. - func visiblePanes() -> Set { - var result: Set = [] - let uncovered = uncoveredRect - for (pane, frame) in paneFrames { - // A strip pane wholly under a docked column is hidden. - let scrolls = geometry.scrolls(pane: pane) - let displayed = displayedRect(frame.rect, pane: pane) - let overlap = displayed.intersection(scrolls ? uncovered : bounds) - if !overlap.isNull, overlap.width > 0.5, overlap.height > 0.5, frame.alpha.value > 0.01 { - result.insert(pane) - } - } - return result - } - - /// Panes whose displayed frame lies within one viewport width of the - /// viewport on either side (architecture.md 4): visible panes plus the - /// off-screen columns a short scroll brings in. Their content stays - /// alive, paused, so scrolling back shows it at once. - func keepAlivePanes() -> Set { - KeepAliveBand.panes(displayed: paneFrames.reduce(into: [:]) { $0[$1.key] = displayedRect($1.value.rect, pane: $1.key) }, - viewport: bounds) - } - - /// The pane under `localPoint`: a docked column's pane above the strip. - func pane(at localPoint: NSPoint) -> PaneID? { - if geometry.dock.contains(where: { $0.cover.contains(localPoint) }) { - return geometry.panes.first { !geometry.scrolls(pane: $0.key) && $0.value.contains(localPoint) }?.key - } - let content = CGPoint(x: localPoint.x - stripShift, y: localPoint.y) - return geometry.panes.first { geometry.scrolls(pane: $0.key) && $0.value.contains(content) }?.key - } - - /// Displayed frame of `pane` in local coordinates. - func displayedFrame(of pane: PaneID) -> CGRect? { - paneFrames[pane].map { displayedRect($0.rect, pane: pane) } - } - /// Releases every hosted pane that is not live elsewhere (screen removed). func tearDown() { // A drag on a screen that goes away gets no release either. @@ -429,6 +394,7 @@ final class ScreenContentView: NSView { context.release(pane) } paneFrames.removeAll() + heldFrames.removeAll() releaseCovered(all: true) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift index 64f0808144d8..673d5261e2f9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/ScreenDividers.swift @@ -88,6 +88,26 @@ struct DividerDragState { /// (cx-piioc), and the one the divider sits on now. var snaps: [Double] = [] var snapped: Double? + /// The pointer along `axis` (content space) and its velocity in pt/s, + /// lightly smoothed, for the release spring (`SplitMotion.seed`). + var pointer: (position: CGFloat, time: CFTimeInterval)? + var velocity: CGFloat = 0 + + /// Notes the pointer at `point`; a pointer still for 50 ms has no velocity. + mutating func sample(_ point: CGPoint, at time: CFTimeInterval) { + let position = axis == .horizontal ? point.x : point.y + if let last = pointer, time > last.time { + let instant = (position - last.position) / CGFloat(time - last.time) + velocity = time - last.time > 0.05 ? instant : velocity * 0.4 + instant * 0.6 + } + pointer = (position, time) + } + + /// Velocity at release: zero when the pointer stopped before letting go. + func releaseVelocity(at time: CFTimeInterval) -> CGFloat { + guard let pointer, time - pointer.time <= 0.05 else { return 0 } + return velocity + } /// How close, in points, a split divider comes to a snap before it lands there. static let snapDistance: CGFloat = 8 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/SplitMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/SplitMotion.swift new file mode 100644 index 000000000000..4e047aa33665 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextLayout/Views/SplitMotion.swift @@ -0,0 +1,66 @@ +import CmuxNextDesign +import CoreGraphics + +/// The physics of a split (DESIGN P3, P4; plans/cmux-next/motion.md). Cmd-D, +/// Cmd-Shift-D, closing a split and a released divider move every pane edge +/// and divider of the change on the `move` spring, all started together from +/// rest (or from one shared release velocity, `seed`). The spring equation +/// is linear, so each coordinate then covers the same fraction of its travel +/// at every instant: the frames are one spring on the divider fraction, read +/// through each pane's start and end. The new pane grows from the split edge +/// while the other shrinks, and the divider moves once. +/// +/// Content never animates: a pane whose size changes keeps one host frame +/// for the whole motion (`heldFrame`) and is clipped to the presented rect, +/// so its terminal reflows (or its page lays out) once, not per frame. +struct SplitMotion { + /// The host frame a pane keeps while its frame springs from `shown` to + /// `target`: the target when the pane grows (lay out once, now, and be + /// revealed), the frame it shows when it shrinks (keep the last layout, + /// clipped, and lay out once at rest). Nil for a pure move or a resize + /// that neither contains the other: those keep following the spring. + static func heldFrame(shown: CGRect, target: CGRect) -> CGRect? { + guard shown.size != target.size else { return nil } + let slack: CGFloat = 0.5 + if target.insetBy(dx: -slack, dy: -slack).contains(shown) { return target } + if shown.insetBy(dx: -slack, dy: -slack).contains(target) { return shown } + return nil + } + + /// The part of a pane cell the person sees: inside the pane padding, as + /// the content at rest. + static func visible(_ cell: CGRect, padding: CGFloat) -> CGRect { + let inset = min(padding, cell.width / 2, cell.height / 2) + return cell.insetBy(dx: max(0, inset), dy: max(0, inset)) + } + + /// A divider released while moving at `velocity` (pt/s along `axis`): + /// the spring toward the snapped layout starts with that velocity. In + /// the spring's own unit (the fraction of the travel left) the divider's + /// velocity is `velocity / travel`; every moving pane and divider gets + /// the same fraction, so the whole change stays one spring. A release + /// moving away from the target, or with nothing left to travel, starts + /// from rest. + static func seed(velocity: CGFloat, along axis: SplitAxis, divider: AnimatedFrame, + panes: inout [PaneID: AnimatedFrame], dividers: inout [DividerHandleView.Kind: AnimatedFrame]) { + let travel = axis == .horizontal ? divider.x.target - divider.x.value : divider.y.target - divider.y.value + guard abs(travel) > 0.5 else { return } + let fraction = velocity / travel + guard fraction > 0 else { return } + // A fling faster than the spring could start (10 travels a second) is capped there. + let rate = min(fraction, 10) + for key in Array(panes.keys) { panes[key]?.seed(fractionVelocity: rate) } + for key in Array(dividers.keys) { dividers[key]?.seed(fractionVelocity: rate) } + } +} + +extension AnimatedFrame { + /// Gives every component still short of its target the velocity of + /// `rate` (fractions of its remaining travel per second). + mutating func seed(fractionVelocity rate: CGFloat) { + x.velocity = (x.target - x.value) * rate + y.velocity = (y.target - y.value) * rate + width.velocity = (width.target - width.value) * rate + height.velocity = (height.target - height.value) * rate + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift index f4220622785f..0e3bb242eb4d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageRouter.swift @@ -75,7 +75,7 @@ public final class PageRouter { opid = text } let params = message["params"] ?? .object([:]) - let value = try await cancellable(id: id) { [self] in try await call(op, params: params, opid: opid) } + let value = try await cancellable(id: id, op: op) { [self] in try await call(op, params: params, opid: opid) } return ["t": "ok", "id": .number(Double(id)), "value": value] } catch let error as PageError { return Self.error(id: id, error) @@ -138,8 +138,8 @@ public final class PageRouter { /// Runs `work` as page call `id`, answering `cmux.op.cancelled` at once when the call is /// cancelled; the work's task is cancelled too, so the owner stops (a relay cancels its op). - private func cancellable(id: UInt64, _ work: @escaping @MainActor () async throws -> JSONValue) async throws -> JSONValue { - let call = PageCallInFlight() + private func cancellable(id: UInt64, op: String, _ work: @escaping @MainActor () async throws -> JSONValue) async throws -> JSONValue { + let call = PageCallInFlight(op: op) inFlight[id] = call defer { if inFlight[id] === call { inFlight[id] = nil } } return try await withCheckedThrowingContinuation { continuation in @@ -289,6 +289,13 @@ public final class PageRouter { public var subscriptionCount: Int { subscriptions.count } + /// The page calls still running (`debug.page` state): page call id, op and age in ms, oldest first. + public func inFlightCalls(now: TimeInterval = ProcessInfo.processInfo.systemUptime) -> [JSONValue] { + inFlight.sorted { $0.key < $1.key }.map { id, call in + ["id": .number(Double(id)), "op": .string(call.op), "age_ms": .number(((now - call.started) * 1000).rounded())] + } + } + static func error(id: UInt64, _ error: PageError) -> JSONValue { var envelope: [String: JSONValue] = [ "t": "err", "id": .number(Double(id)), "code": .string(error.code), "message": .string(error.message), @@ -303,10 +310,17 @@ public final class PageRouter { /// the work's end and a cancel answers it (once); a cancel also cancels the task. @MainActor final class PageCallInFlight { + let op: String + /// When the call arrived (`systemUptime`). + let started = ProcessInfo.processInfo.systemUptime private var continuation: CheckedContinuation? private var task: Task? private var cancelled = false + init(op: String) { + self.op = op + } + func start(_ continuation: CheckedContinuation, _ work: @escaping @MainActor () async throws -> JSONValue) { self.continuation = continuation guard !cancelled else { return finish(.failure(PageError.opCancelled)) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift index ff0cfd94ed7d..1c7266f374fe 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextPages/PageWebView.swift @@ -346,7 +346,10 @@ public final class PageWebView: NSView, PageSurface, WKNavigationDelegate { // The message conversion stays here: `handle` must start in the order the page posted, and // `message.body` is a non-Sendable WebKit object graph (page messages are small calls). guard let body = JSONValue(foundation: message.body) else { return nil } + let started = ProcessInfo.processInfo.systemUptime let reply = await router.handle(body) + let handled = ProcessInfo.processInfo.systemUptime + defer { Self.noteSlowReply(body, page: descriptor.id, started: started, handled: handled, logger: logger) } // The reply reaches the page after the events sent before it and before the events sent // after it (a subscribe's reply names the sub its first events use). A gate holds the // script FIFO here while those earlier events drain and a large reply converts off main. @@ -359,6 +362,16 @@ public final class PageWebView: NSView, PageSurface, WKNavigationDelegate { return await PageReplyObject.convert(reply).object } + /// A page message whose reply took over a second: its op, the router's time and the time the + /// reply then waited for the page's script FIFO (a reply the page never gets looks the same + /// from the page as a call the host never answered). + private static func noteSlowReply(_ body: JSONValue, page: String, started: TimeInterval, handled: TimeInterval, logger: Logger) { + let done = ProcessInfo.processInfo.systemUptime + guard done - started > 1 else { return } + let op = body["op"]?.stringValue ?? body["t"]?.stringValue ?? "?" + logger.info("page \(page, privacy: .public) \(op, privacy: .public): handled in \(Int((handled - started) * 1000)) ms, replied after \(Int((done - started) * 1000)) ms") + } + // MARK: Theme // The page's colors follow this view's theme scope (room, workspace), resolved in the hooks diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift index b4f0f91e200e..b41d137165ee 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextPalette/PaletteController+Pages.swift @@ -35,13 +35,26 @@ extension PaletteController { scope: .root ) page.mergesSectionsWhenTyping = true + return previewingActions(page) + } + + /// The highlighted row previews its action live when the action has a + /// preview (`ActionRegistry.bindPreview`: a "Window Style: Card" row + /// shows that style); another row, leaving the page or closing the + /// palette puts the committed look back, and running the row keeps it. + func previewingActions(_ page: PalettePageSpec) -> PalettePageSpec { + var page = page + let registry = registry + page.onHighlight = { [weak registry] item in registry?.showPreview(item?.actionRefs.first?.action) } + page.onLeave = { [weak registry] in registry?.endPreview() } return page } /// Scope `commands` (`>`): catalog actions only. func commandsOnlyPage() -> PalettePageSpec { - PalettePageSpec(id: "commandsOnly", title: PaletteStrings.commandsTitle, placeholder: PaletteStrings.searchPlaceholder, - symbol: "command", providers: [makeRegistryProvider()], showsRecent: true, scope: PaletteScopeID.commands) + previewingActions(PalettePageSpec(id: "commandsOnly", title: PaletteStrings.commandsTitle, placeholder: PaletteStrings.searchPlaceholder, + symbol: "command", providers: [makeRegistryProvider()], showsRecent: true, + scope: PaletteScopeID.commands)) } /// Scope `scopes` (`?`): every scope, with its prefix and keyword. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting+Actions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting+Actions.swift new file mode 100644 index 000000000000..8a7eb6072e1f --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting+Actions.swift @@ -0,0 +1,22 @@ +public import CmuxNextActions +public import CmuxNextDesign + +extension AnimationKindsSetting { + /// The palette actions of `ui.animations.*`: `appearance.animations..toggle` flips a + /// kind (its default removes the key) and `appearance.animations.bounce.` sets the + /// bounce (none, the default, removes the key). A managed key refuses and changes nothing. + @MainActor public static func bindActions(into registry: ActionRegistry, design: DesignSettings, save: @escaping SettingChoiceActions.Save) { + for kind in MotionKind.allCases { + registry.bind(ActionID(rawValue: "appearance.animations.\(kind.rawValue).toggle"), invoke: { [weak registry] _ in + let on = !design.enabledAnimations.contains(kind) + if let refusal = save(path(kind), on == defaultEnabled.contains(kind) ? nil : .bool(on)) { + registry?.refuse(refusal) + return + } + if on { design.enabledAnimations.insert(kind) } else { design.enabledAnimations.remove(kind) } + }) + } + SettingChoiceActions.bind(MotionBounce.allCases, into: registry, id: { "appearance.animations.bounce.\($0.rawValue)" }, path: bouncePath, + fileValue: { $0 == bounceFallback ? nil : .string($0.rawValue) }, save: save) { design.animationBounce = $0 } + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift new file mode 100644 index 000000000000..c32a08fa43bf --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/AnimationKindsSetting.swift @@ -0,0 +1,54 @@ +public import CmuxNextDesign + +/// `ui.animations.newTab` (bool, default true: a new tab grows in at its slot; +/// Lawrence 2026-10-14) and `ui.animations.newWorkspace` (bool, default false: the change +/// applies in one frame; true opts into its spring) and +/// `ui.animations.bounce` ("none" or "subtle") in cmux.json +/// (plans/cmux-next/motion.md). They apply under `ui.animationSpeed`: +/// "off" or Reduce Motion stops every kind whatever these say. Pane layout +/// is `ui.paneAnimations` (`PaneAnimationsSetting`). +public nonisolated struct AnimationKindsSetting: Sendable, Equatable { + /// Kinds whose `ui.animations.` is true. + public var enabled: Set = AnimationKindsSetting.defaultEnabled + /// `ui.animations.bounce`. + public var bounce: MotionBounce = AnimationKindsSetting.bounceFallback + + /// The kinds on when cmux.json does not name them: new tabs. + public static let defaultEnabled: Set = [.newTab] + + public init(enabled: Set = AnimationKindsSetting.defaultEnabled, bounce: MotionBounce = AnimationKindsSetting.bounceFallback) { + self.enabled = enabled + self.bounce = bounce + } + + public static let bouncePath = ["ui", "animations", "bounce"] + public static let bounceFallback: MotionBounce = .none + + /// `ui.animations.`. + public static func path(_ kind: MotionKind) -> [String] { ["ui", "animations", kind.rawValue] } + + /// The kinds turned on and the bounce. A missing key is its default + /// with no diagnostic; a bad value is the default plus a diagnostic. + static func parse(_ root: JSONValue) -> (AnimationKindsSetting, [SettingsDiagnostic]) { + var enabled = defaultEnabled + var diagnostics: [SettingsDiagnostic] = [] + for kind in MotionKind.allCases { + guard let value = root.value(at: path(kind)) else { continue } + guard let on = value.boolValue else { + diagnostics.append(SettingsDiagnostic(kind: .invalidValue, path: path(kind).joined(separator: "."), message: "expected true or false")) + continue + } + if on { enabled.insert(kind) } else { enabled.remove(kind) } + } + var bounce = bounceFallback + if let value = root.value(at: bouncePath) { + if let text = value.stringValue, let parsed = MotionBounce(rawValue: text) { + bounce = parsed + } else { + let choices = MotionBounce.allCases.map { "\"\($0.rawValue)\"" }.joined(separator: ", ") + diagnostics.append(SettingsDiagnostic(kind: .invalidValue, path: "ui.animations.bounce", message: "expected one of \(choices)")) + } + } + return (AnimationKindsSetting(enabled: enabled, bounce: bounce), diagnostics) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift index 2c87abd043a9..2010f5955907 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/ChromeStyleSetting.swift @@ -55,9 +55,11 @@ extension SettingsApplier { } extension ChromeStyleSetting { - /// Binds every `appearance.chromeStyle.` palette action. + /// Binds every `appearance.chromeStyle.` palette action, each + /// previewing its style while its palette row is highlighted. @MainActor public static func bindActions(into registry: ActionRegistry, design: DesignSettings, save: @escaping SettingChoiceActions.Save) { - SettingChoiceActions.bind(ChromeStyle.allCases, into: registry, id: actionID, path: configPath, fileValue: fileValue, save: save) { + SettingChoiceActions.bind(ChromeStyle.allCases, into: registry, id: actionID, path: configPath, fileValue: fileValue, save: save, + current: { design.chromeStyle }) { design.chromeStyle = $0 } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift index 82624e5ecb6a..35e5e8ed23ff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot+PreviewFeatures.swift @@ -12,6 +12,16 @@ extension CmuxConfigSnapshot { } return (on, nil) } + + /// Another Labs toggle (`labs.classicHome`): absent is `fallback`; a value that is not a bool + /// is `fallback` plus a diagnostic. + static func parseLabsToggle(_ root: JSONValue, path: [String], fallback: Bool) -> (Bool, SettingsDiagnostic?) { + guard let value = root.value(at: path) else { return (fallback, nil) } + guard let on = value.boolValue else { + return (fallback, SettingsDiagnostic(kind: .invalidValue, path: path.joined(separator: "."), message: "expected true or false")) + } + return (on, nil) + } } extension CmuxConfigSnapshot { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift index d406c8b1e7c5..5fcf5bd1acf1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/CmuxConfigSnapshot.swift @@ -36,8 +36,8 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { public var browserShowBookmarksBar = false /// `home.attachments.keepLocation`; off (strip location) when unset. public var homeKeepLocation = false - /// `labs.previewFeatures`; off when unset. - public var previewFeatures = CmuxConfigSnapshot.previewFeaturesDefault + /// `labs.previewFeatures` and `labs.classicHome`; off when unset. + public var previewFeatures = CmuxConfigSnapshot.previewFeaturesDefault, classicHome = CmuxConfigSnapshot.classicHomeDefault /// `browser.hibernation`, `browser.hibernationExclusions`, `browser.hibernatePinnedTabs`. public var browserHibernation: BrowserHibernationSetting = .fallback /// `browser.links.*`: what modified link clicks do; Chrome's when unset. @@ -54,6 +54,7 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { public var remoteLocalhost: RemoteLocalhostSetting = .fallback /// `ui.animationSpeed`; "fast" when unset or invalid. public var animationSpeed: MotionSpeed = AnimationSpeedSetting.fallback + public var animationKinds = AnimationKindsSetting() /// `ui.paneAnimations`; off when unset or invalid. public var paneAnimations: Bool = PaneAnimationsSetting.fallback /// `layout.centerFocusedColumn`; "never" when unset or invalid. @@ -219,6 +220,7 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { snapshot.take(BookmarksBarSetting.parse(root), \.browserShowBookmarksBar) snapshot.take(HomeKeepLocationSetting.parse(root), \.homeKeepLocation) snapshot.take(Self.parsePreviewFeatures(root), \.previewFeatures) + snapshot.take(Self.parseLabsToggle(root, path: classicHomePath, fallback: classicHomeDefault), \.classicHome) snapshot.take(BrowserHibernationSetting.parse(root), \.browserHibernation) snapshot.take(BrowserLinkClickSetting.parse(root), \.browserLinkClicks) snapshot.take(TerminalLinkRouting.parse(root), \.terminalLinkRouting) @@ -229,6 +231,7 @@ public struct CmuxConfigSnapshot: Sendable, Equatable { let (speed, speedDiagnostic) = AnimationSpeedSetting.parse(root) snapshot.animationSpeed = speed if let speedDiagnostic { snapshot.diagnostics.append(speedDiagnostic) } + snapshot.take(AnimationKindsSetting.parse(root), \.animationKinds) snapshot.take(PaneAnimationsSetting.parse(root), \.paneAnimations) let (centering, centeringDiagnostic) = CenterFocusedColumnSetting.parse(root) snapshot.centerFocusedColumn = centering diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings index 904587ec975c..ed5d30b3c8ef 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/Localizable.xcstrings @@ -91429,6 +91429,792 @@ } } }, + "settings.ui.animations.newTab": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Animate New Tabs" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいタブをアニメーション" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تحريك علامات التبويب الجديدة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Animiraj nove kartice" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye faner" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Neue Tabs animieren" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Animar pestañas nuevas" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Animer les nouveaux onglets" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Anima le nuove schede" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ធ្វើចលនាផ្ទាំងថ្មី" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 탭 애니메이션" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye faner" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Animuj nowe karty" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Animar novas abas" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Анимировать новые вкладки" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เคลื่อนไหวแท็บใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni sekmeleri canlandır" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Анімувати нові вкладки" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Tạo hiệu ứng tab mới" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "动画显示新标签页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "動畫顯示新分頁" + } + } + } + }, + "settings.ui.animations.newTab.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A new tab grows into its place in the tab bar. Off shows it there at once." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいタブはタブバーの位置で広がります。オフにするとすぐに表示されます。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تنمو علامة التبويب الجديدة في مكانها في شريط علامات التبويب. عند الإيقاف تظهر فورًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Nova kartica raste na svom mjestu u traci kartica. Isključeno je odmah prikazuje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "En ny fane vokser frem på sin plads i fanelinjen. Fra viser den der med det samme." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Ein neuer Tab wächst an seinem Platz in der Tableiste. Aus zeigt ihn dort sofort." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Una pestaña nueva crece en su lugar de la barra de pestañas. Desactivado la muestra al instante." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Un nouvel onglet s’ouvre à sa place dans la barre d’onglets. Désactivé l’affiche aussitôt." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Una nuova scheda cresce al suo posto nella barra delle schede. Disattivato la mostra subito." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ផ្ទាំងថ្មីលូតលាស់នៅកន្លែងរបស់វាក្នុងរបារផ្ទាំង។ បិទ បង្ហាញវាភ្លាមៗ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 탭은 탭 막대의 자기 자리에서 자라납니다. 끄면 즉시 표시됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "En ny fane vokser inn på plassen sin i fanelinjen. Av viser den der med en gang." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nowa karta wyrasta na swoim miejscu na pasku kart. Wyłączone pokazuje ją od razu." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Uma nova aba cresce no seu lugar na barra de abas. Desativado a mostra na hora." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Новая вкладка вырастает на своём месте в панели вкладок. Выключено показывает её сразу." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "แท็บใหม่จะขยายในตำแหน่งของมันบนแถบแท็บ ปิดจะแสดงทันที" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni sekme sekme çubuğundaki yerinde büyür. Kapalı olduğunda hemen gösterilir." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Нова вкладка виростає на своєму місці на панелі вкладок. Вимкнено показує її одразу." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Tab mới mở ra tại vị trí của nó trên thanh tab. Tắt sẽ hiện ngay." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "新标签页在标签栏中的位置展开。关闭后立即显示。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "新分頁在分頁列中的位置展開。關閉後立即顯示。" + } + } + } + }, + "settings.ui.animations.newWorkspace": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Animate New Workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいワークスペースをアニメーション" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "تحريك مساحات العمل الجديدة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Animiraj nove radne prostore" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye arbejdsområder" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Neue Arbeitsbereiche animieren" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Animar espacios de trabajo nuevos" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Animer les nouveaux espaces de travail" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Anima i nuovi spazi di lavoro" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ធ្វើចលនាកន្លែងធ្វើការថ្មី" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 작업 공간 애니메이션" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Animer nye arbeidsområder" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Animuj nowe obszary robocze" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Animar novos espaços de trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Анимировать новые рабочие пространства" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "เคลื่อนไหวพื้นที่ทำงานใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni çalışma alanlarını canlandır" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Анімувати нові робочі простори" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Tạo hiệu ứng không gian làm việc mới" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "动画显示新工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "動畫顯示新工作區" + } + } + } + }, + "settings.ui.animations.newWorkspace.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A new workspace row opens in the sidebar. Off shows it there at once." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "新しいワークスペースの行がサイドバーで開きます。オフにするとすぐに表示されます。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "ينفتح صف مساحة العمل الجديدة في الشريط الجانبي. عند الإيقاف يظهر فورًا." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Novi red radnog prostora otvara se u bočnoj traci. Isključeno ga odmah prikazuje." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "En ny række for arbejdsområdet åbner i sidepanelet. Fra viser den der med det samme." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Eine neue Arbeitsbereichszeile öffnet sich in der Seitenleiste. Aus zeigt sie dort sofort." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Una fila de espacio de trabajo nueva se abre en la barra lateral. Desactivado la muestra al instante." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Une nouvelle ligne d’espace de travail s’ouvre dans la barre latérale. Désactivé l’affiche aussitôt." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Una nuova riga dello spazio di lavoro si apre nella barra laterale. Disattivato la mostra subito." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ជួរកន្លែងធ្វើការថ្មីបើកនៅក្នុងរបារចំហៀង។ បិទ បង្ហាញវាភ្លាមៗ។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "새 작업 공간 행이 사이드바에서 열립니다. 끄면 즉시 표시됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "En ny rad for arbeidsområdet åpner seg i sidepanelet. Av viser den der med en gang." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nowy wiersz obszaru roboczego otwiera się na pasku bocznym. Wyłączone pokazuje go od razu." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Uma nova linha de espaço de trabalho se abre na barra lateral. Desativado a mostra na hora." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Новая строка рабочего пространства раскрывается на боковой панели. Выключено показывает её сразу." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "แถวพื้นที่ทำงานใหม่จะเปิดในแถบด้านข้าง ปิดจะแสดงทันที" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yeni çalışma alanı satırı kenar çubuğunda açılır. Kapalı olduğunda hemen gösterilir." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Новий рядок робочого простору розкривається на бічній панелі. Вимкнено показує його одразу." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Hàng không gian làm việc mới mở ra trong thanh bên. Tắt sẽ hiện ngay." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "新工作区行在侧边栏中展开。关闭后立即显示。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "新工作區列在側邊欄中展開。關閉後立即顯示。" + } + } + } + }, + "settings.ui.animations.bounce": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Animation Bounce" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "アニメーションの弾み" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "ارتداد الحركة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Odskok animacije" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Animationsfjedring" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Animationsfederung" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Rebote de animación" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Rebond des animations" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Rimbalzo delle animazioni" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ការលោតនៃចលនា" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "애니메이션 바운스" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Animasjonsfjæring" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Odbicie animacji" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Quique das animações" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Пружинистость анимации" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "การเด้งของภาพเคลื่อนไหว" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Animasyon sekmesi" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Пружність анімації" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Độ nảy của hiệu ứng" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "动画弹跳" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "動畫彈跳" + } + } + } + }, + "settings.ui.animations.bounce.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce." + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "なしではタブ、行、ペインがその位置でぴたりと止まります。控えめでは小さく弾んで落ち着きます。" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "لا شيء يوقف علامات التبويب والصفوف والأجزاء في مكانها تمامًا. خفيف يتركها تستقر بارتداد صغير." + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Ništa zaustavlja kartice, redove i okna tačno na mjestu. Suptilno ih pušta da se smire uz mali odskok." + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Ingen stopper faner, rækker og ruder præcis på deres plads. Diskret lader dem falde på plads med et lille fjed." + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Keine hält Tabs, Zeilen und Bereiche genau an ihrem Platz an. Dezent lässt sie mit einem kleinen Federn einrasten." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ninguno detiene pestañas, filas y paneles justo en su sitio. Sutil los deja asentarse con un pequeño rebote." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Aucun arrête onglets, lignes et sous-fenêtres exactement à leur place. Discret les laisse se poser avec un petit rebond." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nessuno ferma schede, righe e riquadri esattamente al loro posto. Leggero li fa assestare con un piccolo rimbalzo." + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "គ្មាន បញ្ឈប់ផ្ទាំង ជួរ និងផ្ទាំងបន្ទះនៅកន្លែងរបស់វាត្រឹមត្រូវ។ ស្រាល ឱ្យវាស្ងប់ដោយលោតបន្តិច។" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "없음은 탭, 행, 창을 정확히 제자리에 멈춥니다. 은은하게는 작게 튕기며 자리 잡습니다." + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Ingen stopper faner, rader og ruter nøyaktig på plassen sin. Diskret lar dem falle på plass med en liten fjæring." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Brak zatrzymuje karty, wiersze i panele dokładnie na miejscu. Subtelne pozwala im osiąść z lekkim odbiciem." + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Nenhum para abas, linhas e painéis exatamente no lugar. Sutil os deixa assentar com um pequeno quique." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Нет останавливает вкладки, строки и панели точно на месте. Слегка даёт им встать на место с небольшим отскоком." + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ไม่มี จะหยุดแท็บ แถว และบานหน้าต่างตรงตำแหน่งพอดี เล็กน้อย จะให้เด้งเล็กน้อยก่อนเข้าที่" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Yok sekmeleri, satırları ve bölmeleri tam yerinde durdurur. Hafif, küçük bir sekmeyle yerleşmelerini sağlar." + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Немає зупиняє вкладки, рядки й панелі точно на місці. Легко дає їм стати на місце з невеликим відскоком." + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Không dừng tab, hàng và khung đúng vị trí. Nhẹ cho chúng nảy nhẹ trước khi dừng." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "无:标签页、行和窗格精确停在原位。轻微:以轻微弹跳落定。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "無:分頁、列與窗格精確停在原位。輕微:以輕微彈跳落定。" + } + } + } + }, "settings.choice.chromeStyle.floating": { "extractionState": "manual", "localizations": { @@ -91559,6 +92345,268 @@ } } } + }, + "settings.labs.classicHome": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Classic Home" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الصفحة الرئيسية الكلاسيكية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Klasična početna" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Klassisk Hjem" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Klassisches Home" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicio clásico" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Accueil classique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Home classica" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "クラシックホーム" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំព័រដើមបុរាណ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "클래식 홈" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Klassisk Hjem" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Klasyczny Start" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Início clássico" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Классическая главная" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าหลักแบบคลาสสิก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasik Ana Sayfa" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Класична головна" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Trang chủ cổ điển" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "经典主页" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "經典主頁" + } + } + } + }, + "settings.labs.classicHome.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض الصفحة الرئيسية كصفحة كاملة بدلًا من مساحة عمل يكون فيها Chief مثبتًا على اليسار. يسري التغيير بعد إعادة تشغيل cmux." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikaži početnu kao cijelu stranicu umjesto radnog prostora s Chiefom usidrenim lijevo. Primjenjuje se nakon ponovnog pokretanja cmuxa." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vis Hjem som en hel side i stedet for et arbejdsområde med Chief fastgjort til venstre. Træder i kraft, når cmux genstartes." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Home als ganze Seite statt als Arbeitsbereich mit links angedocktem Chief anzeigen. Wirkt nach dem Neustart von cmux." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Muestra Inicio como una página completa en lugar de un espacio de trabajo con Chief acoplado a la izquierda. Se aplica después de reiniciar cmux." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher l'Accueil en pleine page plutôt que comme un espace de travail avec Chief ancré à gauche. Prend effet après le redémarrage de cmux." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mostra la Home come pagina intera invece che come spazio di lavoro con Chief agganciato a sinistra. Ha effetto dopo il riavvio di cmux." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ホームを、Chief を左にドッキングしたワークスペースではなく、全画面のページとして表示します。cmux の再起動後に反映されます。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បង្ហាញទំព័រដើមជាទំព័រពេញ ជំនួសឱ្យកន្លែងធ្វើការដែលមាន Chief ភ្ជាប់នៅខាងឆ្វេង។ មានប្រសិទ្ធភាពបន្ទាប់ពីចាប់ផ្ដើម cmux ឡើងវិញ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chief가 왼쪽에 고정된 작업 공간 대신 홈을 전체 페이지로 표시합니다. cmux를 다시 시작한 후 적용됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Vis Hjem som en hel side i stedet for et arbeidsområde med Chief festet til venstre. Trer i kraft når cmux startes på nytt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pokazuj Start jako pełną stronę zamiast obszaru roboczego z Chiefem zadokowanym po lewej. Zmiana działa po ponownym uruchomieniu cmux." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mostra o Início como uma página inteira em vez de um espaço de trabalho com o Chief encaixado à esquerda. Vale depois que o cmux for reiniciado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показывать главную как целую страницу, а не рабочее пространство с Chief, закреплённым слева. Применяется после перезапуска cmux." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แสดงหน้าหลักเป็นหน้าเต็มแทนพื้นที่ทำงานที่มี Chief ปักหมุดอยู่ทางซ้าย มีผลหลังจากเริ่ม cmux ใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ana Sayfa'yı, Chief'in solda sabitlendiği bir çalışma alanı yerine tam sayfa olarak göster. cmux yeniden başlatıldıktan sonra geçerli olur." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Показувати головну як цілу сторінку, а не робочий простір із Chief, закріпленим ліворуч. Застосовується після перезапуску cmux." + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Hiển thị Trang chủ dạng trang đầy đủ thay vì không gian làm việc có Chief neo bên trái. Có hiệu lực sau khi khởi động lại cmux." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将主页显示为完整页面,而不是左侧停靠 Chief 的工作区。重新启动 cmux 后生效。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將主頁顯示為完整頁面,而非左側停駐 Chief 的工作區。重新啟動 cmux 後生效。" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MotionParityCorpus.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MotionParityCorpus.swift new file mode 100644 index 000000000000..6dcbfc6acedc --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/MotionParityCorpus.swift @@ -0,0 +1,54 @@ +public import CmuxNextDesign +import CoreGraphics + +/// The motion parity corpus of this port (`debug.motion {action: +/// "trajectory"}`): every chrome spring token resolved under bounce none +/// and subtle at speed fast, and the frame path of a 200 pt move (and a +/// retarget to 50 pt after 5 frames) at 60 and 120 Hz until the spring +/// rests. cmux-motion's `motion_trajectories` example prints the same +/// document; scripts/cmux-next/motion-parity.py compares them. +/// `debug.motion` (CmuxNextApp) serves it; it lives here, beside the +/// motion settings, to keep the App target small. +public struct MotionParityCorpus { + private let tokens = MotionSpring.allCases + + public init() {} + + public var document: JSONValue { + var tokenRows: [JSONValue] = [] + var paths: [JSONValue] = [] + for bounce in [MotionBounce.none, .subtle] { + let policy = MotionPolicy(speed: .fast, reduceMotion: false, bounce: bounce) + for token in tokens { + let spring = policy.spring(token) + tokenRows.append([ + "token": .string(token.rawValue), "bounce": .string(bounce.rawValue), + "response": .number(spring.response), "damping_fraction": .number(spring.dampingFraction), + "visible_end": .number(spring.perceivedDuration), + ]) + for hz in [60.0, 120.0] { + for (name, retarget) in [("move", nil), ("retarget", (5, CGFloat(50)))] as [(String, (Int, CGFloat)?)] { + paths.append([ + "token": .string(token.rawValue), "bounce": .string(bounce.rawValue), "hz": .number(hz), + "case": .string(name), "values": .array(path(spring, hz: hz, retarget: retarget).map { .number(Double($0)) }), + ]) + } + } + } + } + return ["port": .string("swift"), "tokens": .array(tokenRows), "paths": .array(paths)] + } + + private func path(_ spring: SpringParameters, hz: Double, retarget: (Int, CGFloat)?) -> [CGFloat] { + var value = SpringValue(0) + value.target = 200 + var out: [CGFloat] = [] + for frame in 0..( _ values: [Value], into registry: ActionRegistry, id: (Value) -> String, path: [String], - fileValue: @escaping (Value) -> JSONValue?, save: @escaping Save, apply: @escaping @MainActor (Value) -> Void + fileValue: @escaping (Value) -> JSONValue?, save: @escaping Save, + current: (@MainActor () -> Value)? = nil, apply: @escaping @MainActor (Value) -> Void ) { for value in values { - registry.bind(ActionID(rawValue: id(value)), invoke: { [weak registry] _ in + let actionID = ActionID(rawValue: id(value)) + registry.bind(actionID, invoke: { [weak registry] _ in if let refusal = save(path, fileValue(value)) { + registry?.endPreview() registry?.refuse(refusal) return } apply(value) }) + guard let current else { continue } + registry.bindPreview(actionID, ActionPreview(group: path.joined(separator: ".")) { + let before = current() + apply(value) + return { apply(before) } + }) } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift index 46529ff09c4c..bafcc05de3e0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSettings/SettingsApplier.swift @@ -42,7 +42,10 @@ public final class SettingsApplier { let agentPaneZoom = CGFloat(min(max(snapshot.agentPaneZoom, AgentPaneZoomSetting.range.lowerBound), AgentPaneZoomSetting.range.upperBound)) if design.agentPaneZoom != agentPaneZoom { design.agentPaneZoom = agentPaneZoom } if design.animationSpeed != snapshot.animationSpeed { design.animationSpeed = snapshot.animationSpeed } + if design.enabledAnimations != snapshot.animationKinds.enabled { design.enabledAnimations = snapshot.animationKinds.enabled } + if design.animationBounce != snapshot.animationKinds.bounce { design.animationBounce = snapshot.animationKinds.bounce } if design.paneAnimations != snapshot.paneAnimations { design.paneAnimations = snapshot.paneAnimations } + if design.classicHome != snapshot.classicHome { design.classicHome = snapshot.classicHome } if design.centerFocusedColumn != snapshot.centerFocusedColumn { design.centerFocusedColumn = snapshot.centerFocusedColumn } if design.stripScrollbar != snapshot.stripScrollbar { design.stripScrollbar = snapshot.stripScrollbar } if design.sidebarSections != snapshot.sidebarSections { design.sidebarSections = snapshot.sidebarSections } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift index d3cd28405906..e6e920de6723 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView+HoverCard.swift @@ -23,7 +23,10 @@ extension SidebarListView { /// have a card now: none during a drag or rename, or off the visible rows. func hoverCardWorkspace(at point: CGPoint) -> WorkspaceID? { guard drag == nil, !inlineRename.isActive, visibleRect.contains(point), !isHiddenOrHasHiddenAncestor, - case .workspace(let id)? = displayed.row(at: point.y)?.key, workspaces[id] != nil, !model.isPlaceholder(id) else { return nil } + // The list's own rows (a dictionary): `model.isPlaceholder` walks every section, and this + // runs on each scroll frame (hover geometry), O(workspaces) per frame at 1,000 rows. + case .workspace(let id)? = displayed.row(at: point.y)?.key, let row = workspaces[id], + row.rowState != .placeholder else { return nil } return id } /// The row's frame on screen, nil when it is not laid out. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift index 3910e2f4d72f..37bf84f2d19e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarListView.swift @@ -204,14 +204,16 @@ final class SidebarListView: NSView { var targets: [(SidebarRowView, NSRect)] = [] var appearing: [(SidebarRowView, NSRect)] = [] var keep = Set() - let animate = animated && !old.rows.isEmpty && selectedRowKey.map { old.row(for: $0) != nil } != false // a row shown as it is added (Cmd-N) appears in place, with the rest (cx-8g3sn) + // A new workspace row (Cmd-N, a script, another client) opens only with ui.animations.newWorkspace on (hq-1d). + let insertsWorkspace = layout.rows.contains { if case .workspace = $0.key { old.row(for: $0.key) == nil } else { false } } + let animate = animated && !old.rows.isEmpty && (!insertsWorkspace || Motion.animates(.newWorkspace)) // New rows open from their run's top, so only the rows below move; a row still opening // that the next update moves (appended, then placed below) opens again there, in a new view: // the old one's retargeted spring would carry its stale open on through the rows (cx-ai79). let now = CACurrentMediaTime() openingRows = openingRows.filter { now - $0.value < Motion.spring(.move).settlingTime() } let reopened = animate ? Set(layout.rows.filter { openingRows[$0.key] != nil && old.row(for: $0.key)?.y != $0.y }.map(\.key)) : [] - for row in layout.rows where !old.rows.isEmpty && (old.row(for: row.key) == nil || reopened.contains(row.key)) { openingRows[row.key] = now } + for row in layout.rows where animate && (old.row(for: row.key) == nil || reopened.contains(row.key)) { openingRows[row.key] = now } // only animated applies open rows (hq-1d) let insertRuns = SidebarRowTransition.runTops(of: layout, missingFrom: old, reopened: reopened) for row in layout.rows { let target = frame(for: row) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift index 65b0bd233b25..1f765d0012fb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarRowMotion.swift @@ -30,10 +30,8 @@ struct RowMotion { return } guard Motion.animatesMovement else { - // Reduce Motion: rows take their places at once and the change crossfades (motion.md - // rule 7). Inside the movement spring, which Reduce Motion skips, the fades never ran: - // a closed row vanished and a new one popped in. - if !appearing.isEmpty || !leaving.isEmpty { Motion.crossfadeInPlace(list.layer) } + // Reduce Motion: rows take their places in one step, with no crossfade standing in + // for the motion (DESIGN P6): no movement, and nothing a person must wait out. Motion.withoutAnimation { moves() lines.changes() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift index 80d49a2e9d4d..a74ea9c9f4de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView.swift @@ -338,38 +338,38 @@ public final class SidebarView: NSView { var fontSize: CGFloat var titlebarHeight: CGFloat var cards: SidebarBottomCards + init(_ model: SidebarModel, _ design: DesignSettings) { + sectionsRevision = model.observedSectionsRevision + selection = model.selection + selected = model.selectedItem + profiles = model.profiles + activeProfile = model.activeProfileID + filter = model.filterText + layout = model.layout + itemInfo = model.resolvedItemInfo + transientTopItems = model.transientTopItems + collapsedSections = model.collapsedLayoutSections + look = SidebarSectionTunables.currentLook + drawsLines = Borders.drawsLines + preferences = design.sidebarSections + suppressedApps = model.suppressedApps + metrics = .standard + fontSize = Typography.body.pointSize + titlebarHeight = Metrics.titlebarHeight + cards = SidebarBottomCards(update: model.updateCard, nowPlaying: model.nowPlaying, updated: model.updatedCard, notice: model.noticeCard) + } } private func observe() { let model = model, design = design observation = Task { [weak self] in - for await state in ObservationStream({ - RenderState( - sectionsRevision: model.observedSectionsRevision, - selection: model.selection, - selected: model.selectedItem, - profiles: model.profiles, - activeProfile: model.activeProfileID, - filter: model.filterText, - layout: model.layout, - itemInfo: model.resolvedItemInfo, - transientTopItems: model.transientTopItems, - collapsedSections: model.collapsedLayoutSections, - look: SidebarSectionTunables.currentLook, - drawsLines: Borders.drawsLines, - preferences: design.sidebarSections, - suppressedApps: model.suppressedApps, - metrics: .standard, - fontSize: Typography.body.pointSize, - titlebarHeight: Metrics.titlebarHeight, - cards: SidebarBottomCards(update: model.updateCard, nowPlaying: model.nowPlaying, updated: model.updatedCard, notice: model.noticeCard) - ) - }) { - self?.render(state) - } + for await state in ObservationStream({ RenderState(model, design) }) { self?.render(state) } } } + /// Draws the model now, in the caller's turn (SidebarSameTurn); the observation's next, equal state is skipped. + public func renderNow() { render(RenderState(model, design)) } + private func render(_ state: RenderState) { guard state != lastState else { return } let chromeChanged = lastState?.metrics != state.metrics || lastState?.fontSize != state.fontSize diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift index 788da9bad485..e24991a679b5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Spring.swift @@ -86,7 +86,7 @@ public struct Spring: Equatable, Sendable { step(dt, parameters: Motion.spring(activeToken)) } - /// Advances by `dt` seconds with fixed substeps (stable at any frame rate). + /// Advances by `dt` seconds on the closed-form spring (exact at any frame rate). public mutating func step(_ dt: CGFloat, parameters: SpringParameters) { lastSample = nil guard !isSettled else { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift index 7836b98c6152..13b3d620417d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift @@ -26,8 +26,8 @@ final class TabCell { var isCloseHovered = false { didSet { if oldValue != isCloseHovered { updateColors(animated: true, showing: isCloseHovered) } } } var isClosePressed = false { didSet { if oldValue != isClosePressed { updateColors(animated: false) } } } var isLifted = false { didSet { if oldValue != isLifted { updateLift() } } } - /// The strip's window is main (in front); off, the selected tab has no fill. - var isWindowMain = true { didSet { if oldValue != isWindowMain { updateColors(animated: false) } } } + /// The strip's window is main (in front); off, the selected tab has no fill, so only it repaints. + var isWindowMain = true { didSet { if oldValue != isWindowMain, isSelected { updateColors(animated: false) } } } /// The strip's window is on screen and not fully covered: a busy indicator spins only then /// (cx-qtgj2: a minimized or covered window costs no spinner frames). var isWindowVisible = true { didSet { if oldValue != isWindowVisible, item.isBusy { updateSpinner() } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift index 5ed49e50ee29..b9f9a6c79dfd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabMotion.swift @@ -12,7 +12,7 @@ struct TabMotion { init(x: CGFloat, width: CGFloat, alpha: CGFloat) { self.x = Spring(value: x, token: .move, kind: .position) - self.width = Spring(value: width, token: width == 0 ? .appear : .move) + self.width = Spring(value: width, token: width == 0 ? .tabOpen : .move) self.alpha = Spring(value: alpha, token: .appear, epsilon: 0.004) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripCrossfade.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripCrossfade.swift deleted file mode 100644 index a0a1d451894e..000000000000 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripCrossfade.swift +++ /dev/null @@ -1,14 +0,0 @@ -import AppKit -import CmuxNextDesign - -/// Reduce Motion in the tab strip: tabs take their places at once, and a tab that comes or goes -/// crossfades (motion.md rule 7) instead of vanishing or popping in. The strip's own springs -/// (width and alpha) do not run there. -@MainActor -enum TabStripCrossfade { - /// Crossfades `clip` when the tabs `next` differ from the ones `shown`, under Reduce Motion. - static func run(in clip: NSView, shown: Set, next: Set) { - guard !Motion.animatesMovement, shown != next else { return } - Motion.crossfadeInPlace(clip.layer) - } -} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripDebug+DragProof.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripDebug+DragProof.swift new file mode 100644 index 000000000000..0246a522183d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripDebug+DragProof.swift @@ -0,0 +1,34 @@ +#if DEBUG +public import AppKit +import CmuxNextWakeups + +extension TabStripDebug { + /// `debug.new_tab {action: "frame_strip", gesture: "drag_new"}` (motion proofs): drags `strip`'s + /// first tab right in 12 steps, one per frame (synthetic events into the strip's own handlers), + /// and runs `midway` at step 4 (the app opens a terminal tab there: the drag's springs must keep + /// running). False when the strip shows no tab. + public static func dragFirstTab(in strip: TabStripView, midway: () -> Void) async -> Bool { + guard let window = strip.window, let id = strip.displayedOrder.first, let start = strip.tabCenterInWindow(id) else { return false } + func send(_ type: NSEvent.EventType, _ dx: CGFloat) { + guard let event = NSEvent.mouseEvent(with: type, location: CGPoint(x: start.x + dx, y: start.y), modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, + context: nil, eventNumber: 0, clickCount: 1, pressure: type == .leftMouseUp ? 0 : 1) + else { return } + switch type { + case .leftMouseDown: strip.mouseDown(with: event) + case .leftMouseUp: strip.mouseUp(with: event) + default: strip.mouseDragged(with: event) + } + } + send(.leftMouseDown, 0) + for step in 1...12 { + let tick = DemandTimer(owner: "debug.frame_strip.drag") + await withCheckedContinuation { (done: CheckedContinuation) in tick.schedule(after: .milliseconds(16)) { @MainActor in done.resume() } } + send(.leftMouseDragged, CGFloat(step) * 18) + if step == 4 { midway() } + } + send(.leftMouseUp, 12 * 18) + return true + } +} +#endif diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift index be69a5f33bf0..d518afdae315 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift @@ -276,3 +276,15 @@ extension TabStripView { /// trails the reply by a frame or two) before the model's order shows. static let reorderSettleTimeout: Duration = .milliseconds(300) } + +// Debug proofs (debug.new_tab frame_strip `drag_new`) drive a drag at a tab's place. +extension TabStripView { + /// The tabs in the order the strip shows them. + public var displayedOrder: [TabID] { displayed.map(\.id) } + + /// Where `id`'s cell shows now, its center in window coordinates; nil when it shows nowhere. + public func tabCenterInWindow(_ id: TabID) -> CGPoint? { + guard let m = motion[id] else { return nil } + return tabsClip.convert(CGPoint(x: m.x.value - scroll.value + m.width.value / 2, y: tabsClip.bounds.midY), to: nil) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift index 9aa9e14a2815..5a250149ec4d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Layout.swift @@ -1,5 +1,5 @@ import AppKit -import CmuxNextDesign +public import CmuxNextDesign import CmuxNextWakeups import QuartzCore // Layout, frame application, edge fades, and the FrameScheduler animation client. @@ -45,7 +45,10 @@ extension TabStripView { items.insert(placeholder, at: position) } - func relayout(animated: Bool, added: Set = []) { + /// `growsAdded` (default false): the `added` tabs grow in from zero width (`ui.animations.newTab` + /// on, outside a gesture); otherwise they are there at once at full width (nx-cmdt-fix). + func relayout(animated: Bool, added: Set = [], growsAdded: Bool = false) { + let grows = growsAdded result = TabLayoutEngine.layout( items: layoutItems(), availableWidth: viewportWidth, @@ -56,7 +59,7 @@ extension TabStripView { // A new tab (Cmd-T, +, a script's tab) is there at once, at its slot and full width, and the // tabs make room for it in the same frame: no grow-in, no slide (Lawrence 2026-10-14: no big // animations by default). A drag or a torn-out tab's drop keeps every spring as it was. - let insertsAtOnce = !added.isEmpty && drag == nil && pendingDrop == nil + let insertsAtOnce = !added.isEmpty && drag == nil && pendingDrop == nil && !grows for slot in result.slots where slot.id != Self.placeholderID { guard var m = motion[slot.id] else { continue } if added.contains(slot.id) { @@ -64,9 +67,15 @@ extension TabStripView { m = TabMotion(x: pendingDrop.x, width: pendingDrop.width, alpha: 1) self.pendingDrop = nil } else { - m = TabMotion(x: slot.x, width: slot.width, alpha: 1) + // `ui.animations.newTab` on: it grows in from zero width at its slot (hq-1d). + m = TabMotion(x: slot.x, width: grows ? 0 : slot.width, alpha: grows ? 0 : 1) } } + // A tab growing in and the tabs right of it making room move on one spring (`tabOpen`), + // so the strip never overlaps; later moves go back to `move`. + if grows, !added.contains(slot.id) { (m.x.token, m.width.token) = (.tabOpen, .tabOpen) } + if !grows, m.x.token == .tabOpen, m.x.isSettled { m.x.token = .move } + if !grows, m.width.token == .tabOpen, m.width.isSettled { m.width.token = .move } if drag?.id != slot.id { m.x.target = groupDragX(for: slot) ?? slot.x } if groups.isDragged(slot.id) { m.x.snap() } m.width.target = slot.width @@ -180,9 +189,10 @@ extension TabStripView { func startAnimating() { guard window != nil else { return } - guard !animationClient.isActive else { return } - MotionTrace.begin("tabs") - animationClient.activate() + let clock = MotionClock.forView(self) + if let motionClock, motionClock !== clock { motionClock.stop(self) } + motionClock = clock + clock.run(self, trace: "tabs") } /// One animation frame; false when everything settled. @@ -207,10 +217,17 @@ extension TabStripView { if autoscrollDuringDrag(dt) { active = true } scroll.step(dt) if !scroll.isSettled { active = true } + if MotionTrace.isEnabled { + // The points the strip's tabs have left to travel (x and width): its spring's path, for debug.motion. + MotionTrace.sample("tabs.travel", Double(motion.values.reduce(0) { $0 + abs($1.x.target - $1.x.value) + abs($1.width.target - $1.width.value) })) + } // Tabs sliding under a still pointer update hover (applyFrames -> // geometryDidChange). applyFrames() - if !active { MotionTrace.end("tabs") } return active } } + +extension TabStripView: MotionAnimating { + public func motionStep(_ dt: Double) -> Bool { advance(CGFloat(dt)) } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift index d7d927169d87..bc37ea01a636 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Sync.swift @@ -41,13 +41,28 @@ extension TabStripView { } } - let animated = hasSynced && !reduceMotion && animating + // A new tab grows into its slot while its neighbours slide, on one clock, and a closed one + // gives its room back, only with `ui.animations.newTab` on (hq-1d, Lawrence 2026-10-14: no big + // animations by default). Off (the default) or under Reduce Motion the strip takes its new + // places in one step, with no crossfade standing in for the motion (DESIGN P6). A tab that + // comes or goes during a person's gesture (a drag, a group drag, a drop settling) appears or + // leaves at once while the gesture's other springs keep running. Reorders keep their motion. let ids = Set(ordered.map(\.id)) - if hasSynced { TabStripCrossfade.run(in: tabsClip, shown: Set(cells.keys), next: ids) } + let adding = ordered.contains { cells[$0.id] == nil } + let removing = cells.keys.contains { !ids.contains($0) && !dying.contains($0) } + let structureAnimates = !(adding || removing) || Motion.animates(.newTab) + let inGesture = drag != nil || groups.drag != nil || pendingDrop != nil + let animated = hasSynced && !reduceMotion && animating && (structureAnimates || inGesture) + // Added/closed cells move only outside a gesture; a sync that adds no tab never re-tokens the strip. + let cellsAnimate = animated && structureAnimates && !inGesture + if MotionTrace.isEnabled { + MotionTrace.note("tabs.sync fromModel=\(fromModel) animating=\(animating) animated=\(animated)" + + " added=\(ordered.filter { cells[$0.id] == nil }.map(\.id.rawValue)) removed=\(cells.keys.filter { !ids.contains($0) }.map(\.rawValue))") + } // A tab taking another's place (`TabStripModel.successors`) gets its cell's place; that cell goes now. let handed = model.takeSuccessions(shown: Set(cells.keys), next: ids) for (id, cell) in cells where !ids.contains(id) && !dying.contains(id) && !handed.values.contains(id) { - if animated { + if cellsAnimate { dying.insert(id) motion[id]?.width.target = 0 motion[id]?.alpha.target = 0 @@ -69,7 +84,7 @@ extension TabStripView { motion[item.id] = place removeTab(from) } else { - added.insert(makeCell(item, animated: animated)) + added.insert(makeCell(item, animated: cellsAnimate)) } } added.formUnion(syncChips(ordered, animated: animated)) @@ -90,7 +105,7 @@ extension TabStripView { needsLayout = true } - relayout(animated: animated || (styleChanged && !reduceMotion), added: added) + relayout(animated: animated || (styleChanged && !reduceMotion), added: added, growsAdded: cellsAnimate && adding) let selected = model.selectedID if let selected, selected != lastSelectedID || added.contains(selected) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift index 88e204d552b3..35f4c2fa6da2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView.swift @@ -82,10 +82,10 @@ public final class TabStripView: NSView { var lastModelOrder: [TabID] = [] var lastStyle: TabStripStyle? var lastViewportWidth: CGFloat = -1 - /// Springs and drag autoscroll tick on the window's FrameScheduler. - lazy var animationClient = FrameClient(owner: "TabStrip.animation", view: self) { [weak self] tick in - self?.advance(CGFloat(tick.elapsed)) ?? false - } + /// Springs and drag autoscroll step on the window's one `MotionClock`, + /// with the window's other chrome springs. + weak var motionClock: MotionClock? + var isAnimating: Bool { motionClock?.isRunning(self) ?? false } var observationTask: Task? var tokenObservationTask: Task? @@ -239,7 +239,8 @@ public final class TabStripView: NSView { observationTask = nil tokenObservationTask?.cancel() tokenObservationTask = nil - animationClient.deactivate() + motionClock?.stop(self) + motionClock = nil groupEditor.hide() groups.holdTask?.cancel() removeEscapeMonitor() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneSubagentStopRelayTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneSubagentStopRelayTests.swift new file mode 100644 index 000000000000..3a1a6d9f9c74 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneSubagentStopRelayTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import CmuxNextAgentPane + +/// Stopping a running Claude subagent or workflow at the relay boundary (the page's frames into +/// ``AgentPaneTransport``, out to acpmux): the row's stop button (direct.ts stopSubagent) reaches +/// acpmux only for a chat in the pane's scope and only in the shape the pane sends. It stops +/// work and grants nothing, like `_acpmux/kill`, so it needs no gesture. +@Suite(.serialized) +@MainActor +struct AgentPaneSubagentStopRelayTests { + static func frame(id: Int, params: String) -> String { + #"{"jsonrpc":"2.0","id":\#(id),"method":"_acpmux/subagent_stop","params":\#(params)}"# + } + + @Test func aStopGoesOutOnlyForTheChatInTheShapeThePaneSends() async throws { + let server = AcpmuxStandInServer() + try await server.start() + defer { server.stop() } + let inbox = AgentPaneRelayedAllowTests.PageInbox() + let transport = AgentPaneTransport() + transport.deliver = { event, done in inbox.deliver(event.frames); done() } + let id = try await transport.open(AcpmuxConnection(url: server.url, dashboardToken: "t", localAppToken: nil)) + #expect(await transport.send(connection: id, frames: [#"{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}"#]) == nil) + transport.sessions.add("s") + let sent = { server.peers.first?.frames.filter { $0.contains("_acpmux/subagent_stop") } ?? [] } + + #expect(await transport.send(connection: id, frames: [Self.frame(id: 1, params: #"{"sessionId":"other","subagentSessionId":"a"}"#)]) != nil, + "a chat outside the pane's scope") + #expect(await transport.send(connection: id, frames: [Self.frame(id: 2, params: #"{"sessionId":"s","subagentSessionId":"a","command":"ls"}"#)]) != nil, + "a param the pane never sends") + #expect(await transport.send(connection: id, frames: [Self.frame(id: 3, params: #"{"subagentSessionId":"a"}"#)]) != nil, + "no chat named") + #expect(sent().isEmpty, "a refused frame reached acpmux: \(sent())") + + // No gesture: a stop grants nothing. + #expect(await transport.send(connection: id, frames: [Self.frame(id: 4, params: #"{"sessionId":"s","subagentSessionId":"a"}"#)]) == nil) + _ = await inbox.frame(containing: #""id":4"#) + let frames = sent() + #expect(frames.count == 1, "\(frames)") + let object = try JSONSerialization.jsonObject(with: Data(try #require(frames.first).utf8)) as? [String: Any] + let params = try #require(object?["params"] as? [String: Any]) + #expect(params["sessionId"] as? String == "s") + #expect(params["subagentSessionId"] as? String == "a") + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneHarnessCardTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneHarnessCardTests.swift new file mode 100644 index 000000000000..4fdbf94b9906 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneHarnessCardTests.swift @@ -0,0 +1,38 @@ +@testable import CmuxNextAgentPane +import Foundation +import Testing + +/// The agent CLI card's Restart Chat (cx-1mbnu) restarts only a chat this pane started or shows: +/// a page that adopts another chat's id (`chat.persistSession`) and then asks for a restart is +/// refused before anything reaches acpmux, so it cannot restart other chats' agents. +@MainActor +struct AgentPaneHarnessCardTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } + } + + /// A pane with an acpmux socket that does not answer: a request that reaches it fails as + /// `harness.failed`, one refused first never gets there. + private static func pane() -> AgentPaneModel { + let model = AgentPaneModel(host: NoHost()) + model.transport.acpmuxSocket = FileManager.default.temporaryDirectory.appending(path: "no-acpmux-\(UUID().uuidString).sock").path + return model + } + + private static func restart(_ model: AgentPaneModel, adopting id: String) async -> String? { + _ = await model.respond(to: AgentPaneRequest(body: ["method": "chat.persistSession", "params": ["sessionId": id]])) + model.transport.gestures.record() + let reply = await model.respond(to: AgentPaneRequest(body: ["method": "harness.restart", "params": [:]])) + return (reply["error"] as? [String: Any])?["code"] as? String + } + + @Test func restartOfAChatThePaneDoesNotShowIsRefused() async { + #expect(await Self.restart(Self.pane(), adopting: "someone-elses-chat") == "harness.not_this_chat") + } + + @Test func restartOfThePanesOwnChatGoesToAcpmux() async { + let model = Self.pane() + model.transport.sessions.add("this-chat") + #expect(await Self.restart(model, adopting: "this-chat") == "harness.failed") + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift index 3f0591f4563e..d227a8600bea 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ClosedBlankItemsTests.swift @@ -59,20 +59,26 @@ struct ClosedBlankItemsTests { } /// One row per page (Leo 2026-10-14): a page closed again shows once, at its newest close, - /// newest first. Terminals and chats carry no identity beyond a name or folder, so each close - /// of them stays its own row. - @Test func aPageClosedAgainShowsOnceAtItsNewestClose() { - func entry(_ id: String, _ kind: CmuxNextHistory.ClosedItem.Kind, at seconds: Double, url: String? = nil, cwd: String? = nil) -> HistoryEntry { - let item = CmuxNextHistory.ClosedItem(id: id, kind: kind, title: url ?? cwd ?? "", machine: "local", cwd: cwd, url: url) - return HistoryEntry(id: id, kind: .closed, time: Date(timeIntervalSince1970: seconds), title: item.title, payload: .closed(item)) + /// newest first, by the key the daemon's history list uses (`DaemonClosedHistory.pageKey`). + /// Terminals and chats carry no identity beyond a name or folder, so each close of them stays + /// its own row. + @Test func aPageClosedAgainShowsOnceAtItsNewestClose() throws { + let example = """ + {"kind":"browser","name":null,"cwd":null,"url":"https://example.com/","browser_profile_id":null,"pinned":false} + """ + let cmux = example.replacingOccurrences(of: "example.com", with: "cmux.com") + func entry(_ id: String, _ tab: String) throws -> CmuxNextDaemon.ClosedItem { + var item = try Self.closed("tab", tab) + item.id = id + return item } - let shown = HistoryService.newestOnce([ - entry("example-1", .browserTab, at: 1, url: "https://example.com/"), - entry("shell-1", .terminalTab, at: 2, cwd: "/Users/you"), - entry("example-2", .browserTab, at: 3, url: "https://example.com/"), - entry("shell-2", .terminalTab, at: 4, cwd: "/Users/you"), - entry("cmux", .browserTab, at: 5, url: "https://cmux.com/"), - ]).map { $0.id } + let shown = try DaemonClosedHistory.newestOnce([ + entry("cmux", cmux), + entry("shell-2", Self.terminal), + entry("example-2", example), + entry("shell-1", Self.terminal), + entry("example-1", example), + ]) { ($0, "local") }.map(\.id) #expect(shown == ["cmux", "shell-2", "example-2", "shell-1"]) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneGutterSuites.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneGutterSuites.swift new file mode 100644 index 000000000000..d2c64740b54a --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneGutterSuites.swift @@ -0,0 +1,21 @@ +import CmuxNextDesign +import CoreGraphics +import Testing + +/// The suites that read the curved main pane's gutter (`chrome.mainPane.gutter`): the window +/// edge, its corners and the chrome shade. They run one at a time under this parent, so a test +/// that sets the gutter (the Debug Settings flush opt-in, gutter 0) never runs beside one that +/// reads the default. +@MainActor @Suite(.serialized) struct MainPaneGutterSuites { + /// Runs `body` with the main pane gutter at `points` (the Debug Settings override), then + /// removes the override. + static func withGutter(_ points: CGFloat, _ body: () throws -> T) rethrows -> T { + let store = TunableStore.shared + let gutter = ChromeTunables.mainPaneGutter + store.register([gutter.descriptor]) + if !store.isActive { store.activate(file: nil) } + store.set(gutter.key, .number(Double(points))) + defer { store.set(gutter.key, nil) } + return try body() + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift index 37e2a4c1227e..6f6e56c820b9 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneOneInsetTests.swift @@ -5,82 +5,102 @@ import CmuxNextDesign import CmuxNextSidebar import Testing -/// Edge containers batch 1 (Leo 2026-10-12, "odd padding all around the window and panes"): the -/// window stacked insets. The curved main pane sat a 6 pt gutter in from the window, every pane -/// sat another 2 pt (plus a 2 pt column gap at the sides) inside it as its own outlined, rounded -/// box, and beside the sidebar the gutter came on top of the sidebar's own 6 pt row inset. Inside -/// the main pane panes are flush and told apart by one line. The gutter itself runs on every side -/// of the main pane, the sidebar's too (Lawrence 2026-10-14: a margin all round, as in Helium). -@MainActor @Suite(.serialized) struct MainPaneOneInsetTests { - func root(sidebarHidden: Bool) -> (NSWindow, WindowRootView) { - _ = NSApplication.shared - let model = SidebarModel() - if sidebarHidden { model.presentation = .hidden } - let root = WindowRootView(sidebar: SidebarContainerView(model: model)) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - if sidebarHidden { - root.sidebar.isHidden = true - root.sidebarHidden = true +extension MainPaneGutterSuites { + /// Edge containers batch 1 (Leo 2026-10-12, "odd padding all around the window and panes"): the + /// window stacked insets. The curved main pane sat a 6 pt gutter in from the window, every pane + /// sat another 2 pt (plus a 2 pt column gap at the sides) inside it as its own outlined, rounded + /// box, and beside the sidebar the gutter came on top of the sidebar's own 6 pt row inset. Inside + /// the main pane panes are flush and told apart by one line. The gutter itself runs on every side + /// of the main pane, the sidebar's too (Lawrence 2026-10-14: a margin all round, as in Helium). + /// With the gutter at 0 (the Debug Settings flush opt-in, 2026-10-14) the pane meets the window + /// edges and the sidebar. + @MainActor @Suite(.serialized) struct MainPaneOneInsetTests { + func root(sidebarHidden: Bool) -> (NSWindow, WindowRootView) { + _ = NSApplication.shared + let model = SidebarModel() + if sidebarHidden { model.presentation = .hidden } + let root = WindowRootView(sidebar: SidebarContainerView(model: model)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.contentView = root + if sidebarHidden { + root.sidebar.isHidden = true + root.sidebarHidden = true + } + root.needsLayout = true + root.layoutSubtreeIfNeeded() + return (window, root) } - root.needsLayout = true - root.layoutSubtreeIfNeeded() - return (window, root) - } - @Test func panesInsideTheMainPaneAreFlushWithOneLineBetweenThem() throws { - try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") - let style = LayoutStyle().applyingDesignMetrics() - #expect(style.panePadding == 0, "no inset of a pane inside the main pane's gutter") - #expect(!style.showsPaneBorder, "no outlined box per pane") - #expect(style.paneCornerRadius == 0, "no rounded box per pane: the main pane rounds") - #expect(style.stripGap == 0, "no gap at the column strip's edges or between columns") - #expect(style.showsDividerLine, "one line between neighboring panes") - let cell = CGRect(x: 0, y: 0, width: 400, height: 300) - #expect(PaneChromeGeometry.contentRect(forCell: cell, style: style) == cell) - } + @Test func panesInsideTheMainPaneAreFlushWithOneLineBetweenThem() throws { + try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") + let style = LayoutStyle().applyingDesignMetrics() + #expect(style.panePadding == 0, "no inset of a pane inside the main pane's gutter") + #expect(!style.showsPaneBorder, "no outlined box per pane") + #expect(style.paneCornerRadius == 0, "no rounded box per pane: the main pane rounds") + #expect(style.stripGap == 0, "no gap at the column strip's edges or between columns") + #expect(style.showsDividerLine, "one line between neighboring panes") + let cell = CGRect(x: 0, y: 0, width: 400, height: 300) + #expect(PaneChromeGeometry.contentRect(forCell: cell, style: style) == cell) + } - /// Dividers are only the default for a cmux.json that says nothing about pane chrome: a pane - /// corner radius, border color or border width describes a bordered pane, so it keeps borders - /// (dividers would round flush panes and drop the color and width). - @Test func aPaneLookSettingWithoutASeparationKeepsBorders() { - let looks = [PaneChromeOverrides(cornerRadius: 4), PaneChromeOverrides(borderColor: ThemeRGB(hex: 0x336699)), - PaneChromeOverrides(borderWidth: 2)] - for look in looks { - #expect(PaneSeparation.resolve(look, unset: .dividers) == .borders, "\(look)") + /// Dividers are only the default for a cmux.json that says nothing about pane chrome: a pane + /// corner radius, border color or border width describes a bordered pane, so it keeps borders + /// (dividers would round flush panes and drop the color and width). + @Test func aPaneLookSettingWithoutASeparationKeepsBorders() { + let looks = [PaneChromeOverrides(cornerRadius: 4), PaneChromeOverrides(borderColor: ThemeRGB(hex: 0x336699)), + PaneChromeOverrides(borderWidth: 2)] + for look in looks { + #expect(PaneSeparation.resolve(look, unset: .dividers) == .borders, "\(look)") + } + #expect(PaneSeparation.resolve(PaneChromeOverrides(), unset: .dividers) == .dividers) } - #expect(PaneSeparation.resolve(PaneChromeOverrides(), unset: .dividers) == .dividers) - } - /// A column gap the style carries is honored under every separation: the default inside the - /// main pane is no gap, but an `appearance.metrics.columnGap` the user set still applies. - @Test func aColumnGapIsHonoredUnderDividers() { - for separation in [PaneSeparation.dividers, .none] { - var style = LayoutStyle() - style.paneSeparation = separation - style.panePadding = 0 - style.columnGap = 10 - #expect(style.stripGap == 10, "\(separation)") + /// A column gap the style carries is honored under every separation: the default inside the + /// main pane is no gap, but an `appearance.metrics.columnGap` the user set still applies. + @Test func aColumnGapIsHonoredUnderDividers() { + for separation in [PaneSeparation.dividers, .none] { + var style = LayoutStyle() + style.paneSeparation = separation + style.panePadding = 0 + style.columnGap = 10 + #expect(style.stripGap == 10, "\(separation)") + } } - } - @Test func theGutterRunsBesideTheSidebarToo() throws { - let (window, root) = root(sidebarHidden: false) - defer { window.close() } - try #require(Metrics.mainPaneGutter > 0 && root.sidebar.frame.width > 0) - let sidebar = root.sidebar.frame, card = root.contentHost.frame - #expect(card.minX - sidebar.maxX == Metrics.mainPaneGutter, "the card starts \(card.minX - sidebar.maxX) pt after the sidebar") - #expect(window.contentView!.bounds.maxX - card.maxX == Metrics.mainPaneGutter, "the gutter at the window edge") - } + @Test func theGutterRunsBesideTheSidebarToo() throws { + let (window, root) = root(sidebarHidden: false) + defer { window.close() } + try #require(Metrics.mainPaneCurved && root.sidebar.frame.width > 0) + let sidebar = root.sidebar.frame, card = root.contentHost.frame + #expect(Metrics.mainPaneGutter > 0, "the default keeps a gutter on every side") + #expect(card.minX - sidebar.maxX == Metrics.mainPaneGutter, "the card starts \(card.minX - sidebar.maxX) pt after the sidebar") + #expect(window.contentView!.bounds.maxX - card.maxX == Metrics.mainPaneGutter, "the gutter at the window edge") + } - @Test func withTheSidebarHiddenTheGutterIsAtTheWindowEdge() throws { - let (window, root) = root(sidebarHidden: true) - defer { window.close() } - try #require(Metrics.mainPaneGutter > 0) - #expect(root.contentHost.frame.minX == Metrics.mainPaneGutter) + @Test func withTheSidebarHiddenTheGutterIsAtTheWindowEdge() throws { + let (window, root) = root(sidebarHidden: true) + defer { window.close() } + try #require(Metrics.mainPaneCurved) + #expect(Metrics.mainPaneGutter > 0, "the default keeps a gutter at the window edges") + #expect(root.contentHost.frame.minX == Metrics.mainPaneGutter) + } + + /// Flush (gutter 0): the pane meets every window edge, and the sidebar when it shows. + @Test(arguments: [false, true]) + func withNoGutterThePaneMeetsTheWindowEdges(sidebarHidden: Bool) throws { + try MainPaneGutterSuites.withGutter(0) { + let (window, root) = root(sidebarHidden: sidebarHidden) + defer { window.close() } + try #require(Metrics.mainPaneCurved) + let card = root.contentHost.frame, bounds = window.contentView!.bounds + #expect(card.maxX == bounds.maxX && card.maxY == bounds.maxY && card.minY == bounds.minY, + "no gutter at the window edges") + if sidebarHidden { #expect(card.minX == bounds.minX) } else { #expect(card.minX == root.sidebar.frame.maxX) } + } + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift index 1f9da885ae16..349958cbb57b 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/MainPaneWindowCornerTests.swift @@ -5,33 +5,48 @@ import CmuxNextDesign import CmuxNextSidebar import Testing -/// Edge containers batch 1 (Leo 2026-10-12, "the bottom-edge rounding"): the curved main pane -/// sits the gutter in from the window's edges, and its corners used a fixed 8 pt radius. At the -/// window's bottom corners that curve did not follow the window's own (about 16 pt), so the gutter -/// pinched and widened around each corner. The pane's corners are concentric with the window's: -/// the window radius less the gutter, so the gutter runs at one width all the way round. -@MainActor struct MainPaneWindowCornerTests { - func root() -> (NSWindow, WindowRootView) { - _ = NSApplication.shared - let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) - let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - root.needsLayout = true - root.layoutSubtreeIfNeeded() - return (window, root) - } +extension MainPaneGutterSuites { + /// Edge containers batch 1 (Leo 2026-10-12, "the bottom-edge rounding"): the curved main pane + /// sits the gutter in from the window's edges, and its corners used a fixed 8 pt radius. At the + /// window's bottom corners that curve did not follow the window's own (about 16 pt), so the + /// gutter pinched and widened around each corner. The pane's corners are concentric with the + /// window's: the window radius less the gutter. With the gutter at 0 (the Debug Settings flush + /// opt-in) they are the window's own. + @MainActor struct MainPaneWindowCornerTests { + func root() -> (NSWindow, WindowRootView) { + _ = NSApplication.shared + let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.contentView = root + root.needsLayout = true + root.layoutSubtreeIfNeeded() + return (window, root) + } + + @Test func theMainPaneCornersAreConcentricWithTheWindowCorners() throws { + let (window, root) = root() + defer { window.close() } + let windowRadius = CEFClipShape.windowCornerRadius(window) + let gutter = Metrics.mainPaneGutter + try #require(Metrics.mainPaneCurved && windowRadius > gutter, "a curved main pane in a window with round corners") + #expect(gutter > 0, "the default keeps a gutter at the window edges") + #expect(root.contentHost.layer?.cornerRadius == windowRadius - gutter, + "the pane's corner is the window's corner less the gutter") + } - @Test func theMainPaneCornersAreConcentricWithTheWindowCorners() throws { - let (window, root) = root() - defer { window.close() } - let windowRadius = CEFClipShape.windowCornerRadius(window) - let gutter = Metrics.mainPaneGutter - try #require(Metrics.mainPaneCurved && windowRadius > gutter, "a curved main pane in a window with round corners") - #expect(root.contentHost.layer?.cornerRadius == windowRadius - gutter, - "the pane's corner is the window's corner less the gutter") + /// Flush (gutter 0): the pane's corners are the window's own, so the two are one outline. + @Test func withNoGutterTheMainPaneCornersAreTheWindowCorners() throws { + try MainPaneGutterSuites.withGutter(0) { + let (window, root) = root() + defer { window.close() } + let windowRadius = CEFClipShape.windowCornerRadius(window) + try #require(Metrics.mainPaneCurved && windowRadius > 0, "a curved main pane in a window with round corners") + #expect(root.contentHost.layer?.cornerRadius == windowRadius, "the pane's corner is the window's own") + } + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ReduceMotionListFadeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ReduceMotionListFadeTests.swift deleted file mode 100644 index 8ec837c9fd9b..000000000000 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ReduceMotionListFadeTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import AppKit -@testable import CmuxNextDesign -@testable import CmuxNextSidebar -@testable import CmuxNextTabs -import Testing - -/// POLISH audit, batch 2 (cx-2grmt): under Reduce Motion a closed workspace's sidebar row and a -/// closed tab vanished in one frame, and new ones popped in: their fade ran inside the movement -/// spring, which Reduce Motion skips. The rows and tabs take their places at once and the change -/// crossfades (motion.md rule 7). -@MainActor @Suite(.serialized) struct ReduceMotionListFadeTests { - /// The crossfades running on `layer`. - static func crossfades(_ layer: CALayer?) -> [CATransition] { - (layer?.animationKeys() ?? []).compactMap { layer?.animation(forKey: $0) as? CATransition } - } - - @Test func aClosedWorkspaceRowCrossfadesUnderReduceMotion() throws { - // Synchronous from here to the reset: the override is process-wide. - Motion.reduceMotionOverride = true - defer { Motion.reduceMotionOverride = nil } - let model = SidebarMock.makeModel() - let list = SidebarListView(model: model) - let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 240, height: 900), styleMask: [.borderless], - backing: .buffered, defer: false) - defer { window.orderOut(nil) } - window.contentView = list - list.frame = NSRect(x: 0, y: 0, width: 240, height: 900) - list.reload(animated: false) - let section = try #require(model.sections.firstIndex { $0.id == .machine(.local) }) - let node = try #require(model.sections[section].nodes.firstIndex { - if case let .workspace(ws) = $0 { ws.id != model.activeWorkspaceID } else { false } - }) - guard case let .workspace(closed) = model.sections[section].nodes[node] else { return } - #expect(list.rowViews[.workspace(closed.id)] != nil) - - model.sections[section].nodes.remove(at: node) - list.reload(animated: true) - #expect(list.rowViews[.workspace(closed.id)] == nil) - #expect(!Self.crossfades(list.layer).isEmpty, "the closed row vanished in one frame") - } - - @Test func aClosedTabCrossfadesUnderReduceMotion() throws { - Motion.reduceMotionOverride = true - defer { Motion.reduceMotionOverride = nil } - let tabs = (0..<3).map { TabItem(id: TabID("tab\($0)"), title: "Tab \($0)") } - let model = TabStripModel(tabs: tabs, selectedID: TabID("tab0")) - let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 600, height: 40), styleMask: [.borderless], - backing: .buffered, defer: false) - defer { window.orderOut(nil) } - let strip = TabStripView(model: model) - strip.frame = NSRect(x: 0, y: 0, width: 600, height: TabStripView.preferredHeight) - window.contentView = strip - strip.layoutSubtreeIfNeeded() - - model.tabs.removeLast() - strip.sync(fromModel: true) - #expect(strip.cells[TabID("tab2")] == nil) - #expect(!Self.crossfades(strip.tabsClip.layer).isEmpty, "the closed tab vanished in one frame") - } - - /// Review of #19938: a tab synced without the strip's springs (`animating: false`, at full - /// width at once) crossfades in under Reduce Motion too. - @Test func aNewTabFromCmdTCrossfadesUnderReduceMotion() throws { - Motion.reduceMotionOverride = true - defer { Motion.reduceMotionOverride = nil } - let model = TabStripModel(tabs: [TabItem(id: TabID("tab0"), title: "Tab 0")], selectedID: TabID("tab0")) - let window = NSWindow(contentRect: NSRect(x: 200, y: 200, width: 600, height: 40), styleMask: [.borderless], - backing: .buffered, defer: false) - defer { window.orderOut(nil) } - let strip = TabStripView(model: model) - strip.frame = NSRect(x: 0, y: 0, width: 600, height: TabStripView.preferredHeight) - window.contentView = strip - strip.layoutSubtreeIfNeeded() - - model.tabs.append(TabItem(id: TabID("tab1"), title: "New Tab")) - model.selectedID = TabID("tab1") - strip.sync(fromModel: true, animating: false) - #expect(strip.cells[TabID("tab1")] != nil) - #expect(!Self.crossfades(strip.tabsClip.layer).isEmpty, "Cmd-T's tab popped in") - } -} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift index b0fec54068f4..41be0664dc7d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SharedIndicatorSurfacesTests.swift @@ -19,11 +19,15 @@ struct SharedIndicatorSurfacesTests { /// `working` record, `extra.program_status`). A plain shell command no longer shows busy by /// default (cx-ag5.6.5, `status.inferCommandBusy`). func store(busy: Bool) throws -> DaemonStore { + try store(extra: busy ? #"{"program_status":[{"id":"","state":"working","app":"cargo","updated_seq":"1"}]}"# : "{}") + } + + /// A store with one terminal tab whose terminal record carries `extra`. + func store(extra: String) throws -> DaemonStore { let store = DaemonStore() let pane = PaneSnapshot(id: 1, tabs: [TabSnapshot(surface: 1, terminalResourceID: "term_7")]) let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [pane]) store.apply(snapshot: DaemonTree(workspaces: [WorkspaceSnapshot(id: 1, key: nil, name: "w", screens: [screen])])) - let extra = busy ? #"{"program_status":[{"id":"","state":"working","app":"cargo","updated_seq":"1"}]}"# : "{}" let line = #"{"type":"stream_item","item":{"kind":"snapshot","snapshot":{"terminals":[{"id":"term_7","extra":\#(extra)}],"extra":{"state":{}}}}}"# let item = try #require(SessionStreamItem.decode(Data(line.utf8))) store.session.apply(item, to: store.workspaces) @@ -60,6 +64,41 @@ struct SharedIndicatorSurfacesTests { #expect(shown(card) == (busy ? .working(progress: nil) : nil)) } + /// U3/U7 (cx-6so.11): with `status.inferCommandBusy` on, a plain shell command (the daemon's + /// `extra.busy.since_ms`, OSC 133 C..D) shows busy once it has run `inferCommandBusyAfter`; a + /// younger one shows nothing until its one-shot recheck fires (no polling), and with the setting + /// off it never shows. A daemon whose clock runs ahead counts from when this client saw the command. + @Test func aPlainCommandShowsBusyOnlyAfterItsThreshold() async throws { + let saved = DesignSettings.shared.statusBehavior + defer { DesignSettings.shared.statusBehavior = saved } + func tab(startedMsAgo age: Int64) throws -> TabModel { + let since = Int64(Date().timeIntervalSince1970 * 1000) - age + let extra = #"{"busy":{"since_ms":"\#(since)"}}"# + return try #require(try store(extra: extra).workspaces.first?.screens.first?.panes.first?.tabs.first) + } + // Sets the behavior and reads the indicator with no suspension between, so another suite's + // settings apply on the main actor cannot land in between. + func shown(_ tab: TabModel, infer: Bool, after: Double) -> StatusIndicatorState? { + DesignSettings.shared.statusBehavior.inferCommandBusy = infer + DesignSettings.shared.statusBehavior.inferCommandBusyAfter = after + let card = TabHoverCardView() + card.configure(.tab(TabItemMapping.shared.item(tab, fallbackTitle: "zsh"))) + return self.shown(card) + } + + #expect(try shown(tab(startedMsAgo: 60_000), infer: false, after: 3) == nil) + #expect(try shown(tab(startedMsAgo: 60_000), infer: true, after: 3) == .busy(progress: nil)) + + // A minute in the future by the daemon's clock: as young as when this client saw it. + let young = try tab(startedMsAgo: -60_000) + #expect(shown(young, infer: true, after: 1) == nil) + #expect(young.commandRecheck == 0) + let deadline = ContinuousClock.now + .seconds(10) + while young.commandRecheck == 0, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(50)) } + #expect(young.commandRecheck == 1) + #expect(shown(young, infer: true, after: 1) == .busy(progress: nil)) + } + @Test func homesAgentRowsShowTheirStateInTheIndicator() throws { let home = HomeFirstRunView(frame: .zero) home.setAgents([ diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSameTurnTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSameTurnTests.swift new file mode 100644 index 000000000000..55a00ad3a517 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSameTurnTests.swift @@ -0,0 +1,72 @@ +import AppKit +import CmuxNextBridge +@testable import CmuxNextApp +@testable import CmuxNextDaemon +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// Perceived speed: on Cmd-N the window swapped the new workspace's content in on its +/// observation hop (`WindowController.show`), but the sidebar's rows and selection reached +/// `SidebarModel` on the bridge's own hop and the view drew them a hop after that. The first +/// frame of a new workspace showed its content beside the old sidebar: the old row selected and +/// no new row. The window now shows a workspace with its sidebar row listed and selected. +@MainActor struct SidebarSameTurnTests { + private static func tree(_ keys: [String]) -> DaemonTree { + var tree = DaemonTree(registryID: "s-local", workspaces: keys.enumerated().map { index, key in + WorkspaceSnapshot(id: WorkspaceHandle(rawValue: UInt64(index + 1)), key: WorkspaceKey(rawValue: key), name: key) + }) + tree.personal = PersonalState(workspaces: keys.enumerated().map { index, key in + PersonalWorkspace(sessionID: "s-local", workspaceKey: WorkspaceKey(rawValue: key), index: index) + }) + return tree + } + + private static func connected() throws -> DaemonIdentity { + let json = #"{"app":"cmux-tui","protocol":1,"session":"s-local","generation":"g1"}"# + return try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) + } + + private static func listed(_ list: SidebarListView) -> [SidebarRowKey] { list.displayed.rows.map(\.key) } + + @Test func aShownNewWorkspaceListsAndSelectsItsRowInTheSameTurn() async throws { + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let identity = try Self.connected() + store.noteHandshake(identity) // capability checks read the handshake's identity + store.connectionState = .connected(identity) + store.apply(snapshot: Self.tree(["k1"])) + let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"same-turn","workspace_key":"k1"}"#.utf8)) + let state = WindowState(record: record) + services.windows.registry.provisional[state.id] = ["k1"] + let controller = WindowController(state: state, services: services, frame: NSRect(x: 200, y: 200, width: 1100, height: 720)) + defer { + controller.teardown() + controller.window?.close() + } + controller.showWorkspace(requested: state.workspaceID) + try await Task.sleep(for: .milliseconds(300)) + let list = controller.sidebar.container.sidebarView.list + let old = SidebarRowKey.workspace(WorkspaceID("k1")), new = SidebarRowKey.workspace(WorkspaceID("k2")) + try #require(list.selectedRowKey == old, "the fixture's first workspace is not the sidebar's selected row") + + // Cmd-N: the daemon lists the new workspace in the window, the window selects it, and its + // workspace observation shows it (`WindowController.showWorkspace`). + store.apply(snapshot: Self.tree(["k1", "k2"])) + services.windows.registry.provisional[state.id] = ["k1", "k2"] + state.showWorkspace("k2") + controller.showWorkspace(requested: state.workspaceID) + // What the frame that shows the new content commits: no observation turn has run. + let firstListed = Self.listed(list) + let firstSelected = list.selectedRowKey + + // The settled sidebar, after the observations ran: the fixture does list and select the row. + try await Task.sleep(for: .milliseconds(300)) + #expect(Self.listed(list).contains(new)) + #expect(list.selectedRowKey == new) + // The first frame already showed it. + #expect(firstListed.contains(new)) + #expect(firstSelected == new) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift index f239cf3618d6..933029ec57dd 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SidebarSurfaceTests.swift @@ -6,181 +6,183 @@ import CmuxNextSettings import CmuxNextSidebar import Testing -/// `appearance.sidebarSurface` (Leo 2026-10-12): by default the sidebar draws the main pane's own -/// surface, with no shade of its own while pane outlines part them and a faint step beside the -/// default flush, divided panes; "separate" keeps the old look, where the sidebar and the -/// chrome around the curved main pane are shaded darker than the pane. The setting applies live, -/// from cmux.json through the settings applier to the open window. -@MainActor @Suite(.serialized) struct SidebarSurfaceTests { - /// A window root in a titled, full-size-content window, as the app shows it, sidebar shown. - func openRoot() -> (NSWindow, WindowRootView) { - _ = NSApplication.shared - let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) - let window = NSWindow(contentRect: root.frame, styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - root.needsLayout = true - root.layoutSubtreeIfNeeded() - return (window, root) - } +extension MainPaneGutterSuites { + /// `appearance.sidebarSurface` (Leo 2026-10-12): by default the sidebar draws the main pane's own + /// surface, with no shade of its own while pane outlines part them and a faint step beside the + /// default flush, divided panes; "separate" keeps the old look, where the sidebar and the + /// chrome around the curved main pane are shaded darker than the pane. The setting applies live, + /// from cmux.json through the settings applier to the open window. + @MainActor @Suite(.serialized) struct SidebarSurfaceTests { + /// A window root in a titled, full-size-content window, as the app shows it, sidebar shown. + func openRoot() -> (NSWindow, WindowRootView) { + _ = NSApplication.shared + let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) + let window = NSWindow(contentRect: root.frame, styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + window.contentView = root + root.needsLayout = true + root.layoutSubtreeIfNeeded() + return (window, root) + } - /// Loads `json` as the cmux.json, the way a settings load applies it. - @discardableResult - func load(_ json: JSONValue) -> [SettingsDiagnostic] { - let snapshot = CmuxConfigSnapshot.parse(json, validDensities: SettingsApplier.validDensities, - validMetrics: SettingsApplier.validMetrics) - return SettingsApplier(registry: ActionRegistry.standard()).apply(snapshot) - } + /// Loads `json` as the cmux.json, the way a settings load applies it. + @discardableResult + func load(_ json: JSONValue) -> [SettingsDiagnostic] { + let snapshot = CmuxConfigSnapshot.parse(json, validDensities: SettingsApplier.validDensities, + validMetrics: SettingsApplier.validMetrics) + return SettingsApplier(registry: ActionRegistry.standard()).apply(snapshot) + } - func appearance(_ members: [String: JSONValue]) -> JSONValue { - .object(["appearance": .object(members)]) - } + func appearance(_ members: [String: JSONValue]) -> JSONValue { + .object(["appearance": .object(members)]) + } - /// `appearance` members with outlined panes (`layout.paneSeparation` borders): the pane - /// outlines part the sidebar from the main pane, so the shared surface takes no step. Inside - /// the curved main pane the default separation is dividers, which draws no outline. - func outlined(_ members: [String: JSONValue] = [:]) -> JSONValue { - .object(["appearance": .object(members), "layout": .object(["paneSeparation": .string("borders")])]) - } + /// `appearance` members with outlined panes (`layout.paneSeparation` borders): the pane + /// outlines part the sidebar from the main pane, so the shared surface takes no step. Inside + /// the curved main pane the default separation is dividers, which draws no outline. + func outlined(_ members: [String: JSONValue] = [:]) -> JSONValue { + .object(["appearance": .object(members), "layout": .object(["paneSeparation": .string("borders")])]) + } - /// The color the window shows at `point` (root coordinates) under its chrome: the window's - /// one surface, under the main pane's chrome shade where that shade covers the point. - func background(at point: CGPoint, in root: WindowRootView) -> ThemeRGB { - let surface = root.themeTokens.surfaceBackground.withAlpha(1) - let shade = root.mainPaneShade.layer?.sublayers?.lazy.compactMap { $0 as? CAShapeLayer }.first - guard let shade, let path = shade.path, path.contains(point, using: .evenOdd), - let fill = shade.fillColor.flatMap({ NSColor(cgColor: $0) })?.usingColorSpace(.sRGB) else { return surface } - return ThemeRGB(red: Double(fill.redComponent), green: Double(fill.greenComponent), blue: Double(fill.blueComponent), - alpha: Double(fill.alphaComponent)).composited(over: surface) - } + /// The color the window shows at `point` (root coordinates) under its chrome: the window's + /// one surface, under the main pane's chrome shade where that shade covers the point. + func background(at point: CGPoint, in root: WindowRootView) -> ThemeRGB { + let surface = root.themeTokens.surfaceBackground.withAlpha(1) + let shade = root.mainPaneShade.layer?.sublayers?.lazy.compactMap { $0 as? CAShapeLayer }.first + guard let shade, let path = shade.path, path.contains(point, using: .evenOdd), + let fill = shade.fillColor.flatMap({ NSColor(cgColor: $0) })?.usingColorSpace(.sRGB) else { return surface } + return ThemeRGB(red: Double(fill.redComponent), green: Double(fill.greenComponent), blue: Double(fill.blueComponent), + alpha: Double(fill.alphaComponent)).composited(over: surface) + } - func sidebarBackground(_ root: WindowRootView) -> ThemeRGB { - background(at: CGPoint(x: root.sidebar.frame.midX, y: root.sidebar.frame.midY), in: root) - } + func sidebarBackground(_ root: WindowRootView) -> ThemeRGB { + background(at: CGPoint(x: root.sidebar.frame.midX, y: root.sidebar.frame.midY), in: root) + } - func mainPaneBackground(_ root: WindowRootView) -> ThemeRGB { - background(at: CGPoint(x: root.contentHost.frame.midX, y: root.contentHost.frame.midY), in: root) - } + func mainPaneBackground(_ root: WindowRootView) -> ThemeRGB { + background(at: CGPoint(x: root.contentHost.frame.midX, y: root.contentHost.frame.midY), in: root) + } - /// The sidebar's shaded look before this setting: black at the main pane's chrome shade over - /// the surface. - func separateSidebar(_ root: WindowRootView) -> ThemeRGB { - let alpha = root.themeTokens.isDark ? ChromeTunables.mainPaneShadeDark.value : ChromeTunables.mainPaneShadeLight.value - return ThemeRGB.black.withAlpha(alpha).composited(over: root.themeTokens.surfaceBackground.withAlpha(1)) - } + /// The sidebar's shaded look before this setting: black at the main pane's chrome shade over + /// the surface. + func separateSidebar(_ root: WindowRootView) -> ThemeRGB { + let alpha = root.themeTokens.isDark ? ChromeTunables.mainPaneShadeDark.value : ChromeTunables.mainPaneShadeLight.value + return ThemeRGB.black.withAlpha(alpha).composited(over: root.themeTokens.surfaceBackground.withAlpha(1)) + } - func same(_ a: ThemeRGB, _ b: ThemeRGB) -> Bool { - abs(a.red - b.red) < 0.002 && abs(a.green - b.green) < 0.002 && abs(a.blue - b.blue) < 0.002 - } + func same(_ a: ThemeRGB, _ b: ThemeRGB) -> Bool { + abs(a.red - b.red) < 0.002 && abs(a.green - b.green) < 0.002 && abs(a.blue - b.blue) < 0.002 + } - /// Waits for the window to follow a settings change (the main pane observes the settings). - func settle(_ root: WindowRootView, until condition: () -> Bool) async { - for _ in 0..<150 where !condition() { - try? await Task.sleep(for: .milliseconds(10)) - root.layoutSubtreeIfNeeded() + /// Waits for the window to follow a settings change (the main pane observes the settings). + func settle(_ root: WindowRootView, until condition: () -> Bool) async { + for _ in 0..<150 where !condition() { + try? await Task.sleep(for: .milliseconds(10)) + root.layoutSubtreeIfNeeded() + } } - } - @Test func withPaneOutlinesTheSidebarDrawsTheMainPanesSurface() async { - defer { load(.object([:])) } - load(outlined()) - let (window, root) = openRoot() - defer { window.close() } - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) - } + @Test func withPaneOutlinesTheSidebarDrawsTheMainPanesSurface() async { + defer { load(.object([:])) } + load(outlined()) + let (window, root) = openRoot() + defer { window.close() } + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) + } - @Test func separateKeepsTheOldShadedSidebar() async { - defer { load(.object([:])) } - load(appearance(["sidebarSurface": .string("separate")])) - let (window, root) = openRoot() - defer { window.close() } - await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } - #expect(same(sidebarBackground(root), separateSidebar(root))) - #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) - } + @Test func separateKeepsTheOldShadedSidebar() async { + defer { load(.object([:])) } + load(appearance(["sidebarSurface": .string("separate")])) + let (window, root) = openRoot() + defer { window.close() } + await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } + #expect(same(sidebarBackground(root), separateSidebar(root))) + #expect(same(mainPaneBackground(root), root.themeTokens.surfaceBackground.withAlpha(1))) + } - @Test func switchingTheSettingRepaintsTheOpenWindow() async { - defer { load(.object([:])) } - load(outlined()) - let (window, root) = openRoot() - defer { window.close() } - - load(outlined(["sidebarSurface": .string("separate")])) - await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } - #expect(same(sidebarBackground(root), separateSidebar(root))) - - load(outlined(["sidebarSurface": .string("same")])) - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - - // Removing the key from the file returns to the default. - load(outlined(["sidebarSurface": .string("separate")])) - await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } - load(outlined()) - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - } + @Test func switchingTheSettingRepaintsTheOpenWindow() async { + defer { load(.object([:])) } + load(outlined()) + let (window, root) = openRoot() + defer { window.close() } + + load(outlined(["sidebarSurface": .string("separate")])) + await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } + #expect(same(sidebarBackground(root), separateSidebar(root))) + + load(outlined(["sidebarSurface": .string("same")])) + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + + // Removing the key from the file returns to the default. + load(outlined(["sidebarSurface": .string("separate")])) + await settle(root) { same(sidebarBackground(root), separateSidebar(root)) } + load(outlined()) + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + } - @Test func aBadValueKeepsTheDefaultAndSaysWhy() async { - defer { load(.object([:])) } - let diagnostics = load(outlined(["sidebarSurface": .string("purple")])) - #expect(diagnostics.contains { $0.path == "appearance.sidebarSurface" && $0.kind == .invalidValue }) - let (window, root) = openRoot() - defer { window.close() } - await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } - #expect(same(sidebarBackground(root), mainPaneBackground(root))) - } + @Test func aBadValueKeepsTheDefaultAndSaysWhy() async { + defer { load(.object([:])) } + let diagnostics = load(outlined(["sidebarSurface": .string("purple")])) + #expect(diagnostics.contains { $0.path == "appearance.sidebarSurface" && $0.kind == .invalidValue }) + let (window, root) = openRoot() + defer { window.close() } + await settle(root) { same(sidebarBackground(root), mainPaneBackground(root)) } + #expect(same(sidebarBackground(root), mainPaneBackground(root))) + } - /// With `appearance.borders` none no pane outline or edge line parts the sidebar from the - /// main pane, so the shared surface keeps a faint tonal step there: darker than the pane, - /// lighter than the separate look. - @Test func withNoLinesTheSidebarKeepsAFaintStep() async { - defer { load(.object([:])) } - load(appearance(["borders": .string("none")])) - let (window, root) = openRoot() - defer { window.close() } - let pane = root.themeTokens.surfaceBackground.withAlpha(1) - func luminance(_ color: ThemeRGB) -> Double { color.relativeLuminance } - await settle(root) { - let sidebar = luminance(sidebarBackground(root)) - return sidebar < luminance(pane) && sidebar > luminance(separateSidebar(root)) - } - #expect(luminance(sidebarBackground(root)) < luminance(pane)) - #expect(luminance(sidebarBackground(root)) > luminance(separateSidebar(root))) - #expect(same(mainPaneBackground(root), pane)) - } + /// With `appearance.borders` none no pane outline or edge line parts the sidebar from the + /// main pane, so the shared surface keeps a faint tonal step there: darker than the pane, + /// lighter than the separate look. + @Test func withNoLinesTheSidebarKeepsAFaintStep() async { + defer { load(.object([:])) } + load(appearance(["borders": .string("none")])) + let (window, root) = openRoot() + defer { window.close() } + let pane = root.themeTokens.surfaceBackground.withAlpha(1) + func luminance(_ color: ThemeRGB) -> Double { color.relativeLuminance } + await settle(root) { + let sidebar = luminance(sidebarBackground(root)) + return sidebar < luminance(pane) && sidebar > luminance(separateSidebar(root)) + } + #expect(luminance(sidebarBackground(root)) < luminance(pane)) + #expect(luminance(sidebarBackground(root)) > luminance(separateSidebar(root))) + #expect(same(mainPaneBackground(root), pane)) + } - /// The default inside the curved main pane: panes are flush and divided by one line, with no - /// outline beside the sidebar, so the shared surface keeps the faint step there. - @Test func byDefaultFlushPanesKeepAFaintStepBesideTheSidebar() async { - load(.object([:])) - let (window, root) = openRoot() - defer { window.close() } - let pane = root.themeTokens.surfaceBackground.withAlpha(1) - await settle(root) { sidebarBackground(root).relativeLuminance < pane.relativeLuminance } - #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) - #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) - #expect(same(mainPaneBackground(root), pane)) - } + /// The default inside the curved main pane: panes are flush and divided by one line, with no + /// outline beside the sidebar, so the shared surface keeps the faint step there. + @Test func byDefaultFlushPanesKeepAFaintStepBesideTheSidebar() async { + load(.object([:])) + let (window, root) = openRoot() + defer { window.close() } + let pane = root.themeTokens.surfaceBackground.withAlpha(1) + await settle(root) { sidebarBackground(root).relativeLuminance < pane.relativeLuminance } + #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) + #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) + #expect(same(mainPaneBackground(root), pane)) + } - /// Cards (like dividers and none) draw no pane outline, and the sidebar's edge line is off by - /// default, so nothing parts the shared surface either: the faint step stays. - @Test func withoutPaneOutlinesTheSidebarKeepsAFaintStep() async { - defer { load(.object([:])) } - load(.object(["layout": .object(["paneSeparation": .string("cards")])])) - let (window, root) = openRoot() - defer { window.close() } - let pane = root.themeTokens.surfaceBackground.withAlpha(1) - await settle(root) { - let sidebar = sidebarBackground(root).relativeLuminance - return sidebar < pane.relativeLuminance && sidebar > separateSidebar(root).relativeLuminance - } - #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) - #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) - #expect(same(mainPaneBackground(root), pane)) + /// Cards (like dividers and none) draw no pane outline, and the sidebar's edge line is off by + /// default, so nothing parts the shared surface either: the faint step stays. + @Test func withoutPaneOutlinesTheSidebarKeepsAFaintStep() async { + defer { load(.object([:])) } + load(.object(["layout": .object(["paneSeparation": .string("cards")])])) + let (window, root) = openRoot() + defer { window.close() } + let pane = root.themeTokens.surfaceBackground.withAlpha(1) + await settle(root) { + let sidebar = sidebarBackground(root).relativeLuminance + return sidebar < pane.relativeLuminance && sidebar > separateSidebar(root).relativeLuminance + } + #expect(sidebarBackground(root).relativeLuminance < pane.relativeLuminance) + #expect(sidebarBackground(root).relativeLuminance > separateSidebar(root).relativeLuminance) + #expect(same(mainPaneBackground(root), pane)) + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabSearchClosedOnceTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabSearchClosedOnceTests.swift new file mode 100644 index 000000000000..df4a472ffd65 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabSearchClosedOnceTests.swift @@ -0,0 +1,53 @@ +@testable import CmuxNextApp +import CmuxNextDaemon +import Foundation +import Testing + +/// Search Tabs' closed section lists a page closed again once, at its newest close, as Recently +/// Closed does, and Forget on that row deletes every close of the page, so no older one takes its +/// place. A terminal carries nothing that tells one close from another: each close stays its +/// own row, and Forget deletes only it. A page on another machine is another page. +@MainActor +struct TabSearchClosedOnceTests { + private typealias Closed = (item: CmuxNextDaemon.ClosedItem, machine: String) + + private static func browser(_ url: String) -> String { + """ + {"kind":"browser","name":null,"cwd":null,"url":"\(url)","browser_profile_id":null,"pinned":false} + """ + } + + private static let terminal = """ + {"kind":"terminal","name":null,"cwd":"/Users/you","url":null,"browser_profile_id":null,"pinned":false} + """ + + /// One closed tab item as `closed.list` lists it, closed at `at`. + private static func closed(_ id: String, at: Int, _ tab: String, machine: String = "local") throws -> Closed { + let json = """ + {"id":"\(id)","kind":"tab","name":null,"workspace_id":null,"pane_id":null,"index":0,"closed_at_ms":\(at), + "screens":[{"name":null,"tabs":[\(tab)]}],"window":null,"member_count":1,"members":[]} + """ + return (try JSONDecoder().decode(CmuxNextDaemon.ClosedItem.self, from: Data(json.utf8)), machine) + } + + @Test func aPageClosedAgainIsListedOnceAndForgottenWhole() throws { + let newestFirst = try [ + Self.closed("cmux", at: 6, Self.browser("https://cmux.com/")), + Self.closed("example-3", at: 5, Self.browser("https://example.com/")), + Self.closed("shell-2", at: 4, Self.terminal), + Self.closed("example-other-mac", at: 3, Self.browser("https://example.com/"), machine: "studio"), + Self.closed("example-2", at: 2, Self.browser("https://example.com/")), + Self.closed("shell-1", at: 1, Self.terminal), + Self.closed("example-1", at: 0, Self.browser("https://example.com/")), + ] + let listed = TabSearchFactsBuilder.listed(newestFirst) { $0 } + #expect(listed.map(\.item.id) == ["cmux", "example-3", "shell-2", "example-other-mac", "shell-1"]) + + let forgotten = { (id: String) in + let target = try #require(newestFirst.first { $0.item.id == id }) + return TabSearchFactsBuilder.forgotten(target, among: newestFirst) { $0 }.map(\.item.id).sorted() + } + #expect(try forgotten("example-3") == ["example-1", "example-2", "example-3"]) + #expect(try forgotten("shell-2") == ["shell-2"]) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripActivationRecolorTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripActivationRecolorTests.swift new file mode 100644 index 000000000000..2411c7b257ea --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/TabStripActivationRecolorTests.swift @@ -0,0 +1,47 @@ +import AppKit +@testable import CmuxNextTabs +import Testing + +/// A window becoming or leaving main changes one thing in its tab strip: whether the selected +/// tab shows its fill. Every tab of every strip used to re-resolve its theme and recolor itself +/// on each activation (where a DEV build's 10 s watchdog hang was sampled, cosmic-gray +/// 2026-10-14), so activation repaints the selected tab only, however many tabs the strip holds. +@MainActor @Suite(.serialized) struct TabStripActivationRecolorTests { + static let tabs = 300 + + /// Marks every unselected tab's title with a color no theme draws: a tab that repaints + /// replaces it. + static let sentinel = CGColor(srgbRed: 1, green: 0, blue: 1, alpha: 1) + + @Test func activationRepaintsOnlyTheSelectedTab() throws { + let model = TabStripModel(tabs: (0.. AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } - } +extension MainPaneGutterSuites { + /// One surface edge (Leo 2026-10-13, the New Tab page on cosmic-gray): a webview pane showed the + /// curved main pane's rounded card and, a few points inside it, a second faint rounded box: the + /// pane's own content area, clipped to the pane corner radius and outlined by the pane border. + /// The page itself draws no frame. Between a webview and the window exactly one layer draws an + /// edge, the main pane card, as for any other pane. + /// + /// One outline at the window edge (Leo 2026-10-13, an agent chat pane over wallpaper art): at the + /// window's top-right corner the window's own rounded edge showed and, a gutter inside it, a second + /// rounded outline: the main pane card stood off the window edge, and the chrome shade over that + /// gutter stepped darker than the card over the art. The window's frame is an edge too: with the + /// flush opt-in (Debug Settings gutter 0) the main pane takes the window's own edge and corners + /// where it meets the window, so one outline shows. The default keeps today's 6 pt gutter. + @MainActor @Suite(.serialized) struct WebviewPaneOneEdgeTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } + } - /// Hands the layout one pane: the app's pane view (tab strip and content host) showing `content`. - final class Provider: LayoutPaneContentProvider { - let content: NSView - private(set) var pane: PaneContentView? + /// Hands the layout one pane: the app's pane view (tab strip and content host) showing `content`. + final class Provider: LayoutPaneContentProvider { + let content: NSView + private(set) var pane: PaneContentView? - init(content: NSView) { self.content = content } + init(content: NSView) { self.content = content } - func makeContentView(for pane: PaneID) -> NSView { - let view = PaneContentView(stripModel: TabStripModel(tabs: [TabItem(id: TabID("tab"), title: "New Tab")]), - reveal: LaunchReveal(deadline: .zero)) - view.show(content) - self.pane = view - return view + func makeContentView(for pane: PaneID) -> NSView { + let view = PaneContentView(stripModel: TabStripModel(tabs: [TabItem(id: TabID("tab"), title: "New Tab")]), + reveal: LaunchReveal(deadline: .zero)) + view.show(content) + self.pane = view + return view + } } - } - /// A window as the app builds it: the sidebar, the curved main pane card, and one workspace - /// whose single pane shows `content`. - final class Rig { - let window: NSWindow - let root: WindowRootView - let layout: LayoutRootView - let provider: Provider - - init(content: NSView) { - _ = NSApplication.shared - provider = Provider(content: content) - let model = LayoutModel(screens: [LayoutScreen(id: "s", name: "s", layout: .splits(.leaf("p1")))], - activeScreenID: "s", focusedPane: "p1") - layout = LayoutRootView(model: model, contentProvider: provider) - root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel())) - window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), - styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], - backing: .buffered, defer: false) - window.isReleasedWhenClosed = false - window.titlebarAppearsTransparent = true - window.contentView = root - root.show(layout) - for _ in 0..<2 { - root.needsLayout = true - root.layoutSubtreeIfNeeded() + /// A window as the app builds it: the sidebar, the curved main pane card, and one workspace + /// whose single pane shows `content`. + final class Rig { + let window: NSWindow + let root: WindowRootView + let layout: LayoutRootView + let provider: Provider + + /// The window's room theme, with `art` as its wallpaper when given. + let room = ThemeScope(level: .room) + + init(content: NSView, art: BackdropArt? = nil, sidebarSide: SidebarSide = .left, sidebarHidden: Bool = false) { + _ = NSApplication.shared + provider = Provider(content: content) + let model = LayoutModel(screens: [LayoutScreen(id: "s", name: "s", layout: .splits(.leaf("p1")))], + activeScreenID: "s", focusedPane: "p1") + layout = LayoutRootView(model: model, contentProvider: provider) + let sidebarModel = SidebarModel() + if sidebarHidden { sidebarModel.presentation = .hidden } + root = WindowRootView(sidebar: SidebarContainerView(model: sidebarModel)) + window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 1000, height: 640), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.titlebarAppearsTransparent = true + if let art { room.pinBackdrop(.selection(.art(art))) } + room.root(root) + room.adopt(window) + window.contentView = root + root.sidebarSide = sidebarSide + if sidebarHidden { + root.sidebar.isHidden = true + root.sidebarHidden = true + } + root.themeDidChange() + root.show(layout) + for _ in 0..<2 { + root.needsLayout = true + root.layoutSubtreeIfNeeded() + } } - } - func tearDown() { - window.orderOut(nil) - window.close() - } + func tearDown() { + window.orderOut(nil) + window.close() + } - /// Every layer between `content` (itself included) and the window that draws a surface - /// edge: a visible border, or a corner radius that clips. The pane border lives in the - /// layout's overlay plane, not in the ancestor chain, so it is added when it shows. - func edges(of content: NSView) -> [String] { - var found: [String] = [] - var view: NSView? = content - while let current = view { - if let layer = current.layer, !current.isHidden { - if layer.borderWidth > 0 && layer.opacity > 0 { - found.append("\(type(of: current)) border \(layer.borderWidth)") - } else if layer.cornerRadius > 0 && layer.masksToBounds { - found.append("\(type(of: current)) radius \(layer.cornerRadius)") + /// Every layer between `content` (itself included) and the window that draws a surface + /// edge: a visible border, or a corner radius that clips. The pane border lives in the + /// layout's overlay plane, not in the ancestor chain, so it is added when it shows. + func edges(of content: NSView) -> [String] { + var found: [String] = [] + var view: NSView? = content + while let current = view { + if let layer = current.layer, !current.isHidden { + if layer.borderWidth > 0 && layer.opacity > 0 { + found.append("\(type(of: current)) border \(layer.borderWidth)") + } else if layer.cornerRadius > 0 && layer.masksToBounds { + found.append("\(type(of: current)) radius \(layer.cornerRadius)") + } } + view = current.superview + } + let host = layout.screenViews["s"]?.displayedHosts.first + if host?.chrome.showsBorder == true || layout.model.style.showsPaneBorder { + found.append("PaneOverlayView border") } - view = current.superview + return found } - let host = layout.screenViews["s"]?.displayedHosts.first - if host?.chrome.showsBorder == true || layout.model.style.showsPaneBorder { - found.append("PaneOverlayView border") + + /// The window edges the main pane meets: every edge but the sidebar's while it shows. + var windowEdges: [NSRectEdge] { + let all: [NSRectEdge] = [.maxY, .maxX, .minY, .minX] + if root.sidebarHidden { return all } + return all.filter { $0 != (root.sidebarSide == .left ? .minX : .maxX) } + } + + /// Every rounded outline where the main pane meets the window's `edge`, the window's own + /// frame included: a card standing off the edge (a gutter step), a stroke on the card, a + /// card corner that is not the window's, and a chrome shade band along the edge. + func outlines(at edge: NSRectEdge) -> [String] { + let windowRadius = CEFClipShape.windowCornerRadius(window) + var found = ["window frame radius \(windowRadius)"] + let bounds = root.bounds, card = root.contentHost.frame + let gap: CGFloat = switch edge { + case .maxY: bounds.maxY - card.maxY + case .maxX: bounds.maxX - card.maxX + case .minY: card.minY - bounds.minY + default: card.minX - bounds.minX + } + if gap > 0.25 { found.append("card \(gap) pt inside the window edge") } + if let layer = root.contentHost.layer { + if layer.borderWidth > 0 { found.append("card border \(layer.borderWidth)") } + if layer.masksToBounds, layer.cornerRadius > 0, abs(layer.cornerRadius - windowRadius) > 0.25 { + found.append("card corner \(layer.cornerRadius) inside the window's \(windowRadius)") + } + } + // Half a point inside the window edge, along the pane's middle. + let probe: CGPoint = switch edge { + case .maxY: CGPoint(x: card.midX, y: bounds.maxY - 0.5) + case .maxX: CGPoint(x: bounds.maxX - 0.5, y: card.midY) + case .minY: CGPoint(x: card.midX, y: bounds.minY + 0.5) + default: CGPoint(x: bounds.minX + 0.5, y: card.midY) + } + let shade = root.mainPaneShade.layer?.sublayers?.lazy.compactMap { $0 as? CAShapeLayer }.first + if let shade, let path = shade.path, path.contains(probe, using: .evenOdd), + let fill = shade.fillColor, fill.alpha > 0 { + found.append("chrome shade band \(fill.alpha)") + } + return found } - return found } - } - /// The single edge is the main pane card's rounded clip. - private func expectOneEdge(_ rig: Rig, content: NSView, _ what: String) throws { - try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") - try #require(content.isDescendant(of: rig.root.contentHost), "\(what) is hosted in the main pane") - let edges = rig.edges(of: content) - #expect(edges.count == 1, "\(what): one surface edge between the page and the window, got \(edges)") - #expect(edges.first?.hasPrefix("MainPaneCardView") == true, "\(what): the edge is the main pane card's, got \(edges)") - #expect((rig.root.contentHost.layer?.cornerRadius ?? 0) > 0, "\(what): the card is rounded") - } + /// The single edge is the main pane card's rounded clip. + private func expectOneEdge(_ rig: Rig, content: NSView, _ what: String) throws { + try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") + try #require(content.isDescendant(of: rig.root.contentHost), "\(what) is hosted in the main pane") + let edges = rig.edges(of: content) + #expect(edges.count == 1, "\(what): one surface edge between the page and the window, got \(edges)") + #expect(edges.first?.hasPrefix("MainPaneCardView") == true, "\(what): the edge is the main pane card's, got \(edges)") + #expect((rig.root.contentHost.layer?.cornerRadius ?? 0) > 0, "\(what): the card is rounded") + } - private static func page(_ html: String) throws -> URL { - let directory = FileManager.default.temporaryDirectory.appending(path: "one-edge-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let file = directory.appending(path: "index.html") - try Data(html.utf8).write(to: file) - return file - } + private static func page(_ html: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory.appending(path: "one-edge-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appending(path: "index.html") + try Data(html.utf8).write(to: file) + return file + } - @Test func theNewTabPageHasOneSurfaceEdge() throws { - let file = try Self.page("") - defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } - let model = AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)) - let newTab = try #require(AgentPaneView(model: model, source: .bundled(file))) - let rig = Rig(content: newTab) - defer { rig.tearDown() } - try expectOneEdge(rig, content: newTab, "New Tab") - } + @Test func theNewTabPageHasOneSurfaceEdge() throws { + let file = try Self.page("") + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + let model = AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)) + let newTab = try #require(AgentPaneView(model: model, source: .bundled(file))) + let rig = Rig(content: newTab) + defer { rig.tearDown() } + try expectOneEdge(rig, content: newTab, "New Tab") + } - /// A first-party page pane (History, Settings, App Store, Activity) is a `PageWebView`. - @Test func aPagePaneHasOneSurfaceEdge() throws { - let file = try Self.page("") - defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } - let descriptor = PageDescriptor(id: "test.one-edge", resource: "one-edge", namespaces: []) - let page = try #require(PageWebView(descriptor: descriptor, root: file.deletingLastPathComponent(), routes: [])) - let rig = Rig(content: page) - defer { rig.tearDown() } - try expectOneEdge(rig, content: page, "page pane") - } + /// A first-party page pane (History, Settings, App Store, Activity) is a `PageWebView`. + @Test func aPagePaneHasOneSurfaceEdge() throws { + let file = try Self.page("") + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + let descriptor = PageDescriptor(id: "test.one-edge", resource: "one-edge", namespaces: []) + let page = try #require(PageWebView(descriptor: descriptor, root: file.deletingLastPathComponent(), routes: [])) + let rig = Rig(content: page) + defer { rig.tearDown() } + try expectOneEdge(rig, content: page, "page pane") + } + + /// A webview pane's edges are any other pane's: a plain view (a terminal's place) gets the same one. + @Test func aWebviewPaneHasTheEdgesOfAnyOtherPane() throws { + let file = try Self.page("") + defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } + let newTab = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)), + source: .bundled(file))) + let web = Rig(content: newTab) + defer { web.tearDown() } + let plain = NSView() + let other = Rig(content: plain) + defer { other.tearDown() } + #expect(web.edges(of: newTab) == other.edges(of: plain)) + } - /// A webview pane's edges are any other pane's: a plain view (a terminal's place) gets the same one. - @Test func aWebviewPaneHasTheEdgesOfAnyOtherPane() throws { - let file = try Self.page("") - defer { try? FileManager.default.removeItem(at: file.deletingLastPathComponent()) } - let newTab = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost(), newTab: AgentPaneNewTab(kind: .agent)), - source: .bundled(file))) - let web = Rig(content: newTab) - defer { web.tearDown() } - let plain = NSView() - let other = Rig(content: plain) - defer { other.tearDown() } - #expect(web.edges(of: newTab) == other.edges(of: plain)) + /// With the Debug Settings flush opt-in (gutter 0), where the main pane meets the window (top, + /// trailing, bottom, and the leading edge with the sidebar hidden) exactly one rounded outline + /// shows, the window's own, over wallpaper art and on an opaque theme, with the sidebar on + /// either side or hidden. + @Test(arguments: [BackdropArt?.some(.goyaSaturn), nil]) + func whereThePaneMeetsTheWindowOneOutlineShows(art: BackdropArt?) throws { + try #require(Metrics.mainPaneCurved, "the curved main pane is on by default") + try MainPaneGutterSuites.withGutter(0) { + for (side, hidden) in [(SidebarSide.left, false), (.right, false), (.left, true)] { + let rig = Rig(content: NSView(), art: art, sidebarSide: side, sidebarHidden: hidden) + defer { rig.tearDown() } + if art != nil { try #require(rig.root.backdrop.art != nil, "the window shows wallpaper art") } + let what = "\(art.map { "art \($0.rawValue)" } ?? "opaque"), sidebar \(hidden ? "hidden" : side.rawValue)" + for edge in rig.windowEdges { + let outlines = rig.outlines(at: edge) + #expect(outlines.count == 1, "\(what), window edge \(edge.rawValue): one outline, got \(outlines)") + } + } + } + } } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/CEFProfileStorageLogTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/CEFProfileStorageLogTests.swift new file mode 100644 index 000000000000..4c6b37690451 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextBrowserTests/CEFProfileStorageLogTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import CmuxNextBrowser + +/// `cef.previous.log` keeps the run before this one's Chromium log, so a +/// crash's FATAL line survives the relaunch (crash-elimination.md section 4). +@Suite struct CEFProfileStorageLogTests { + private func storage() throws -> CEFProfileStorage { + let root = FileManager.default.temporaryDirectory.appending(path: "cef-log-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return CEFProfileStorage(root: root) + } + + @Test func theLogMovesAsidePreviousReplaced() throws { + let storage = try storage() + defer { try? FileManager.default.removeItem(at: storage.root) } + try Data("older run\n".utf8).write(to: storage.previousLogFile) + try Data("FATAL: last run\n".utf8).write(to: storage.logFile) + #expect(storage.keepPreviousLog()) + #expect(!FileManager.default.fileExists(atPath: storage.logFile.path)) + #expect(try String(contentsOf: storage.previousLogFile, encoding: .utf8) == "FATAL: last run\n") + } + + /// A first launch has no log; an earlier run's previous log stays. + @Test func noLogKeepsThePreviousOne() throws { + let storage = try storage() + defer { try? FileManager.default.removeItem(at: storage.root) } + try Data("older run\n".utf8).write(to: storage.previousLogFile) + #expect(!storage.keepPreviousLog()) + #expect(try String(contentsOf: storage.previousLogFile, encoding: .utf8) == "older run\n") + } + + @Test func bothLogsSitInTheRoot() { + let storage = CEFProfileStorage(root: URL(filePath: "/tmp/Chromium")) + #expect(storage.logFile.path == "/tmp/Chromium/cef.log") + #expect(storage.previousLogFile.path == "/tmp/Chromium/cef.previous.log") + } +} diff --git a/Resources/opencode-plugin.js b/Resources/opencode-plugin.js index 2d00ad80cd31..a476d2a30ae0 100644 --- a/Resources/opencode-plugin.js +++ b/Resources/opencode-plugin.js @@ -12,6 +12,17 @@ const DEFAULT_SOCKET = `${os.homedir()}/.config/cmux/cmux.sock`; const REPLY_TIMEOUT_MS = 120_000; const MAX_PLAN_BYTES = 128 * 1024; +// OpenCode publishes these through session.error without ending the turn: a +// user interrupt (Esc) then goes idle, and a context overflow compacts and +// continues. The idle stop that follows is the turn's outcome. +const NON_TERMINAL_SESSION_ERRORS = new Set(["MessageAbortedError", "ContextOverflowError"]); + +const isNonTerminalSessionError = (event) => { + if (event?.type !== "session.error") return false; + const props = event.properties || event.data || {}; + return NON_TERMINAL_SESSION_ERRORS.has(props.error?.name); +}; + const createCMUXFeed = async (ctx, options = {}) => { const environment = { ...process.env, ...options.environment }; const socketPath = environment.CMUX_SOCKET_PATH || DEFAULT_SOCKET; @@ -84,6 +95,52 @@ const createCMUXFeed = async (ctx, options = {}) => { return firstString(status.type, status.status, status.state)?.toLowerCase() === "idle"; }; + const sessionStatusType = (status) => { + if (typeof status === "string") return status.trim().toLowerCase(); + if (!isObject(status)) return null; + return firstString(status.type, status.status, status.state)?.toLowerCase() || null; + }; + + // Mirrors AgentHookNotificationPolicy.redactSensitiveErrorDetail: Feed + // receives this text directly, so credentials are redacted here. Paths and + // model names stay readable because they are the useful part of the error. + const redactErrorDetail = (value) => { + if (!value) return value; + const redacted = [ + [/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, ""], + [/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/gi, ""], + [/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, "Bearer "], + [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gi, ""], + [/\b(?:x[-_])?(?:api[-_]?key|password|secret|token|authorization|cookie)\s*[:=]\s*(?:Bearer\s+)?(?:'[^']*'|"[^"]*"|[^\s'";&|,]+)/gi, ":"], + [/\b(?:sk|rk|sess)-[A-Za-z0-9._:-]{8,}\b/gi, ""], + ].reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), value); + return redacted.replace(/<[a-z]+>|[\s\p{P}]/gu, "") ? redacted : null; + }; + + const failureMessage = (event) => redactErrorDetail(rawFailureMessage(event)); + + const rawFailureMessage = (event) => { + const props = eventProperties(event); + const raw = props.error || props.failure || props.reason; + if (typeof raw === "string") return normalizeText(raw, 500); + if (isObject(raw)) { + const nestedData = isObject(raw.data) ? raw.data : {}; + const nested = firstString( + raw.message, + raw.error, + raw.reason, + raw.description, + nestedData.message, + nestedData.error, + nestedData.reason, + nestedData.description, + raw.name + ); + if (nested) return normalizeText(nested, 500); + } + return normalizeText(firstString(props.message, props.description), 500); + }; + const normalizeText = (value, max = 1000) => { if (typeof value !== "string") return null; const normalized = value.replace(/\s+/g, " ").trim(); @@ -98,11 +155,25 @@ const createCMUXFeed = async (ctx, options = {}) => { lastUserMessage: null, assistantPreamble: null, cwd: null, + lastStopKind: null, }); } return sessions.get(key); }; + // A turn publishes exactly one terminal stop. Only a run signal (session + // creation, busy status, a promoted prompt or an execution start) opens the + // next turn. Message updates and queued prompts do not: a queued prompt's + // message arrives while the previous turn is still running, and a repeated + // update after a stop must not reopen a finished turn. + const beginTurn = (sessionId) => { + sessionState(sessionId).lastStopKind = null; + }; + + // OpenCode keeps an execution interrupted by shutdown and resumes it. + const isResumableInterruption = (event) => + event?.type === "session.execution.interrupted" && eventProperties(event).reason === "shutdown"; + const contextForSession = (sessionId) => { const state = sessionState(sessionId); const context = {}; @@ -711,6 +782,15 @@ const createCMUXFeed = async (ctx, options = {}) => { return null; }; + const shouldEmitStop = (sessionId, kind) => { + // The first terminal event settles the turn. A published stop cannot be + // reclassified, so a later failure or idle for the same turn is dropped. + const state = sessionState(sessionId); + if (state.lastStopKind) return false; + state.lastStopKind = kind; + return true; + }; + const pushBlocking = (event, requestId) => { const reply = new Promise((resolve) => { let timeout; @@ -767,6 +847,7 @@ const createCMUXFeed = async (ctx, options = {}) => { const props = eventProperties(event); const info = props.info || {}; const sid = sessionIdFromProperties(props) || "unknown"; + beginTurn(sid); const state = sessionState(sid); state.cwd = info.directory || props.location?.directory || event.location?.directory || ctx?.location?.directory || ctx?.directory || state.cwd; pushTelemetry(base(sid, { @@ -777,19 +858,38 @@ const createCMUXFeed = async (ctx, options = {}) => { } case "session.status": { const props = eventProperties(event); - if (!sessionStatusIsIdle(props.status)) break; const sid = sessionIdFromProperties(props); - if (!sid) break; + if (sid && sessionStatusType(props.status) === "busy") { + beginTurn(sid); + break; + } + if (!sessionStatusIsIdle(props.status)) break; + if (!sid || !shouldEmitStop(sid, "success")) break; pushTelemetry(base(sid, { hook_event_name: "Stop", })); break; } case "session.execution.succeeded": - case "session.execution.failed": case "session.execution.interrupted": { const sid = sessionIdFromEvent(event); - if (sid) pushTelemetry(base(sid, { hook_event_name: "Stop" })); + if (isResumableInterruption(event)) break; + if (sid && shouldEmitStop(sid, "success")) pushTelemetry(base(sid, { hook_event_name: "Stop" })); + break; + } + case "session.execution.failed": + case "session.next.step.failed": + case "session.error": { + if (isNonTerminalSessionError(event)) break; + const sid = sessionIdFromEvent(event); + if (sid && shouldEmitStop(sid, "failure")) { + const error = failureMessage(event); + pushTelemetry(base(sid, { + hook_event_name: "Stop", + is_error: true, + ...(error ? { error } : {}), + })); + } break; } case "session.updated": { @@ -798,19 +898,25 @@ const createCMUXFeed = async (ctx, options = {}) => { if (eventProperties(event).info?.time?.archived) { pushTelemetry(base(sid, { hook_event_name: "SessionEnd" })); sessions.delete(sid); - } else { - pushTelemetry(base(sid, { hook_event_name: "SessionStart" })); } break; } case "session.idle": { const sid = sessionIdFromProperties(eventProperties(event)); - if (!sid) break; + if (!sid || !shouldEmitStop(sid, "success")) break; pushTelemetry(base(sid, { hook_event_name: "Stop", })); break; } + // Admission queues a prompt behind the running turn; promotion and + // execution start are when it actually runs. + case "session.next.prompted": + case "session.execution.started": { + const sid = sessionIdFromEvent(event); + if (sid) beginTurn(sid); + break; + } case "session.deleted": { const sid = sessionIdFromProperties(eventProperties(event)); if (!sid) break; @@ -999,10 +1105,23 @@ const createCMUXFeed = async (ctx, options = {}) => { export const CMUXFeed = createCMUXFeed; +const CMUX_FEED_SERVER_SCOPES_KEY = Symbol.for("cmux.feed.server.scopes"); + +const isTUIProcessServerContext = (ctx) => + Boolean(ctx && typeof ctx === "object" && ctx.client && ctx.serverUrl && ctx.directory); + export default { id: "cmux.server", - // V2 uses the package's ./tui export; never subscribe in the shared service. - server() { return {}; }, + async server(ctx) { + if (!isTUIProcessServerContext(ctx)) return {}; + // cmux-feed.js and plugins/cmux/index.js both load this source, and every + // loader in one plugin scope receives the same ctx. OpenCode keeps one + // scope per directory, and disposes a scope's hooks when it tears it down. + const scopes = globalThis[CMUX_FEED_SERVER_SCOPES_KEY] ||= new WeakSet(); + if (scopes.has(ctx)) return {}; + scopes.add(ctx); + return createCMUXFeed(ctx); + }, // Compatibility alias for older V2 snapshots that called setup(). setup() { return () => {}; }, }; diff --git a/cmux-tui/Cargo.lock b/cmux-tui/Cargo.lock index 61e8186ad224..f4d1736cb327 100644 --- a/cmux-tui/Cargo.lock +++ b/cmux-tui/Cargo.lock @@ -29,6 +29,7 @@ dependencies = [ "regex", "reqwest", "rustls", + "schemars", "serde", "serde_json", "sha2 0.10.9", @@ -947,6 +948,13 @@ dependencies = [ "url", ] +[[package]] +name = "cmux-build-identity" +version = "0.1.0" +dependencies = [ + "cmux-tui-source-watch", +] + [[package]] name = "cmux-chat-index" version = "0.1.0" @@ -1275,12 +1283,12 @@ dependencies = [ "axum", "base64", "bytes", + "cmux-build-identity", "cmux-link", "cmux-pty", "cmux-relay", "cmux-remote-protocol", "cmux-tui-core", - "cmux-tui-source-watch", "cmux-unix-socket", "cmux-wg", "diffy", @@ -1467,6 +1475,7 @@ dependencies = [ "base64", "bytes", "cmux-agent-pane-policy", + "cmux-build-identity", "cmux-host", "cmux-link", "cmux-pty", diff --git a/cmux-tui/Cargo.toml b/cmux-tui/Cargo.toml index 899379699e6b..e08191319b94 100644 --- a/cmux-tui/Cargo.toml +++ b/cmux-tui/Cargo.toml @@ -43,6 +43,7 @@ members = [ "crates/cmux-unix-socket", "crates/cmux-pty", "crates/cmux-remote-protocol", + "crates/cmux-build-identity", "crates/cmux-remote", "crates/cmux-terminal-client", "crates/cmux-relay", @@ -103,6 +104,7 @@ default-members = [ "crates/cmux-focus-core", "crates/cmux-pty", "crates/cmux-remote-protocol", + "crates/cmux-build-identity", "crates/cmux-remote", "crates/cmux-relay", "crates/chatmux-relay", @@ -182,6 +184,7 @@ cmux-tui-hooks = { path = "crates/cmux-tui-hooks" } cmux-tui-util = { path = "crates/cmux-tui-util" } cmux-tui-remote-access = { path = "crates/cmux-tui-remote-access" } cmux-link = { path = "crates/cmux-link" } +cmux-build-identity = { path = "crates/cmux-build-identity" } cmux-remote = { path = "crates/cmux-remote", default-features = false, features = ["wireguard-transport"] } cmux-wg = { path = "crates/cmux-wg" } cmux-server = { path = "crates/cmux-server" } diff --git a/cmux-tui/bindings/cpp/.cmux-resource-api.json b/cmux-tui/bindings/cpp/.cmux-resource-api.json index 47e8c9613d53..b52ec7c5ff84 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": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/go/.cmux-resource-api.json b/cmux-tui/bindings/go/.cmux-resource-api.json index 47e8c9613d53..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/go/.cmux-resource-api.json +++ b/cmux-tui/bindings/go/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/java/.cmux-resource-api.json b/cmux-tui/bindings/java/.cmux-resource-api.json index 47e8c9613d53..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/java/.cmux-resource-api.json +++ b/cmux-tui/bindings/java/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/python/.cmux-resource-api.json b/cmux-tui/bindings/python/.cmux-resource-api.json index 47e8c9613d53..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/python/.cmux-resource-api.json +++ b/cmux-tui/bindings/python/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/rust/.cmux-resource-api.json b/cmux-tui/bindings/rust/.cmux-resource-api.json index 47e8c9613d53..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/rust/.cmux-resource-api.json +++ b/cmux-tui/bindings/rust/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/typescript/.cmux-resource-api.json b/cmux-tui/bindings/typescript/.cmux-resource-api.json index 47e8c9613d53..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/typescript/.cmux-resource-api.json +++ b/cmux-tui/bindings/typescript/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/bindings/zig/.cmux-resource-api.json b/cmux-tui/bindings/zig/.cmux-resource-api.json index 47e8c9613d53..b52ec7c5ff84 100644 --- a/cmux-tui/bindings/zig/.cmux-resource-api.json +++ b/cmux-tui/bindings/zig/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "0218d7cb3429e0aeb9093ada4184d045f5c97fb4a13afd506f31c11f61d8615a", + "catalog_sha256": "6067497d3ba2844a13049c7797bcfd4a8e00aa95f055ebf1dc41a39c54bd05dd", "operations": { "agent.list": { "class": "read" @@ -88,6 +88,9 @@ "chief.engine.set": { "class": "mutation" }, + "chief.profile.adopt": { + "class": "mutation" + }, "chief.profile.avatar.get": { "class": "read" }, diff --git a/cmux-tui/build-support/source-watch.rs b/cmux-tui/build-support/source-watch.rs index f3664feda29f..6337393c1d53 100644 --- a/cmux-tui/build-support/source-watch.rs +++ b/cmux-tui/build-support/source-watch.rs @@ -1,4 +1,4 @@ -//! Cargo-only dependency that invalidates cmux-remote's build identity when a +//! Cargo-only dependency that invalidates cmux-build-identity's build identity when a //! new workspace-root source input appears. #![forbid(unsafe_code)] diff --git a/cmux-tui/crates/acpmux/Cargo.toml b/cmux-tui/crates/acpmux/Cargo.toml index 6cde77493e97..a1af2b5b90c7 100644 --- a/cmux-tui/crates/acpmux/Cargo.toml +++ b/cmux-tui/crates/acpmux/Cargo.toml @@ -50,6 +50,8 @@ unicode-width = "0.2" wait-timeout.workspace = true zeroize.workspace = true regex = "1" +# The typed protocol registry (src/protocol): JSON Schema for spec/acpmux-protocol.json. +schemars = "1" # The model catalog fetch (src/catalog/fetch.rs): HTTPS only, through the # workspace's reqwest and ring rustls provider. reqwest.workspace = true diff --git a/cmux-tui/crates/acpmux/docs/acpmux-schema.json b/cmux-tui/crates/acpmux/docs/acpmux-schema.json index 07364f3a220d..5605e2142609 100644 --- a/cmux-tui/crates/acpmux/docs/acpmux-schema.json +++ b/cmux-tui/crates/acpmux/docs/acpmux-schema.json @@ -31,8 +31,8 @@ "_acpmux/route/test": {"params": {"id": "string"}, "result": {"id": "string", "status": "ok|auth_needed|unreachable|rate_limited|unknown", "message": "string (HTTP status and the start of the body, or the connect error; never a secret)", "ms": "number"}, "note": "One GET to healthUrl, else the model list. On demand only."}, "_acpmux/route/default.set": {"params": {"scope": "\"global\"|\"family:NAME\"|\"workspace:ID\"", "routeId": "string|null (null clears)"}, "result": {"scope": "string", "routeId": "string|null", "bindings": "object"}, "note": "Precedence at spawn: chat, workspace, family, global; none: the harness profile env decides."}, "_acpmux/chat/route.set": {"params": {"sessionId": "string", "routeId": "string|null (null: back to the inherited route)", "when": "\"after-turn\"|\"now\"? (default after-turn; only matters during a turn)"}, "result": {"routeId": "string|null", "applied": "now|after-turn|after-cancel"}, "errors": "data.reason route.not_found, route.invalid (another family), route.unavailable (local CodeRouter down): the chat keeps its route", "note": "Records mux event route_changed {from, to, when}. Idle: the harness restarts now on the new route and resumes the same agent session. The binding survives a daemon restart."}, - "_acpmux/defaults": {"params": {"family": "string?", "set": "SessionDefaults? (null field clears it)", "clear": "boolean?"}, "result": "{families: {family: {kind, profile, error, profiles, model, effort, policy, prefer, env}}, defaults} or one entry", "note": "session/new takes _meta.acpmux.harness (a family or profile: the head of -m HEAD/MODEL), .preset, .model, .effort, .policy, .name. Explicit values win over the preset, which wins over profile then family defaults."}, - "_acpmux/presets": {"params": {"name": "string?", "set": "{harness (family or profile), model?, effort?, policy?, env?, args? (string[]: argv words appended to the harness command, no shell; an allowlist: on Claude Code --tools \"\", --strict-mcp-config, --no-session-persistence, else none), systemPrompt? (string: a Claude Code system prompt, written to acpmux's own presets//system.md and checked against its sha256 at every session start), description?}? (null field clears it); a remote-origin (WebSocket) connection never sets or uses args or systemPrompt", "clear": "boolean?"}, "result": "{presets: [{name, harness, profile, error, model, effort, policy, env, args, systemPromptSha256, description}]} or one preset"}, + "_acpmux/defaults": {"params": {"family": "string?", "set": "SessionDefaults? (null field clears it)", "clear": "boolean?"}, "result": "{families: {family: {kind, profile, error, profiles, model, effort, policy, prefer, env}}, defaults} or one entry", "note": "session/new takes _meta.acpmux.harness (a family or profile: the head of -m HEAD/MODEL), .preset, .model, .effort, .policy, .name, .mode (the mode it starts in; one that does not ask needs the person, an approved preset, or a caller session in that mode: hub/person.rs). Explicit values win over the preset, which wins over profile then family defaults."}, + "_acpmux/presets": {"params": {"name": "string?", "set": "{harness (family or profile), model?, effort?, policy?, env?, args? (string[]: argv words appended to the harness command, no shell; an allowlist: on Claude Code --tools \"\", --strict-mcp-config, --no-session-persistence, else none), systemPrompt? (string: a Claude Code system prompt, written to acpmux's own presets//system.md and checked against its sha256 at every session start), description?, mode? (the mode a session from the preset starts in; one that does not ask is set by the person only)}? (null field clears it); a remote-origin (WebSocket) connection never sets or uses args or systemPrompt", "clear": "boolean?", "approveMode": "boolean? (true: the person approves the preset's mode for its current contents, so any client may start the preset in it until the preset changes; person only. false: withdraws it)"}, "result": "{presets: [{name, harness, profile, error, model, effort, policy, env, args, systemPromptSha256, description, mode, modeApproved}]} or one preset"}, "_acpmux/models_note": "params {refresh?: boolean} forgets reported catalogs and probes every ACP harness again before answering", "_acpmux/models": {"params": {}, "result": {"harnesses": [{"harness": "string", "kind": "acp|claude-stdio", "isDefault": "boolean", "models": [{"id": "string", "name": "string", "declared": "boolean? (from the profile)", "shortName": "string?", "family": "string?", "efforts": "string[]?", "defaultEffort": "string?", "fast": "boolean?", "contextWindow": "number? (these from a profile file or the catalog)", "curated": "boolean? (true: from the curated catalog, with its ref, shortName, provider, aliases, status and its models[ref] metadata: contextWindow, maxOutput, cost, input, reasoning, toolCall...; false: the harness reported it and the catalog does not list it)"}], "probeError": "string? (the last model probe failed: the harness may not start)", "catalog": "{id, name, brand, families, modelSource, docsUrl?, defaultModel?}? (the harness's curated catalog entry)"}], "catalog": "CatalogSummary"}}, "catalog.get": {"params": {}, "result": "CatalogSummary & {catalog: ModelCatalog (schemaVersion 1: {schemaVersion, generatedAt, source, harnesses, models, providers}; web/services/model-catalog/types.ts)}"}, @@ -102,7 +102,7 @@ "SessionDefaults": {"model": "string?", "effort": "string?", "policy": "string?", "prefer": "string[]? (profiles to use for a family, in order; first installed wins)", "env": "{key: value}? (${cwd}, ${home}, ${model}, ~/ expand)", "note": "keyed by family or profile"}, "HarnessProfile": {"kind": "acp|claude-stdio", "argv": "string[] (${model} = the requested model at spawn)", "env": "{key: value}", "family": "string? (derived from argv when absent)", "models": "(string | {id, name})[] declared catalog, shown ahead of the reported one", "model": "string?", "effort": "string?", "policy": "string?", "fallback": "string? (profile to move to on limit or auth errors)", "description": "string?"}, "SessionSummary": {"sessionId": "string", "name": "string", "harness": "string", "family": "string|null", "cwd": "string", "status": "idle|ready|running|waiting|disconnected|closed", "peer": "string? (host name for remote sessions)", "stateSeq": "number (bumps on status, permission and turn changes)", "unread": "boolean (a turn ended while no attached client showed the chat; cleared when one shows it)", "shown": "boolean (some attached client shows the chat: _acpmux/attach visible, _acpmux/view)", "indicator": "input|working|failed|lost|done|null (the status dot every client draws; first match wins: input = a permission or question waits, working = a turn runs, failed = the last turn failed, lost = the agent process is gone (disconnected or unreachable) and the last turn did not answer, done = the last turn answered (seen is client-side), null = nothing to show)", "lastTurn": "{turnId, promptId, status, stopReason?, errorText?, errorSource?, detail?, endedAt} | null", "lane": "{state: working|input|idle|failed|lost, since: number|null, error: string|null} | null (a coordinator's view of the session; null when closed: working since its turn started, input since it began waiting, idle since its last turn ended, failed with that turn's errorText, lost when the agent process or peer is gone)", "inputSince": "number|null (when the session began waiting on the person)", "attached": "number (clients attached now)", "pendingPermissions": "number", "queued": "number", "turnCount": "number", "lastSeq": "number", "lastPrompt": "string|null", "preview": "string|null", "model": "string|null", "currentModeId": "string|null", "policy": "string|null", "rules": "boolean", "tools": "ToolRow[] (the chat's tools: cmux's own, then what the harness reported)", "toolsMutable": "boolean (_acpmux/set_tools can turn its MCP servers off: Claude Code only)", "tags": "{key: value}", "createdAt": "number", "updatedAt": "number", "queue": "[{promptId, turnId, client, prompt, queuedAt}] prompts waiting for the running turn", "turn": "{startedAt, client, prompt, turnId, promptId, turnSeq} | null (the running turn)", "enforcement": "Enforcement"}, - "SessionDetail": "SessionSummary plus modes, configOptions, models, agentInfo, agentCapabilities, usage, pending: [{permissionId, request, groupId, turnId}] (groupId and turnId are string|null), rulesJson", + "SessionDetail": "SessionSummary plus modes, configOptions, models, agentInfo, agentCapabilities, usage, pending: [{permissionId, request, groupId, turnId}] (groupId and turnId are string|null), rulesJson, gitBranch: string|null and gitHead: string|null (the cwd's branch and full HEAD commit when the session started or forked; branch null on a detached HEAD, both null outside a repository)", "Process": {"id": "string (/)", "sessionId": "string", "subagentSessionId": "string|null (the subagent whose call started it)", "toolCallId": "string", "taskId": "string", "harness": "string (the session's harness family: claude)", "command": "string", "cwd": "string", "startedAt": "number (unix ms)", "endedAt": "number|null", "state": "running|exited|failed|killed|lost (lost: it ended with its harness, unseen)", "exitCode": "null (not reported yet)", "signal": "null", "reason": "person|agent|harness_exit|null (why it stopped)", "detail": "string|null (the harness's own words on its end)", "origin": "background|auto_backgrounded", "outputBytes": "null (not tracked yet)"}, "process_update": {"process": "Process", "note": "Logged when a process starts or ends; a daemon rebuilds the registry from these and the harness updates."}, "Event": {"sessionId": "string", "seq": "number", "at": "number (unix ms)", "dir": "in|out|mux|peer", "kind": "string (method, sessionUpdate value, or mux kind)", "msg": "object"}, diff --git a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json new file mode 100644 index 000000000000..3fde153d80a9 --- /dev/null +++ b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json @@ -0,0 +1,1934 @@ +{ + "methods": [ + { + "acp": true, + "kind": "request", + "name": "initialize", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "authenticate", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/new", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/load", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/resume", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/list", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/fork", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/close", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/delete", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/prompt", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "client_notification", + "name": "session/cancel", + "params": true + }, + { + "acp": true, + "kind": "request", + "name": "session/set_mode", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/set_model", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "request", + "name": "session/set_config_option", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "notification", + "name": "session/update", + "params": true + }, + { + "acp": true, + "kind": "server_request", + "name": "session/request_permission", + "params": true, + "result": true + }, + { + "acp": true, + "kind": "client_notification", + "name": "$/cancel_request", + "params": true + }, + { + "kind": "request", + "name": "acp.session.fork", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "acp.trust.get", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "acp.trust.set", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "catalog.get", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "catalog.refresh", + "params": true, + "result": true + }, + { + "kind": "notification", + "name": "catalog.changed", + "params": true + }, + { + "kind": "request", + "name": "_acpmux/status", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/sessions", + "params": { + "$ref": "#/types/SessionsParams" + }, + "result": { + "$ref": "#/types/SessionsResult" + } + }, + { + "kind": "request", + "name": "_acpmux/attach", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/detach", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/view", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/markUnread", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/watch", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/rename", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/offer_session", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/offer_respond", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/set_tools", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/subagent_stop", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/stats", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/bookmark_get", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/bookmark_set", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/prompt_withdraw", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/process/list", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/process/kill", + "params": true, + "result": true + }, + { + "kind": "notification", + "name": "_acpmux/process_update", + "params": true + }, + { + "kind": "request", + "name": "_acpmux/kill", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/archive", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/side", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/tag", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/info", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/events", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/history", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/wait", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/ask", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/warm", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/prewarm", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/queue_remove", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/queue_edit", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/draft_get", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/draft_set", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/export", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/import", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/permission_respond", + "params": { + "$ref": "#/types/PermissionRespondParams" + }, + "result": { + "$ref": "#/types/PermissionRespondResult" + } + }, + { + "kind": "request", + "name": "_acpmux/permission_groups", + "params": { + "$ref": "#/types/PermissionGroupsParams" + }, + "result": { + "$ref": "#/types/PermissionGroupList" + } + }, + { + "kind": "request", + "name": "_acpmux/permission_group_respond", + "params": { + "$ref": "#/types/PermissionGroupRespondParams" + }, + "result": { + "$ref": "#/types/PermissionGroupReceipt" + } + }, + { + "kind": "request", + "name": "_acpmux/permission_chat_revoke", + "params": { + "$ref": "#/types/PermissionChatRevokeParams" + }, + "result": { + "$ref": "#/types/PermissionChatRevokeReceipt" + } + }, + { + "kind": "request", + "name": "_acpmux/set_policy", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/set_default_policy", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/set_rules", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/person_enroll", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/person_prove", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/person_holds", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/secret_request", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/secret_submit", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/secret_run", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chats", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chats_watch", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chat_open", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chat_roots", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chat_roots_record", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chat_settings", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chat_settled", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chat_transcript", + "params": true, + "result": true + }, + { + "kind": "notification", + "name": "_acpmux/chat_changed", + "params": true + }, + { + "kind": "notification", + "name": "_acpmux/chats_lagged", + "params": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_abort", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_begin", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_commit", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_finish", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_forget", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_read", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_receive", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/transfer_write", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/handoff_prepare", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/handoff_get", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/handoff_draft", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/handoff_start", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/handoff_discard", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/harnesses", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/harness_enable", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/harness/add", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/harness/remove", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/harness/restore", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/harness/doctor", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/harness/health", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/registry", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/defaults", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/presets", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/models", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/directories", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/list", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/show", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/add", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/edit", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/remove", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/restore", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/test", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/route/default.set", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/chat/route.set", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/web_modes", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/web_token_rotate", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/reload_config", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/schema", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/shutdown", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/peers", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/peer_add", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/peer_reconnect", + "params": true, + "result": true + }, + { + "kind": "request", + "name": "_acpmux/peer_remove", + "params": true, + "result": true + }, + { + "kind": "notification", + "name": "_acpmux/event", + "params": true + }, + { + "kind": "notification", + "name": "_acpmux/session_changed", + "params": { + "$ref": "#/types/SessionChanged" + } + }, + { + "kind": "notification", + "name": "_acpmux/permission_pending", + "params": { + "$ref": "#/types/PermissionPending" + } + }, + { + "kind": "notification", + "name": "_acpmux/harnesses_changed", + "params": true + }, + { + "kind": "notification", + "name": "_acpmux/prompt_accepted", + "params": true + }, + { + "kind": "notification", + "name": "_acpmux/lagged", + "params": true + }, + { + "kind": "client_local", + "name": "_acpmux/disconnected", + "params": true + } + ], + "types": { + "AllowanceExpiry": { + "enum": [ + "session_stop_or_daemon_restart" + ], + "type": "string" + }, + "AnswerMeta": { + "additionalProperties": false, + "description": "`_meta.acpmux` on an answer. Every field is optional; a relayed allow\ncarries the decision proof, the person's app may say whose answer it\nrelays, and a peer forwarding for its Web client says `via: \"web\"`.", + "properties": { + "answeredBy": { + "anyOf": [ + { + "$ref": "#/types/AnsweredBy" + }, + { + "type": "null" + } + ], + "description": "`{device, feedItem}`, from the person's connection only." + }, + "decisionNonce": { + "type": [ + "string", + "null" + ] + }, + "personDecision": { + "type": [ + "string", + "null" + ] + }, + "via": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AnsweredBy": { + "additionalProperties": false, + "properties": { + "device": { + "type": "string" + }, + "feedItem": { + "type": "string" + } + }, + "required": [ + "device", + "feedItem" + ], + "type": "object" + }, + "ChangedSession": { + "anyOf": [ + { + "$ref": "#/types/SessionSummary" + }, + { + "$ref": "#/types/PurgedSession" + } + ] + }, + "ChatAllowance": { + "properties": { + "active": { + "type": "boolean" + }, + "expires": { + "$ref": "#/types/AllowanceExpiry" + } + }, + "required": [ + "active", + "expires" + ], + "type": "object" + }, + "CoverageLabel": { + "enum": [ + "acp_requests_only" + ], + "type": "string" + }, + "Enforcement": { + "description": "What limits the session's agent as far as acpmux can tell.", + "properties": { + "detail": { + "type": "string" + }, + "isolation": { + "$ref": "#/types/Isolation" + }, + "label": { + "description": "Always `native_policy`.", + "type": "string" + }, + "policy": { + "description": "The acpmux permission policy (the session's own, else the daemon\ndefault), or `unknown`.", + "type": "string" + } + }, + "required": [ + "detail", + "isolation", + "label", + "policy" + ], + "type": "object" + }, + "Indicator": { + "enum": [ + "input", + "working", + "failed", + "lost", + "done" + ], + "type": "string" + }, + "Isolation": { + "description": "acpmux never claims host isolation.", + "enum": [ + "unverified" + ], + "type": "string" + }, + "Lane": { + "description": "The session's lane (cx-v9k2f), decided once by the daemon (`views::lane`).", + "properties": { + "error": { + "description": "The failed or lost turn's error text.", + "type": [ + "string", + "null" + ] + }, + "since": { + "description": "Unix ms the state began; null when the daemon does not know.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "state": { + "$ref": "#/types/LaneState" + } + }, + "required": [ + "error", + "since", + "state" + ], + "type": "object" + }, + "LaneState": { + "enum": [ + "working", + "input", + "failed", + "lost", + "idle" + ], + "type": "string" + }, + "LastMessage": { + "properties": { + "role": { + "$ref": "#/types/MessageRole" + }, + "text": { + "type": "string" + } + }, + "required": [ + "role", + "text" + ], + "type": "object" + }, + "LastTurn": { + "description": "The outcome of the session's last turn. Keys the turn result lacked\nare absent.", + "properties": { + "detail": { + "description": "Why a turn ended without an answer, e.g. `outcome_unknown`.", + "type": [ + "string", + "null" + ] + }, + "endedAt": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "errorSource": { + "type": [ + "string", + "null" + ] + }, + "errorText": { + "type": [ + "string", + "null" + ] + }, + "promptId": { + "type": [ + "string", + "null" + ] + }, + "status": { + "description": "`completed`, `failed` or `cancelled`.", + "type": [ + "string", + "null" + ] + }, + "stopReason": { + "type": [ + "string", + "null" + ] + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "endedAt" + ], + "type": "object" + }, + "MessageRole": { + "enum": [ + "agent", + "user" + ], + "type": "string" + }, + "PermissionBatching": { + "properties": { + "maxItems": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "maxPendingGroups": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "maxReceipts": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "windowMs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "windowMs", + "maxItems", + "maxPendingGroups", + "maxReceipts" + ], + "type": "object" + }, + "PermissionChatRevokeParams": { + "additionalProperties": false, + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" + }, + "PermissionChatRevokeReceipt": { + "properties": { + "active": { + "description": "Always false.", + "type": "boolean" + } + }, + "required": [ + "active" + ], + "type": "object" + }, + "PermissionCoverage": { + "properties": { + "detail": { + "type": "string" + }, + "isolation": { + "$ref": "#/types/Isolation" + }, + "label": { + "$ref": "#/types/CoverageLabel" + } + }, + "required": [ + "label", + "isolation", + "detail" + ], + "type": "object" + }, + "PermissionDecision": { + "enum": [ + "allow_once", + "allow_chat", + "deny" + ], + "type": "string" + }, + "PermissionGroup": { + "description": "A batch of permission requests from one turn, decided together.", + "properties": { + "decision": { + "anyOf": [ + { + "$ref": "#/types/PermissionDecision" + }, + { + "type": "null" + } + ], + "description": "The decision taken, once one was." + }, + "decisions": { + "description": "What a respond may choose: all three, or `deny` alone when a pending\nitem has no single allow-once option.", + "items": { + "$ref": "#/types/PermissionDecision" + }, + "type": "array" + }, + "groupId": { + "type": "string" + }, + "items": { + "items": { + "$ref": "#/types/PermissionGroupItem" + }, + "type": "array" + }, + "revision": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "sessionId": { + "type": "string" + }, + "state": { + "$ref": "#/types/PermissionGroupState" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "decision", + "decisions", + "groupId", + "items", + "revision", + "sessionId", + "state", + "turnId" + ], + "type": "object" + }, + "PermissionGroupItem": { + "properties": { + "permissionId": { + "type": "string" + }, + "request": { + "additionalProperties": true, + "description": "The agent's `session/request_permission` params.", + "type": "object" + }, + "state": { + "$ref": "#/types/PermissionItemState" + } + }, + "required": [ + "permissionId", + "request", + "state" + ], + "type": "object" + }, + "PermissionGroupList": { + "properties": { + "batching": { + "$ref": "#/types/PermissionBatching" + }, + "chatAllowance": { + "$ref": "#/types/ChatAllowance" + }, + "coverage": { + "$ref": "#/types/PermissionCoverage" + }, + "groups": { + "items": { + "$ref": "#/types/PermissionGroup" + }, + "type": "array" + } + }, + "required": [ + "groups", + "chatAllowance", + "coverage", + "batching" + ], + "type": "object" + }, + "PermissionGroupReceipt": { + "properties": { + "group": { + "$ref": "#/types/PermissionGroup" + }, + "replayed": { + "description": "An earlier respond with the same key and body answered this.", + "type": "boolean" + } + }, + "required": [ + "group", + "replayed" + ], + "type": "object" + }, + "PermissionGroupRespondParams": { + "additionalProperties": false, + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/types/RequestMeta" + }, + { + "type": "null" + } + ] + }, + "decision": { + "$ref": "#/types/PermissionDecision" + }, + "decisionKey": { + "description": "1 to 256 bytes; the same key with the same body replays the receipt.", + "type": "string" + }, + "groupId": { + "type": "string" + }, + "revision": { + "description": "The group revision the decision was made on.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId", + "groupId", + "revision", + "decisionKey", + "decision" + ], + "type": "object" + }, + "PermissionGroupState": { + "enum": [ + "collecting", + "pending", + "resolved", + "cancelled" + ], + "type": "string" + }, + "PermissionGroupsParams": { + "additionalProperties": false, + "properties": { + "groupId": { + "description": "One group; absent lists them all. Never null.", + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" + }, + "PermissionItemState": { + "enum": [ + "pending", + "resolved", + "cancelled" + ], + "type": "string" + }, + "PermissionPending": { + "additionalProperties": true, + "description": "To attached clients (and watchers not attached) when a permission or\nquestion waits: the log record's message (the agent's request with\nacpmux's `permissionId`) plus where it came from.", + "properties": { + "decisionNonce": { + "description": "Signs an allow relayed for the person (`hub/person.rs`).", + "type": [ + "string", + "null" + ] + }, + "groupId": { + "description": "The batch it joined, if any.", + "type": [ + "string", + "null" + ] + }, + "permissionId": { + "type": "string" + }, + "personInstance": { + "type": [ + "string", + "null" + ] + }, + "request": { + "additionalProperties": true, + "description": "The agent's `session/request_permission` params.", + "type": "object" + }, + "seq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "sessionId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + }, + "via": { + "description": "`attach` or `watch`.", + "type": "string" + } + }, + "required": [ + "sessionId", + "seq", + "via", + "permissionId", + "request" + ], + "type": "object" + }, + "PermissionRespondParams": { + "additionalProperties": false, + "properties": { + "_meta": { + "anyOf": [ + { + "$ref": "#/types/RequestMeta" + }, + { + "type": "null" + } + ] + }, + "answers": { + "description": "A question's answers, by question." + }, + "optionId": { + "description": "The option chosen; absent cancels the request.", + "type": [ + "string", + "null" + ] + }, + "permissionId": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId", + "permissionId" + ], + "type": "object" + }, + "PermissionRespondResult": { + "type": "object" + }, + "PurgedSession": { + "description": "A purged session: only its id is left.", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "type": "object" + }, + "QueuedPromptSummary": { + "description": "A prompt waiting for the running turn to end.", + "properties": { + "client": { + "type": "string" + }, + "held": { + "description": "Waits behind a failed turn until the queue is resumed.", + "type": "boolean" + }, + "prompt": { + "type": "string" + }, + "promptId": { + "type": "string" + }, + "queuedAt": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "client", + "held", + "prompt", + "promptId", + "queuedAt", + "turnId" + ], + "type": "object" + }, + "RequestMeta": { + "additionalProperties": false, + "properties": { + "acpmux": { + "anyOf": [ + { + "$ref": "#/types/AnswerMeta" + }, + { + "type": "null" + } + ] + }, + "cmuxGesture": { + "description": "The app's gesture ticket for this exact pick (`transport.gesture`);\nthe app's relay reads it, acpmux does not.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "SessionChanged": { + "description": "To watchers when a session changed: its new summary (only its id when\npurged), the coarse `kind` and the log record behind it.", + "properties": { + "kind": { + "description": "The record kind, or `queue`, `permission_resolved` or `purged`.", + "type": "string" + }, + "peer": { + "description": "The peer a remote session runs on.", + "type": [ + "string", + "null" + ] + }, + "recordKind": { + "type": "string" + }, + "seq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "session": { + "$ref": "#/types/ChangedSession" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId", + "session", + "kind", + "recordKind", + "seq" + ], + "type": "object" + }, + "SessionSummary": { + "description": "One session as every list shows it (`Hub::session_summary`). A peer's\nsession arrives as the peer daemon's own summary plus `peer`, with\n`status` `unreachable` while the peer is gone; a peer on an older\nacpmux may lack newer fields, so nullable ones are optional here.", + "properties": { + "agentSessionId": { + "type": [ + "string", + "null" + ] + }, + "attached": { + "description": "Attached clients.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "createdAt": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "currentModeId": { + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Lossy when the path is not UTF-8.", + "type": "string" + }, + "enforcement": { + "$ref": "#/types/Enforcement" + }, + "eventCount": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "family": { + "type": [ + "string", + "null" + ] + }, + "forkSeq": { + "description": "Where a fork branched off its parent.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "harness": { + "type": "string" + }, + "indicator": { + "anyOf": [ + { + "$ref": "#/types/Indicator" + }, + { + "type": "null" + } + ], + "description": "The status dot every client draws (`status_indicator`); null for none." + }, + "inputSince": { + "description": "Since when it waits on the person (`lane`); null while it does not.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "lane": { + "anyOf": [ + { + "$ref": "#/types/Lane" + }, + { + "type": "null" + } + ], + "description": "What it is doing and since when (`views::lane`); null for a closed session." + }, + "lastMessage": { + "anyOf": [ + { + "$ref": "#/types/LastMessage" + }, + { + "type": "null" + } + ], + "description": "One sidebar line; null before the first prompt." + }, + "lastPrompt": { + "type": [ + "string", + "null" + ] + }, + "lastSeq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "lastTurn": { + "anyOf": [ + { + "$ref": "#/types/LastTurn" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "moving": { + "description": "Being moved to another daemon; it takes no prompt meanwhile.", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "parentId": { + "type": [ + "string", + "null" + ] + }, + "peer": { + "description": "The peer daemon a remote session runs on; absent for this daemon's own.", + "type": [ + "string", + "null" + ] + }, + "pendingPermissions": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "policy": { + "type": [ + "string", + "null" + ] + }, + "preview": { + "type": [ + "string", + "null" + ] + }, + "queue": { + "items": { + "$ref": "#/types/QueuedPromptSummary" + }, + "type": "array" + }, + "queuePaused": { + "description": "A turn failed: the prompts behind it wait for a client to resume them.", + "type": "boolean" + }, + "queued": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "rules": { + "description": "The session has its own permission rules.", + "type": "boolean" + }, + "sessionId": { + "type": "string" + }, + "shown": { + "description": "Some attached client shows the chat.", + "type": "boolean" + }, + "stateSeq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "status": { + "description": "`idle`, `ready`, `running`, `waiting`, `disconnected` or `closed`;\na peer's session also `unreachable`.", + "type": "string" + }, + "steering": { + "description": "The agent takes a prompt mid-turn.", + "type": "boolean" + }, + "tags": { + "additionalProperties": { + "type": "string" + }, + "description": "Tags that have not expired.", + "type": "object" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "tools": { + "description": "The chat's tools (`hub/tools.rs`).", + "items": { + "$ref": "#/types/ToolSummary" + }, + "type": "array" + }, + "toolsMutable": { + "description": "`_acpmux/set_tools` can change them.", + "type": "boolean" + }, + "turn": { + "anyOf": [ + { + "$ref": "#/types/TurnSummary" + }, + { + "type": "null" + } + ] + }, + "turnCount": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "unread": { + "type": "boolean" + }, + "updatedAt": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "sessionId", + "name", + "harness", + "cwd", + "status", + "createdAt", + "updatedAt", + "lastSeq", + "eventCount", + "turnCount", + "queued", + "queuePaused", + "moving", + "steering", + "queue", + "pendingPermissions", + "enforcement", + "rules", + "tags", + "stateSeq", + "unread", + "attached", + "shown", + "tools", + "toolsMutable" + ], + "type": "object" + }, + "SessionsParams": { + "additionalProperties": false, + "type": "object" + }, + "SessionsResult": { + "properties": { + "sessions": { + "description": "Local and peer sessions, newest first.", + "items": { + "$ref": "#/types/SessionSummary" + }, + "type": "array" + } + }, + "required": [ + "sessions" + ], + "type": "object" + }, + "ToolSummary": { + "description": "One row of a chat's tool list (`store::ToolRow`).", + "properties": { + "canToggle": { + "description": "`_acpmux/set_tools` can turn it on or off in this chat.", + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "id": { + "description": "`mcp:`, `skill:` or `plugin:`, unique in the chat.", + "type": "string" + }, + "kind": { + "description": "`mcp`, `skill`, `plugin`, or `cmux` for cmux's own tools.", + "type": "string" + }, + "name": { + "type": "string" + }, + "source": { + "description": "What gives it: `cmux`, or the harness that reported it (`claude`).", + "type": "string" + } + }, + "required": [ + "canToggle", + "enabled", + "id", + "kind", + "name", + "source" + ], + "type": "object" + }, + "TurnSummary": { + "description": "The running turn.", + "properties": { + "client": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "promptId": { + "type": "string" + }, + "startedAt": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "turnId": { + "type": "string" + }, + "turnSeq": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "client", + "prompt", + "promptId", + "startedAt", + "turnId", + "turnSeq" + ], + "type": "object" + } + }, + "version": "1" +} diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index 0a23cc980e50..82f61e868280 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -8,13 +8,15 @@ use crate::rpc::{Id, Message, RpcError}; use anyhow::{Context, Result, anyhow}; use serde_json::Value; use std::collections::HashMap; -use std::process::Stdio; use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, Command}; +use tokio::process::Child; use tokio::sync::{Mutex, mpsc, oneshot}; +mod command; +pub(crate) use command::{command_env, harness_command}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Direction { /// acpmux -> agent (stdin) @@ -147,6 +149,9 @@ pub struct ChildAgent { /// (`codex_app_server.rs`) and run directly under acpmux. codex: Option, hosted: Option, + /// Windows: the agent's job (its process group on Unix). + #[cfg(windows)] + job: Option, } /// The in-process Codex bridge of a direct child: its translator, and the @@ -160,170 +165,15 @@ struct CodexLink { order: Mutex<()>, } -/// The harness command line and environment, as acpmux runs it: the login -/// environment, no nested-agent markers, the session's `ACPMUX_*` caller -/// context, and the profile's env. Shared by direct children and hosts. -pub(crate) fn harness_command( - name: &str, - profile: &HarnessProfile, - cwd: &std::path::Path, - command_line: Option<(String, Vec)>, - session: Option<(&str, &str)>, -) -> Result { - // A session's Claude Code (a planned command line) gets cmux's browser - // and computer use tools and skills (agent_tools.rs); a remote chain's - // sandboxed plan carries --strict-mcp-config and gets none. - let claude_session = profile.kind == crate::config::HarnessKind::ClaudeStdio - && command_line.is_some() - && session.is_some(); - let owned: (String, Vec) = match command_line { - Some(c) => c, - None => { - let (program, args) = profile - .argv - .split_first() - .ok_or_else(|| anyhow!("agent {name} has an empty argv"))?; - (program.clone(), args.to_vec()) - } - }; - if profile.kind == crate::config::HarnessKind::Terminal { - return Err(anyhow!( - "{name} is a terminal harness without ACP; open it with `cmux harness run {name}`" - )); - } - let owned = if let (true, Some((session_id, _))) = (claude_session, session) { - let extra = crate::agent_tools::claude_args_for(false, &profile.env, &owned.1, session_id); - (owned.0, owned.1.into_iter().chain(extra).collect()) - } else { - owned - }; - let mut env = resolved_profile_env(profile)?; - // The Codex bridge runs the configured codex binary: `CODEX_PATH` of the - // profile or its family (`defaults.codex.env`), as the ACP adapter does. - let owned = match env.get("CODEX_PATH").filter(|p| !p.is_empty()) { - Some(path) if crate::codex_app_server::is_launch(&owned.0, &owned.1) => { - (path.clone(), owned.1) - } - _ => owned, - }; - // A bound route's provider variables the inherited env must not keep - // (routes.rs): an earlier route's key never reaches this process. - let route_unset = env.remove(crate::routes::UNSET_KEY); - env.remove(crate::routes::PROFILE_ROUTE_KEY); - let (program, args) = (&owned.0, &owned.1); - let mut cmd = Command::new(program); - // Startup files changed since the last read: refresh in the background. - crate::login_env::refresh_if_changed(); - // The login shell's allow-list for this harness's family (login_env.rs). - let family_name = crate::config::derive_family(name, profile); - let family = Some(family_name.as_str()); - crate::login_env::apply_tokio_for(&mut cmd, family, Some(&env)); - crate::config::scrub_nested_claude_env_tokio_for(&mut cmd, family); - // Caller context, herdr-style: the agent knows which session it is. - for (k, _) in std::env::vars_os() { - if k.to_string_lossy().starts_with("ACPMUX_") { - cmd.env_remove(&k); - } - } - // Helper tokens reach the cmux-cua MCP server through its own env only. - crate::cua_socket::scrub_agent_env(&mut cmd); - // A nested launch must not be taken for its parent's thread. - cmd.env_remove("CODEX_THREAD_ID").env_remove("OMPCODE"); - // The daemon's own terminal (it may have started in a cmux terminal) is - // not the agent's: a stale id would open the agent's tabs beside it. - for key in crate::agent_tools::INHERITED_CALLER_ENV { - cmd.env_remove(key); - } - for k in route_unset.iter().flat_map(|list| list.split(',')) { - cmd.env_remove(k); - } - if let Some((id, sname)) = session { - cmd.env("ACPMUX_ENV", "1") - .env("ACPMUX_SESSION_ID", id) - .env("ACPMUX_SESSION_NAME", sname) - .env("ACPMUX_SOCKET", crate::config::socket_path()) - // The caller the cmux CLI names to the app: its tabs open in the - // column right of this session's chat (beside_caller). - .env(crate::agent_tools::AGENT_SESSION_ENV, id); - } - cmd.args(crate::harness_limits::launch_args(program, args, session.map(|_| (cwd, &env)))) - .envs(env.iter()) - // A terminal's launch credential names that one terminal; no agent - // acts as the terminal that started acpmux, not even through a preset. - .env_remove(crate::config::LAUNCH_CREDENTIAL_ENV) - // Nor as the Chief whose host started acpmux (config::CHIEF_CREDENTIAL_ENV). - .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[0]) - .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[1]) - // Claude refuses to nest inside another Claude session. - .env_remove("CLAUDECODE") - .env_remove("CLAUDE_CODE_ENTRYPOINT") - .current_dir(cwd) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - // Own process group, so stopping the session stops everything the - // agent started underneath it (background shells included). Windows - // port: a job object there (a later landing). - #[cfg(unix)] - cmd.process_group(0); - crate::harness_limits::lift_claude(&mut cmd, profile, session.is_some()); - // A Claude agent's sign-in inputs, for a clear message if it has none. - match (family == Some("claude"), session) { - (true, Some((id, _))) => crate::claude_auth::remember(id, &command_env(&cmd)), - (false, Some((id, _))) => crate::claude_auth::forget(id), - _ => {} - } - Ok(cmd) -} - -/// The profile's env with its `${keychain:…}` and `${env:…}` references -/// resolved (`config::profiles`). The secret store is asked only when a -/// value holds a reference; the lookup blocks, so a multi-thread runtime -/// moves it off its worker first. -fn resolved_profile_env( - profile: &HarnessProfile, -) -> Result> { - let mut env = profile.env.clone(); - if !crate::config::profiles::has_env_refs(&env) { - return Ok(env); - } - let resolve = |env: &mut std::collections::BTreeMap| { - crate::config::profiles::resolve_env_refs( - env, - &|var| crate::login_env::allowed_var(var).or_else(|| std::env::var(var).ok()), - &crate::config::profiles::keychain_lookup, - ) - }; - let multi_thread = tokio::runtime::Handle::try_current() - .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread); - let result = if multi_thread { - tokio::task::block_in_place(|| resolve(&mut env)) - } else { - resolve(&mut env) - }; - result.map_err(|e| anyhow!(e))?; - Ok(env) -} - -/// The full environment `cmd` gives its child (inherited, then changed). -pub(crate) fn command_env(cmd: &Command) -> Vec<(String, String)> { - let mut env: std::collections::BTreeMap = std::env::vars().collect(); - for (k, v) in cmd.as_std().get_envs() { - let k = k.to_string_lossy().into_owned(); - match v { - Some(v) => { - env.insert(k, v.to_string_lossy().into_owned()); - } - None => { - env.remove(&k); - } +impl ChildAgent { + /// Ends every process in the agent's job (Windows; killpg on Unix). + #[cfg(windows)] + fn kill_job(&self) { + if let Some(job) = &self.job { + job.kill(); } } - env.into_iter().collect() -} -impl ChildAgent { /// Spawn the agent and start its reader loop. Inbound requests and /// notifications are delivered on `inbound`. pub async fn spawn( @@ -350,6 +200,8 @@ impl ChildAgent { // it can drive its own session and siblings through the CLI. session: Option<(&str, &str)>, ) -> Result> { + // The build-session kill switch: no agent process at all (cx-9ettq). + crate::launch_policy::refuse_if_disabled()?; let codex = (translator.is_none() && command_line.is_none() && crate::codex_app_server::is_profile_launch(&profile.argv)) @@ -359,9 +211,25 @@ impl ChildAgent { order: Mutex::new(()), }); let mut cmd = harness_command(name, profile, cwd, command_line, session)?; + #[cfg(windows)] + cmd.creation_flags(crate::job::AGENT_FLAGS); let mut child = cmd .spawn() .with_context(|| format!("spawn agent {name}: {}", profile.argv.join(" ")))?; + #[cfg(windows)] + let job = { + let contained = child + .raw_handle() + .ok_or_else(|| std::io::Error::other("the agent has no process handle")) + .and_then(crate::job::Job::contain); + match contained { + Ok(job) => Some(job), + Err(e) => { + let _ = child.start_kill(); + anyhow::bail!("spawn agent {name}: its job object: {e}"); + } + } + }; let pid = child.id(); let stdin = child.stdin.take().context("agent stdin")?; let stdout = child.stdout.take().context("agent stdout")?; @@ -381,6 +249,8 @@ impl ChildAgent { translator: translator.clone(), codex, hosted: None, + #[cfg(windows)] + job, }); // Writer task. @@ -443,6 +313,8 @@ impl ChildAgent { libc::killpg(pg as i32, libc::SIGKILL); } } + #[cfg(windows)] + agent_for_exit.kill_job(); } continue; } @@ -627,9 +499,15 @@ impl ChildAgent { unsafe { libc::killpg(pid as i32, libc::SIGTERM); } + // Windows: Ctrl+Break to the agent's process group first. + #[cfg(windows)] + crate::job::ctrl_break(pid); if let Some(child) = guard.as_mut() { let _ = tokio::time::timeout(KILL_GRACE, child.wait()).await; } + // Then the job ends, stragglers included (SIGKILL on Unix). + #[cfg(windows)] + self.kill_job(); #[cfg(unix)] unsafe { libc::killpg(pid as i32, libc::SIGKILL); @@ -679,6 +557,11 @@ impl ChildAgent { libc::killpg(pg, libc::SIGTERM); } } + // Windows: Ctrl+Break to the agent's process group first. + #[cfg(windows)] + if let Some(pg) = pgid { + crate::job::ctrl_break(pg as u32); + } let _ = tokio::time::timeout(grace, async { // The exit watcher may hold this lock while it reaps, and frees // it once the child is gone; otherwise wait on the exit here. @@ -687,6 +570,9 @@ impl ChildAgent { } }) .await; + // Then the job ends, stragglers included (SIGKILL on Unix). + #[cfg(windows)] + self.kill_job(); #[cfg(unix)] if let Some(pg) = pgid { unsafe { @@ -1021,6 +907,8 @@ impl ChildAgent { exit_seen: std::sync::atomic::AtomicBool::new(false), exit: tokio::sync::Notify::new(), }), + #[cfg(windows)] + job: None, }); agent.start_reader(link); (agent, responses) diff --git a/cmux-tui/crates/acpmux/src/agent/command.rs b/cmux-tui/crates/acpmux/src/agent/command.rs new file mode 100644 index 000000000000..be1273006694 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/agent/command.rs @@ -0,0 +1,170 @@ +//! The harness command line and environment, as acpmux runs it, for direct +//! children (`agent.rs`) and agent hosts. + +use crate::config::HarnessProfile; +use anyhow::{Result, anyhow}; +use std::process::Stdio; +use tokio::process::Command; + +/// The harness command line and environment, as acpmux runs it: the login +/// environment, no nested-agent markers, the session's `ACPMUX_*` caller +/// context, and the profile's env. Shared by direct children and hosts. +pub(crate) fn harness_command( + name: &str, + profile: &HarnessProfile, + cwd: &std::path::Path, + command_line: Option<(String, Vec)>, + session: Option<(&str, &str)>, +) -> Result { + // A session's Claude Code (a planned command line) gets cmux's browser + // and computer use tools and skills (agent_tools.rs); a remote chain's + // sandboxed plan carries --strict-mcp-config and gets none. + let claude_session = profile.kind == crate::config::HarnessKind::ClaudeStdio + && command_line.is_some() + && session.is_some(); + let owned: (String, Vec) = match command_line { + Some(c) => c, + None => { + let (program, args) = profile + .argv + .split_first() + .ok_or_else(|| anyhow!("agent {name} has an empty argv"))?; + (program.clone(), args.to_vec()) + } + }; + if profile.kind == crate::config::HarnessKind::Terminal { + return Err(anyhow!( + "{name} is a terminal harness without ACP; open it with `cmux harness run {name}`" + )); + } + let owned = if let (true, Some((session_id, _))) = (claude_session, session) { + let extra = crate::agent_tools::claude_args_for(false, &profile.env, &owned.1, session_id); + (owned.0, owned.1.into_iter().chain(extra).collect()) + } else { + owned + }; + let mut env = resolved_profile_env(profile)?; + // The Codex bridge runs the configured codex binary: `CODEX_PATH` of the + // profile or its family (`defaults.codex.env`), as the ACP adapter does. + let owned = match env.get("CODEX_PATH").filter(|p| !p.is_empty()) { + Some(path) if crate::codex_app_server::is_launch(&owned.0, &owned.1) => { + (path.clone(), owned.1) + } + _ => owned, + }; + // A bound route's provider variables the inherited env must not keep + // (routes.rs): an earlier route's key never reaches this process. + let route_unset = env.remove(crate::routes::UNSET_KEY); + env.remove(crate::routes::PROFILE_ROUTE_KEY); + let (program, args) = (&owned.0, &owned.1); + let mut cmd = Command::new(program); + // Startup files changed since the last read: refresh in the background. + crate::login_env::refresh_if_changed(); + // The login shell's allow-list for this harness's family (login_env.rs). + let family_name = crate::config::derive_family(name, profile); + let family = Some(family_name.as_str()); + crate::login_env::apply_tokio_for(&mut cmd, family, Some(&env)); + crate::config::scrub_nested_claude_env_tokio_for(&mut cmd, family); + // Caller context, herdr-style: the agent knows which session it is. + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("ACPMUX_") { + cmd.env_remove(&k); + } + } + // Helper tokens reach the cmux-cua MCP server through its own env only. + crate::cua_socket::scrub_agent_env(&mut cmd); + // A nested launch must not be taken for its parent's thread. + cmd.env_remove("CODEX_THREAD_ID").env_remove("OMPCODE"); + // The daemon's own terminal (it may have started in a cmux terminal) is + // not the agent's: a stale id would open the agent's tabs beside it. + for key in crate::agent_tools::INHERITED_CALLER_ENV { + cmd.env_remove(key); + } + for k in route_unset.iter().flat_map(|list| list.split(',')) { + cmd.env_remove(k); + } + if let Some((id, sname)) = session { + cmd.env("ACPMUX_ENV", "1") + .env("ACPMUX_SESSION_ID", id) + .env("ACPMUX_SESSION_NAME", sname) + .env("ACPMUX_SOCKET", crate::config::socket_path()) + // Its caller token and the socket it is for (hub/person.rs (a)); the cmux CLI's caller. + .envs(crate::hub::person::caller_env(id)) + .env(crate::agent_tools::AGENT_SESSION_ENV, id); + } + cmd.args(crate::harness_limits::launch_args(program, args, session.map(|_| (cwd, &env)))) + .envs(env.iter()) + // A terminal's launch credential names that one terminal; no agent + // acts as the terminal that started acpmux, not even through a preset. + .env_remove(crate::config::LAUNCH_CREDENTIAL_ENV) + // Nor as the Chief whose host started acpmux (config::CHIEF_CREDENTIAL_ENV). + .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[0]) + .env_remove(crate::config::CHIEF_CREDENTIAL_ENV[1]) + // Claude refuses to nest inside another Claude session. + .env_remove("CLAUDECODE") + .env_remove("CLAUDE_CODE_ENTRYPOINT") + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + // Own process group, so stopping the session stops everything the + // agent started underneath it (background shells included). Windows: a + // job object (`job.rs`), made where the agent is spawned. + #[cfg(unix)] + cmd.process_group(0); + crate::harness_limits::lift_claude(&mut cmd, profile, session.is_some()); + // A Claude agent's sign-in inputs, for a clear message if it has none. + match (family == Some("claude"), session) { + (true, Some((id, _))) => crate::claude_auth::remember(id, &command_env(&cmd)), + (false, Some((id, _))) => crate::claude_auth::forget(id), + _ => {} + } + Ok(cmd) +} + +/// The profile's env with its `${keychain:…}` and `${env:…}` references +/// resolved (`config::profiles`). The secret store is asked only when a +/// value holds a reference; the lookup blocks, so a multi-thread runtime +/// moves it off its worker first. +fn resolved_profile_env( + profile: &HarnessProfile, +) -> Result> { + let mut env = profile.env.clone(); + if !crate::config::profiles::has_env_refs(&env) { + return Ok(env); + } + let resolve = |env: &mut std::collections::BTreeMap| { + crate::config::profiles::resolve_env_refs( + env, + &|var| crate::login_env::allowed_var(var).or_else(|| std::env::var(var).ok()), + &crate::config::profiles::keychain_lookup, + ) + }; + let multi_thread = tokio::runtime::Handle::try_current() + .is_ok_and(|h| h.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread); + let result = if multi_thread { + tokio::task::block_in_place(|| resolve(&mut env)) + } else { + resolve(&mut env) + }; + result.map_err(|e| anyhow!(e))?; + Ok(env) +} + +/// The full environment `cmd` gives its child (inherited, then changed). +pub(crate) fn command_env(cmd: &Command) -> Vec<(String, String)> { + let mut env: std::collections::BTreeMap = std::env::vars().collect(); + for (k, v) in cmd.as_std().get_envs() { + let k = k.to_string_lossy().into_owned(); + match v { + Some(v) => { + env.insert(k, v.to_string_lossy().into_owned()); + } + None => { + env.remove(&k); + } + } + } + env.into_iter().collect() +} diff --git a/cmux-tui/crates/acpmux/src/agent_host/link.rs b/cmux-tui/crates/acpmux/src/agent_host/link.rs index d15f3dbd7456..d810633f069d 100644 --- a/cmux-tui/crates/acpmux/src/agent_host/link.rs +++ b/cmux-tui/crates/acpmux/src/agent_host/link.rs @@ -36,6 +36,8 @@ pub async fn spawn_within( spec: &SpawnSpec, budget: std::time::Duration, ) -> Result { + // The build-session kill switch: no host and no harness (cx-9ettq). + crate::launch_policy::refuse_if_disabled()?; let mut cmd = tokio::process::Command::new(&launcher.exe); cmd.args(&launcher.prefix) .arg(HOST_ARG) diff --git a/cmux-tui/crates/acpmux/src/agent_host/record_watch.rs b/cmux-tui/crates/acpmux/src/agent_host/record_watch.rs index f034ed68243f..b537157e657f 100644 --- a/cmux-tui/crates/acpmux/src/agent_host/record_watch.rs +++ b/cmux-tui/crates/acpmux/src/agent_host/record_watch.rs @@ -11,8 +11,17 @@ //! fleet Mac (cx-ckdxf, host_lifetime.rs). One thread waits in `kevent` with //! no timeout; dropping [`DirWatch`] triggers a user event that ends it. //! -//! Elsewhere: notify's recommended watcher (inotify on Linux), which is a -//! kernel queue too. +//! Linux: an inotify watch on each directory with a mask of entry changes +//! only (created, deleted, renamed in or out) and the directory's own +//! deletion or move. notify's inotify backend always asks for open, close and +//! modify events too: the callback's own read of the record then queued an +//! open and a close in the watched directory, which ran the callback again, +//! so every pooled host spun at 100% CPU reading its record. With this mask a +//! read never queues an event. One thread waits in `poll` with no timeout; +//! dropping [`DirWatch`] writes an eventfd that ends it. +//! +//! Elsewhere: notify's recommended watcher; access events are ignored there +//! for the same reason. use std::path::PathBuf; @@ -20,7 +29,9 @@ use std::path::PathBuf; pub(super) struct DirWatch { #[cfg(target_os = "macos")] kq: std::sync::Arc, - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + stop: std::sync::Arc, + #[cfg(not(any(target_os = "macos", target_os = "linux")))] _watcher: notify::RecommendedWatcher, } @@ -107,7 +118,76 @@ impl DirWatch { Some(Self { kq }) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + pub(super) fn start( + dirs: &[PathBuf], + mut on_change: impl FnMut() -> bool + Send + 'static, + ) -> Option { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + use std::os::unix::ffi::OsStrExt; + // SAFETY: inotify_init1 has no preconditions. + let raw = unsafe { libc::inotify_init1(libc::IN_CLOEXEC | libc::IN_NONBLOCK) }; + if raw < 0 { + tracing::warn!( + "the host cannot watch its record: inotify: {}", + std::io::Error::last_os_error() + ); + return None; + } + // SAFETY: inotify_init1 returned a new descriptor that nothing else owns. + let inotify = unsafe { OwnedFd::from_raw_fd(raw) }; + // SAFETY: eventfd has no preconditions. + let raw_stop = unsafe { libc::eventfd(0, libc::EFD_CLOEXEC) }; + if raw_stop < 0 { + tracing::warn!( + "the host cannot watch its record: eventfd: {}", + std::io::Error::last_os_error() + ); + return None; + } + // SAFETY: eventfd returned a new descriptor that nothing else owns. + let stop = std::sync::Arc::new(unsafe { OwnedFd::from_raw_fd(raw_stop) }); + // Entry changes and the directory's own end only: no IN_OPEN, + // IN_ACCESS, IN_CLOSE_* or IN_MODIFY, which a read of the record + // (the callback's own) or a write to an open file would queue. + let mask = libc::IN_CREATE + | libc::IN_DELETE + | libc::IN_MOVED_FROM + | libc::IN_MOVED_TO + | libc::IN_DELETE_SELF + | libc::IN_MOVE_SELF + | libc::IN_ONLYDIR; + let mut watched = 0; + for dir in dirs { + let added = std::ffi::CString::new(dir.as_os_str().as_bytes()) + .map_err(std::io::Error::other) + .and_then(|path| { + // SAFETY: a valid inotify descriptor and a NUL-terminated path. + let wd = unsafe { + libc::inotify_add_watch(inotify.as_raw_fd(), path.as_ptr(), mask) + }; + if wd < 0 { Err(std::io::Error::last_os_error()) } else { Ok(()) } + }); + match added { + Ok(()) => watched += 1, + Err(e) => tracing::warn!("the host cannot watch {}: {e}", dir.display()), + } + } + if watched == 0 { + return None; + } + let thread_stop = stop.clone(); + let spawned = std::thread::Builder::new() + .name("acpmux-record-watch".into()) + .spawn(move || wait_inotify(&inotify, &thread_stop, &mut on_change)); + if let Err(e) = spawned { + tracing::warn!("the host cannot watch its record: {e}"); + return None; + } + Some(Self { stop }) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] pub(super) fn start( dirs: &[PathBuf], mut on_change: impl FnMut() -> bool + Send + 'static, @@ -115,8 +195,11 @@ impl DirWatch { use notify::Watcher as _; let mut done = false; let mut watcher = - notify::recommended_watcher(move |_event: notify::Result| { - if !done { + notify::recommended_watcher(move |event: notify::Result| { + // An access (the callback's own read of the record among + // them) never changes whether the record is there. + let access = matches!(&event, Ok(e) if e.kind.is_access()); + if !done && !access { done = on_change(); } }) @@ -133,6 +216,67 @@ impl DirWatch { } } +/// Runs `on_change` once per batch of inotify events until it returns true +/// or `stop` becomes readable. Every queued event is drained before the +/// check, so a burst of entry changes costs one check. +#[cfg(target_os = "linux")] +fn wait_inotify( + inotify: &std::os::fd::OwnedFd, + stop: &std::os::fd::OwnedFd, + on_change: &mut dyn FnMut() -> bool, +) { + use std::os::fd::AsRawFd; + let mut fds = [ + libc::pollfd { fd: inotify.as_raw_fd(), events: libc::POLLIN, revents: 0 }, + libc::pollfd { fd: stop.as_raw_fd(), events: libc::POLLIN, revents: 0 }, + ]; + // Room for at least one event with the longest name. + let mut buf = [0u8; 4096]; + loop { + // SAFETY: two valid pollfds; -1 waits until one is ready. + let ready = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, -1) }; + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() == std::io::ErrorKind::Interrupted { + continue; + } + tracing::warn!("the host's record watch stopped: poll: {error}"); + return; + } + if fds[1].revents != 0 { + return; + } + if fds[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0 { + tracing::warn!("the host's record watch stopped: inotify descriptor failed"); + return; + } + let mut events = false; + loop { + // SAFETY: a valid descriptor and a buffer of `buf.len()` bytes. + let n = unsafe { libc::read(inotify.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) }; + if n > 0 { + events = true; + continue; + } + if n == 0 { + break; + } + let error = std::io::Error::last_os_error(); + match error.kind() { + std::io::ErrorKind::Interrupted => continue, + std::io::ErrorKind::WouldBlock => break, + _ => { + tracing::warn!("the host's record watch stopped: read: {error}"); + return; + } + } + } + if events && on_change() { + return; + } + } +} + /// Runs `on_change` for each vnode event until it returns true or the stop /// event comes. The directories in `files` stay open (watched) meanwhile. #[cfg(target_os = "macos")] @@ -182,3 +326,16 @@ impl Drop for DirWatch { let _ = kevent_change(self.kq.as_raw_fd(), STOP, libc::EVFILT_USER, 0, libc::NOTE_TRIGGER); } } + +#[cfg(target_os = "linux")] +impl Drop for DirWatch { + fn drop(&mut self) { + use std::os::fd::AsRawFd; + // Not joined, as on macOS. Any nonzero count makes the eventfd readable. + let one: u64 = 1; + // SAFETY: a valid eventfd and an 8-byte value. + let _ = unsafe { + libc::write(self.stop.as_raw_fd(), (&raw const one).cast(), std::mem::size_of::()) + }; + } +} diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs index 2d43ba54d316..91ca4a8667c5 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs @@ -98,42 +98,57 @@ static MODELS: std::sync::LazyLock> = std::sync::LazyLock: models }); -/// The reasoning choices: Claude Code's effort levels (`--effort` and the -/// live `apply_flag_settings` `effortLevel`), plus two built on them. -/// "default" leaves the model's own level. Ultracode is xhigh plus Claude -/// Code's `ultracode` setting; Ultrathink keeps the level and prefixes each -/// prompt with `Ultrathink:` (verified on Claude Code 2.1.287). The pane -/// offers a model only the levels its `list_models` row supports. -const EFFORTS: [(&str, &str, Option<&str>); 8] = [ - ("default", "Default", None), - ("low", "Low", None), - ("medium", "Medium", None), - ("high", "High", None), - ("xhigh", "Extra High", None), - ("max", "Max", None), - ("ultracode", "Ultracode", Some("xhigh effort plus multi-agent workflow orchestration")), - ("ultrathink", "Ultrathink", Some("Thinks as hard as it can on every prompt")), +/// The Reasoning choices: Claude Code's effort levels (`--effort` and the +/// live `apply_flag_settings` `effortLevel`). "default" leaves the model's +/// own level. The pane offers a model only the levels its `list_models` row +/// supports. +const EFFORTS: [(&str, &str); 6] = [ + ("default", "Default"), + ("low", "Low"), + ("medium", "Medium"), + ("high", "High"), + ("xhigh", "Extra High"), + ("max", "Max"), ]; -/// The prefix Claude Code reads as "think as hard as you can" (Ultrathink). -const ULTRATHINK_PREFIX: &str = "Ultrathink:\n"; +/// The session's effort may also be `ultracode`: Claude Code's workflow +/// orchestration setting, which runs at xhigh. It is not a level, so it is +/// its own On/Off option (`ULTRACODE_OPTION`), not a Reasoning choice. +const ULTRACODE: &str = "ultracode"; +pub const ULTRACODE_OPTION: &str = "ultracode"; -/// The `--effort` a session starts with: Ultracode runs at xhigh, Ultrathink -/// and "default" pass none (the model's own level). -fn cli_effort(effort: &str) -> Option<&str> { +/// A stored or sent effort as this translator keeps it. `ultrathink` was a +/// Reasoning choice that only prefixed each prompt with a keyword; a saved +/// pick of it now runs the model's own level. +fn effort_value(effort: &str) -> &str { match effort { - "default" | "ultrathink" | "" => None, - "ultracode" => Some("xhigh"), - other => Some(other), + "ultrathink" | "" => "default", + other => other, + } +} + +/// The Reasoning select's value for a session effort: Ultracode runs at xhigh. +fn shown_level(effort: &str) -> &str { + match effort_value(effort) { + ULTRACODE => "xhigh", + level => level, } } -/// The `apply_flag_settings` settings for a reasoning choice. Every choice but -/// Ultracode clears `ultracode`; Ultrathink leaves the level as it is. +/// The `--effort` a session starts with: Ultracode runs at xhigh, "default" +/// passes none (the model's own level). +fn cli_effort(effort: &str) -> Option<&str> { + match effort_value(effort) { + "default" => None, + level => Some(shown_level(level)), + } +} + +/// The `apply_flag_settings` settings for a session effort. Every level +/// clears `ultracode`. fn effort_settings(effort: &str) -> Value { - match effort { - "ultracode" => json!({"effortLevel": "xhigh", "ultracode": true}), - "ultrathink" => json!({"ultracode": false}), + match effort_value(effort) { + ULTRACODE => json!({"effortLevel": "xhigh", "ultracode": true}), "default" => json!({"effortLevel": "auto", "ultracode": false}), level => json!({"effortLevel": level, "ultracode": false}), } @@ -143,7 +158,7 @@ fn effort_settings(effort: &str) -> Value { /// Ultracode and fast mode have no command-line flag of their own. fn startup_settings(effort: &str, fast: bool) -> Option { let mut settings = serde_json::Map::new(); - if effort == "ultracode" { + if effort_value(effort) == ULTRACODE { settings.insert("effortLevel".into(), json!("xhigh")); settings.insert("ultracode".into(), json!(true)); } @@ -383,7 +398,7 @@ impl Translator { acp_session_id, mode: Mutex::new(mode.to_owned()), model: Mutex::new(model.to_owned()), - effort: Mutex::new(effort.to_owned()), + effort: Mutex::new(effort_value(effort).to_owned()), fast: Mutex::new(false), in_turn: AtomicBool::new(false), prompt_echo: Mutex::new(None), @@ -485,16 +500,20 @@ impl Translator { } pub async fn config_options_value(&self) -> Value { + let effort = self.effort.lock().await.clone(); json!([ {"id": "model", "name": "Model", "type": "select", "category": "model", "currentValue": *self.model.lock().await, "options": models().iter().map(|(v, n)| json!({"value": v, "name": n})).collect::>()}, {"id": "mode", "name": "Permission mode", "type": "select", "category": "mode", "currentValue": *self.mode.lock().await, "options": MODES.iter().map(|(v, n)| json!({"value": v, "name": n})).collect::>()}, - {"id": "effort", "name": "Reasoning", "type": "select", "category": "thought_level", "currentValue": *self.effort.lock().await, - "options": EFFORTS.iter().map(|(v, n, d)| match d { - Some(d) => json!({"value": v, "name": n, "description": d}), - None => json!({"value": v, "name": n}), - }).collect::>()}, + {"id": "effort", "name": "Reasoning", "type": "select", "category": "thought_level", "currentValue": shown_level(&effort), + "options": EFFORTS.iter().map(|(v, n)| json!({"value": v, "name": n})).collect::>()}, + {"id": ULTRACODE_OPTION, "name": "Ultracode", "type": "select", "category": "model_config", + "currentValue": if effort_value(&effort) == ULTRACODE { "on" } else { "off" }, + "options": [ + {"value": "off", "name": "Off"}, + {"value": "on", "name": "On", "description": "Extra High effort plus multi-agent workflow orchestration"}, + ]}, {"id": "fast-mode", "name": "Fast mode", "type": "select", "category": "model_config", "currentValue": if *self.fast.lock().await { "on" } else { "off" }, "options": [ diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs b/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs index 736fd5e7bf36..2c38b8b2f63c 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs @@ -100,10 +100,6 @@ impl Translator { _ => json!({"type": "text", "text": b.get("text").and_then(Value::as_str).unwrap_or("")}), }) .collect(); - let mut content = content; - if !steer && *self.effort.lock().await == "ultrathink" { - ultrathink(&mut content); - } // Claude Code echoes the line with this uuid when it reads it. let uuid = uuid::Uuid::now_v7().to_string(); let line = json!({"type": "user", "uuid": uuid, "message": {"role": "user", "content": content}}); @@ -172,26 +168,51 @@ impl Translator { ]) } "effort" => { - if !EFFORTS.iter().any(|(v, _, _)| *v == val) { + // Ultracode and Ultrathink were Reasoning choices once; a + // remembered pick of either still applies. + let legacy = matches!(val.as_str(), "ultracode" | "ultrathink"); + if !legacy && !EFFORTS.iter().any(|(v, _)| *v == val) { return Outbound::Reply(Message::err( id.clone(), RpcError::invalid_params(format!( "effort must be one of {}", EFFORTS .iter() - .map(|(v, _, _)| *v) + .map(|(v, _)| *v) .collect::>() .join(", ") )), )); } + let effort = effort_value(&val).to_owned(); + let settings = effort_settings(&effort); self.pending.lock().await.insert( id.to_string(), - Pending::Control(Setting::Effort, val.clone()), + Pending::Control(Setting::Effort, effort), ); // Claude Code's live effort switch is the flag-settings channel. Outbound::Lines(vec![ - json!({"type": "control_request", "request_id": format!("ctl-{}", id), "request": {"subtype": "apply_flag_settings", "settings": effort_settings(&val)}}), + json!({"type": "control_request", "request_id": format!("ctl-{}", id), "request": {"subtype": "apply_flag_settings", "settings": settings}}), + ]) + } + ULTRACODE_OPTION => { + // On runs Ultracode (xhigh plus the setting); Off keeps xhigh. + let effort = match val.as_str() { + "on" => ULTRACODE, + "off" => "xhigh", + _ => { + return Outbound::Reply(Message::err( + id.clone(), + RpcError::invalid_params("ultracode must be on or off"), + )); + } + }; + self.pending.lock().await.insert( + id.to_string(), + Pending::Control(Setting::Effort, effort.to_owned()), + ); + Outbound::Lines(vec![ + json!({"type": "control_request", "request_id": format!("ctl-{}", id), "request": {"subtype": "apply_flag_settings", "settings": effort_settings(effort)}}), ]) } other => Outbound::Reply(Message::err( @@ -301,18 +322,6 @@ impl Translator { } } -/// Ultrathink: the prompt's first text block starts with the prefix (one is -/// added when the prompt has only images or resources). -fn ultrathink(content: &mut Vec) { - match content.iter_mut().find(|b| b["type"] == "text") { - Some(block) => { - let text = block["text"].as_str().unwrap_or(""); - block["text"] = json!(format!("{ULTRATHINK_PREFIX}{text}")); - } - None => content.insert(0, json!({"type": "text", "text": ULTRATHINK_PREFIX.trim_end()})), - } -} - /// An ACP `resource` or `resource_link` block as prompt text, so embedded /// context reaches Claude instead of an empty string. fn resource_text(b: &Value) -> String { diff --git a/cmux-tui/crates/acpmux/src/cli/command.rs b/cmux-tui/crates/acpmux/src/cli/command.rs index 4cfb4ba46755..95aaee7d9543 100644 --- a/cmux-tui/crates/acpmux/src/cli/command.rs +++ b/cmux-tui/crates/acpmux/src/cli/command.rs @@ -720,6 +720,11 @@ pub struct NewArgs { /// Permission policy: ask, approve-reads, approve-edits, approve-all, deny-all. #[arg(long)] pub policy: Option, + /// The harness mode to start in (e.g. `auto`). One that does not ask needs the + /// person in cmux NEXT, a preset the person approved, or this command running + /// inside a session that is in that mode. + #[arg(long)] + pub mode: Option, /// HARNESS[/MODEL]: a family or profile name (`claude`, `codex`, `omp`), optionally with a model /// (`claude/opus`, `codex/gpt-5.5`, `opencode/zai/glm-5.1`). Omitted: the default harness and its defaults. #[arg(long, short)] diff --git a/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs b/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs index 864798229b5d..111528357810 100644 --- a/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs +++ b/cmux-tui/crates/acpmux/src/cli/orchestrate/presets.rs @@ -149,7 +149,7 @@ pub(crate) async fn preset( .split_once('=') .ok_or_else(|| AppError::usage(format!("expected key=value, got {pair:?}")))?; match k { - "harness" | "model" | "effort" | "policy" | "description" => { + "harness" | "model" | "effort" | "policy" | "description" | "mode" => { set.insert(k.into(), if v.is_empty() { Value::Null } else { json!(v) }); } _ if k.starts_with("env.") => { @@ -170,7 +170,7 @@ pub(crate) async fn preset( set.insert("args".into(), args); } _ => return Err(AppError::usage(format!( - "unknown key {k:?}; use harness, model, effort, policy, description, env.KEY, args" + "unknown key {k:?}; use harness, model, effort, policy, description, mode, env.KEY, args" )) .into()), } diff --git a/cmux-tui/crates/acpmux/src/cli/run/standard.rs b/cmux-tui/crates/acpmux/src/cli/run/standard.rs index e385f89393b2..b9b78844e62e 100644 --- a/cmux-tui/crates/acpmux/src/cli/run/standard.rs +++ b/cmux-tui/crates/acpmux/src/cli/run/standard.rs @@ -307,6 +307,9 @@ pub(super) async fn run(cmd: Command, json_out: bool, suppress_reads: bool) -> R if let Some(p) = &args.policy { meta["policy"] = json!(p); } + if let Some(m) = &args.mode { + meta["mode"] = json!(m); + } if let Some(e) = &args.effort { meta["effort"] = json!(e); } diff --git a/cmux-tui/crates/acpmux/src/client.rs b/cmux-tui/crates/acpmux/src/client.rs index f8f7afc44190..1ff481110151 100644 --- a/cmux-tui/crates/acpmux/src/client.rs +++ b/cmux-tui/crates/acpmux/src/client.rs @@ -91,12 +91,26 @@ impl Client { notif_tx, closed, }); - let init = client - .request( - method::INITIALIZE, - json!({"protocolVersion": 1, "clientCapabilities": {}, "clientInfo": {"name": "acpmux-cli", "version": crate::hub::VERSION}}), - ) - .await?; + let mut hello = json!({"protocolVersion": 1, "clientCapabilities": {}, "clientInfo": {"name": "acpmux-cli", "version": crate::hub::VERSION}}); + // Inside an acpmux session: say which, with the caller token the + // daemon put in that session's env (`hub/person.rs` (a)), only to + // the socket of the daemon that issued it (never to another daemon, + // or whatever listens on a path the agent pointed the CLI at). + let caller = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty()); + let issuer = caller(crate::hub::person::CALLER_SOCKET_ENV); + if let (Some(id), Some(token), true) = ( + caller("ACPMUX_SESSION_ID"), + caller(crate::hub::person::CALLER_TOKEN_ENV), + issuer.is_some_and(|socket| Path::new(&socket) == path), + ) { + let mut meta = serde_json::Map::new(); + meta.insert( + crate::hub::person::CALLER_FIELD.to_owned(), + json!({"sessionId": id, "token": token}), + ); + hello["_meta"] = json!({"acpmux": meta}); + } + let init = client.request(method::INITIALIZE, hello).await?; *client.daemon_build.lock().unwrap() = init.pointer("/_meta/acpmux/build").and_then(Value::as_str).map(str::to_owned); Ok(client) diff --git a/cmux-tui/crates/acpmux/src/config.rs b/cmux-tui/crates/acpmux/src/config.rs index aaffe24ffda5..edf8598eac27 100644 --- a/cmux-tui/crates/acpmux/src/config.rs +++ b/cmux-tui/crates/acpmux/src/config.rs @@ -44,9 +44,22 @@ pub fn socket_path() -> PathBuf { // socket there. The long path fails to bind with a clear error instead. let dir = PathBuf::from(format!("/tmp/acpmux-{uid}")); if resolved.parent() == Some(dir.as_path()) && !private_dir(&dir, uid) { - return configured.map(PathBuf::from).unwrap_or_else(|| home().join("acpmux.sock")); + return absolute( + configured.map(PathBuf::from).unwrap_or_else(|| home().join("acpmux.sock")), + ); + } + absolute(resolved) +} + +/// A relative `ACPMUX_SOCKET` against this process's folder: the daemon +/// hands the path to harnesses that run in a project folder +/// (`hub/person.rs` `caller_env`), where a relative one names another file. +fn absolute(path: PathBuf) -> PathBuf { + if path.is_relative() { + std::env::current_dir().map(|d| d.join(&path)).unwrap_or(path) + } else { + path } - resolved } /// Windows: `ACPMUX_SOCKET`, else `\\acpmux.sock`; a long home (AF_UNIX @@ -56,7 +69,7 @@ pub fn socket_path() -> PathBuf { #[cfg(windows)] pub fn socket_path() -> PathBuf { if let Some(v) = std::env::var("ACPMUX_SOCKET").ok().filter(|v| !v.is_empty()) { - return PathBuf::from(v); + return absolute(PathBuf::from(v)); } let preferred = home().join("acpmux.sock"); if preferred.as_os_str().len() < 96 { @@ -938,10 +951,7 @@ pub(crate) use launchers::which; pub use launchers::{subrouter_route, verify_launchers, verify_launchers_with}; mod codex_adapter; mod discover; -pub use codex_adapter::{ - CODEX_ACP_PACKAGE, adapter_package_launch, codex_through_adapter_package, - resolve_adapter_package_bin, -}; +pub use codex_adapter::{CODEX_ACP_PACKAGE, adapter_package_launch, resolve_adapter_package_bin}; pub use discover::{ CODEROUTER_CLAUDE_PROFILE, CODEROUTER_CLAUDE_ROUTE_ENV, add_coderouter_route, coderouter_claude_route, discover_harnesses, discover_harnesses_from, diff --git a/cmux-tui/crates/acpmux/src/config/codex_adapter.rs b/cmux-tui/crates/acpmux/src/config/codex_adapter.rs index 98dabe20094a..63ae16875da6 100644 --- a/cmux-tui/crates/acpmux/src/config/codex_adapter.rs +++ b/cmux-tui/crates/acpmux/src/config/codex_adapter.rs @@ -1,36 +1,10 @@ -//! Codex without its ACP adapter on PATH: run the pinned adapter package through npx. +//! The pinned Codex ACP adapter package, and npx launches of adapter packages. -use std::collections::BTreeMap; - -use super::{HarnessKind, HarnessProfile}; - -/// The codex-acp adapter version acpmux runs when only `codex` is installed (the version the -/// cmux ACP installer pins). +/// The codex-acp adapter version the cmux ACP installer pins. Discovery offers no npx launch of +/// it: an installed codex is the in-process bridge (`discover.rs`). pub const CODEX_ACP_PACKAGE: &str = "@agentclientprotocol/codex-acp@1.10.0"; -/// A `codex` harness through `npx -y `, the adapter pointed at the installed -/// codex by CODEX_PATH; None unless both codex and npx are found. -pub fn codex_through_adapter_package( - codex: Option<&str>, - npx: Option<&str>, -) -> Option { - let (codex, npx) = (codex?, npx?); - Some(HarnessProfile { - kind: HarnessKind::Acp, - argv: vec![npx.to_owned(), "-y".into(), CODEX_ACP_PACKAGE.into()], - env: BTreeMap::from([("CODEX_PATH".to_owned(), codex.to_owned())]), - description: Some(format!("Codex through {CODEX_ACP_PACKAGE} (no codex-acp on PATH)")), - fallback: None, - family: None, - models: vec![], - model: None, - effort: None, - policy: None, - }) -} - -/// `[npx, -y, PACKAGE, ...]` (the pinned adapter launch above, or a config -/// written the same way): `(npx, PACKAGE)`. +/// `[npx, -y, PACKAGE, ...]` (a config written that way): `(npx, PACKAGE)`. pub fn adapter_package_launch(argv: &[String]) -> Option<(&str, &str)> { let [npx, yes, package, ..] = argv else { return None }; let is_npx = std::path::Path::new(npx).file_name().is_some_and(|n| n == "npx"); diff --git a/cmux-tui/crates/acpmux/src/config/discover.rs b/cmux-tui/crates/acpmux/src/config/discover.rs index c50fd7aeb14c..814118e2b3bd 100644 --- a/cmux-tui/crates/acpmux/src/config/discover.rs +++ b/cmux-tui/crates/acpmux/src/config/discover.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; -use super::{HarnessKind, HarnessProfile, codex_through_adapter_package, which}; +use super::{HarnessKind, HarnessProfile, which}; /// The profile name of a configured CodeRouter Claude route. pub const CODEROUTER_CLAUDE_PROFILE: &str = "claude-cr"; @@ -284,52 +284,37 @@ pub fn discover_harnesses_from( }, ); } - // Codex speaks ACP only through its adapter. Without a codex-acp on PATH (or an ~/.acpx - // entry), an installed codex still gets a harness through the pinned adapter package. - if !agents.contains_key("codex") - && let Some(profile) = - codex_through_adapter_package(which("codex").as_deref(), which("npx").as_deref()) - { - agents.insert("codex".to_owned(), profile); - } // Codex through `codex app-server`, bridged in process (cmux-codex-acp, - // `codex_app_server.rs`), is `codex` whenever codex is installed; the - // ACP adapter, when found, stays as `codex-acp`. A profile's (or the - // family's) CODEX_PATH picks the codex binary it runs (`agent.rs`), and - // a `prefer` list still picks among the family's profiles. + // `codex_app_server.rs`), is `codex` whenever codex is installed; an ACP + // adapter on PATH stays as `codex-acp`. A profile's (or the family's) + // CODEX_PATH picks the codex binary it runs (`agent.rs`), and a `prefer` + // list still picks among the family's profiles. Without an adapter on + // PATH no npx adapter package is offered: it reads the account through + // codex's `account/read`, which a ChatGPT login behind a routing proxy + // (subrouter) fails, so its chats never started; the bridge does not. if !codex_imported && let Some(codex) = which("codex") { if let Some(adapter) = agents.remove("codex") { agents.entry(CODEX_ACP.to_owned()).or_insert(adapter); } - // The name the bridge had before it became `codex`, for sessions and - // presets that recorded it. - agents.entry(CODEX_APP_SERVER.to_owned()).or_insert_with(|| HarnessProfile { + let bridge = |description: &str| HarnessProfile { kind: HarnessKind::Acp, argv: vec![codex.clone(), "app-server".into()], env: BTreeMap::new(), - description: Some("Codex through codex app-server, in process (same as codex)".into()), + description: Some(description.into()), fallback: None, family: None, models: vec![], model: None, effort: None, policy: None, - }); - agents.insert( - "codex".to_owned(), - HarnessProfile { - kind: HarnessKind::Acp, - argv: vec![codex, "app-server".into()], - env: BTreeMap::new(), - description: Some("Codex through codex app-server, in process".into()), - fallback: None, - family: None, - models: vec![], - model: None, - effort: None, - policy: None, - }, - ); + }; + // Names the bridge stands in for, so sessions, presets and model picks + // that recorded them run it: its name before it became `codex`, and + // `codex-acp` when no adapter is on PATH. + let alias = "Codex through codex app-server, in process (same as codex)"; + agents.entry(CODEX_APP_SERVER.to_owned()).or_insert_with(|| bridge(alias)); + agents.entry(CODEX_ACP.to_owned()).or_insert_with(|| bridge(alias)); + agents.insert("codex".to_owned(), bridge("Codex through codex app-server, in process")); } agents } diff --git a/cmux-tui/crates/acpmux/src/config/preset_args.rs b/cmux-tui/crates/acpmux/src/config/preset_args.rs index 547a386ef73d..8e28c069d509 100644 --- a/cmux-tui/crates/acpmux/src/config/preset_args.rs +++ b/cmux-tui/crates/acpmux/src/config/preset_args.rs @@ -57,9 +57,55 @@ pub struct Preset { pub system_prompt_sha256: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, + /// The mode a session started from this preset starts in. One that does + /// not ask is set by the person only, and used without the person only + /// while `mode_approval` holds (`hub/person.rs`, (b)). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// The person's approval of this preset's mode: the + /// [`Preset::approval_digest`] of the contents the person approved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode_approval: Option, } impl Preset { + /// The sha256 (lowercase hex) of everything this preset starts a + /// session with: every field but `description` and `mode_approval`. + /// None only if it cannot be serialized (then nothing is approved). + pub fn approval_digest(&self) -> Option { + let bound = Self { description: None, mode_approval: None, ..self.clone() }; + // A struct of strings, lists and a BTreeMap: its JSON is stable. + serde_json::to_vec(&bound).ok().map(|bytes| crate::sha256::sha256_hex(&bytes)) + } + + /// `approveMode` of a preset write (`hub/person.rs` (b); the person's + /// own for `true`): bind the approval to the contents as they are now, + /// or withdraw it. A later change of the contents drops it. + pub fn apply_mode_approval(&mut self, approve: Option) -> Result<(), String> { + match approve { + Some(true) if self.mode.is_none() => { + Err("this preset has no mode to approve: set mode first".into()) + } + Some(true) => { + self.mode_approval = self.approval_digest(); + Ok(()) + } + Some(false) => { + self.mode_approval = None; + Ok(()) + } + None => Ok(()), + } + } + + /// Whether the person approved this preset's mode for its current contents. + pub fn mode_approved(&self) -> bool { + match (&self.mode, &self.mode_approval, self.approval_digest()) { + (Some(_), Some(approved), Some(now)) => cmux_local_auth::tokens_match(approved, &now), + _ => false, + } + } + /// Whether the preset changes the harness command line (args or a system /// prompt): never for a remote-origin session. pub fn shapes_command(&self) -> bool { diff --git a/cmux-tui/crates/acpmux/src/git_state.rs b/cmux-tui/crates/acpmux/src/git_state.rs new file mode 100644 index 000000000000..7737316bdc43 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/git_state.rs @@ -0,0 +1,276 @@ +//! A folder's git branch and HEAD commit, which session/new and session/fork +//! record in the session's meta (hq#1920: a session joins the pull request it +//! produced). Read from the repository's own files, never a git process, so +//! it costs a few small reads and cannot hang on a git that waits for input. +//! +//! The lookup finds `.git` in the folder or above it, follows a linked +//! worktree's or submodule's `gitdir:` file and its `commondir`, and resolves +//! HEAD through a loose ref, else `packed-refs`. Anything it cannot read +//! leaves that part `None`; it never fails the session. + +use std::fs::File; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; + +/// The git state of a folder at one moment. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GitState { + /// The checked-out branch, without `refs/heads/`: `None` on a detached + /// HEAD or outside a repository. + pub branch: Option, + /// The full HEAD commit: `None` outside a repository or on a branch with + /// no commit yet. + pub head: Option, +} + +/// Symbolic refs followed before giving up (a ref that names a ref). +const MAX_REF_DEPTH: usize = 5; +/// The most read of HEAD, a ref, `gitdir` or `commondir` (each one line). +const MAX_SMALL_FILE: u64 = 4 * 1024; +/// The most read of `packed-refs`. +const MAX_PACKED_REFS: u64 = 16 * 1024 * 1024; +/// The longest ref name taken as a branch. +const MAX_REF_NAME: usize = 1024; + +impl GitState { + /// The git state of `dir`, the folder a session runs in. + pub fn read(dir: &Path) -> Self { + // The physical folder, as git sees it: a symlinked folder walked by + // name would miss its repository. A missing folder has no state. + let Ok(dir) = std::fs::canonicalize(dir) else { + return Self::default(); + }; + if !dir.is_dir() { + return Self::default(); + } + let Some(git_dir) = find_git_dir(&dir) else { + return Self::default(); + }; + let common = common_dir(&git_dir); + let Some(head) = read_trimmed(&git_dir.join("HEAD")) else { + return Self::default(); + }; + let Some(name) = head.strip_prefix("ref: ").map(str::trim) else { + return Self { branch: None, head: is_object_id(&head).then_some(head) }; + }; + // A reftable repository keeps refs out of files; its HEAD names this + // placeholder. Its branch and commit are unreadable here, as are a + // name git would refuse (it is stored and shown to clients). + if name == "refs/heads/.invalid" || !is_ref_name(name) { + return Self::default(); + } + Self { + branch: name.strip_prefix("refs/heads/").map(str::to_owned), + head: resolve_ref(&git_dir, &common, name, 0), + } + } +} + +/// The repository's git directory for `dir`: the nearest `.git` folder at or +/// above it, or where the nearest `.git` file (`gitdir: `) points. +fn find_git_dir(dir: &Path) -> Option { + for folder in dir.ancestors() { + let dot_git = folder.join(".git"); + if dot_git.is_dir() { + return Some(dot_git); + } + if dot_git.is_file() { + let text = read_trimmed(&dot_git)?; + let target = text.strip_prefix("gitdir:")?.trim(); + return Some(folder.join(target)); + } + } + None +} + +/// Where the shared refs live: a linked worktree's `commondir`, else the +/// git directory itself. +fn common_dir(git_dir: &Path) -> PathBuf { + match read_trimmed(&git_dir.join("commondir")) { + Some(common) => git_dir.join(common), + None => git_dir.to_path_buf(), + } +} + +/// The commit `name` points to: its loose ref in the worktree's git +/// directory or the shared one, else its `packed-refs` line. +fn resolve_ref(git_dir: &Path, common: &Path, name: &str, depth: usize) -> Option { + if depth > MAX_REF_DEPTH || !is_ref_name(name) { + return None; + } + let loose = read_trimmed(&git_dir.join(name)).or_else(|| read_trimmed(&common.join(name))); + if let Some(text) = loose { + return match text.strip_prefix("ref: ") { + Some(next) => resolve_ref(git_dir, common, next.trim(), depth + 1), + None => is_object_id(&text).then_some(text), + }; + } + let packed = open_file(&common.join("packed-refs"))?.take(MAX_PACKED_REFS); + BufReader::new(packed).lines().map_while(Result::ok).find_map(|line| { + let (id, ref_name) = line.split_once(' ')?; + (ref_name.trim() == name && is_object_id(id)).then(|| id.to_owned()) + }) +} + +/// A SHA-1 or SHA-256 object id in git's lowercase hex. +fn is_object_id(text: &str) -> bool { + matches!(text.len(), 40 | 64) && text.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// A ref name git would accept as a path under the git directory: relative, +/// no empty, `.` or `..` part on either separator, no control characters. +fn is_ref_name(name: &str) -> bool { + name.len() <= MAX_REF_NAME + && !name.chars().any(char::is_control) + && name.split(['/', '\\']).all(|part| !matches!(part, "" | "." | "..")) + && !name.contains(':') +} + +/// `path` opened for reading only when it is a regular file: a FIFO or a +/// device (a planted `.git` file or ref) would block or never end. Opened +/// without blocking, then checked on the open handle, so a swap between the +/// check and the open cannot slip one through. +fn open_file(path: &Path) -> Option { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let file = options.open(path).ok()?; + file.metadata().ok()?.is_file().then_some(file) +} + +fn read_trimmed(path: &Path) -> Option { + let mut text = String::new(); + open_file(path)?.take(MAX_SMALL_FILE).read_to_string(&mut text).ok()?; + let text = text.trim(); + (!text.is_empty()).then(|| text.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + /// A fresh folder for one test, canonical (macOS's temp dir is a symlink). + fn scratch(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("acpmux-git-state-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(dir).unwrap() + } + + fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(["-c", "user.name=acpmux", "-c", "user.email=acpmux@example.com"]) + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .env_remove("GIT_INDEX_FILE") + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_owned() + } + + #[test] + fn git_state_reads_a_subfolder_of_a_repository() { + let dir = scratch("sub"); + git(&dir, &["init", "-q"]); + git(&dir, &["checkout", "-q", "-b", "topic"]); + git(&dir, &["commit", "-q", "--allow-empty", "-m", "init"]); + let head = git(&dir, &["rev-parse", "HEAD"]); + std::fs::create_dir_all(dir.join("a/b")).unwrap(); + let state = GitState::read(&dir.join("a/b")); + assert_eq!(state, GitState { branch: Some("topic".into()), head: Some(head) }); + } + + #[test] + fn git_state_reads_a_linked_worktree_with_packed_refs() { + let dir = scratch("worktree"); + let main = dir.join("main"); + std::fs::create_dir_all(&main).unwrap(); + git(&main, &["init", "-q"]); + git(&main, &["commit", "-q", "--allow-empty", "-m", "init"]); + let linked = dir.join("linked"); + git(&main, &["worktree", "add", "-q", "-b", "side", linked.to_str().unwrap()]); + git(&main, &["pack-refs", "--all"]); + let head = git(&linked, &["rev-parse", "HEAD"]); + let state = GitState::read(&linked); + assert_eq!(state, GitState { branch: Some("side".into()), head: Some(head) }); + } + + #[test] + fn git_state_of_a_branch_with_no_commit_has_no_head() { + let dir = scratch("unborn"); + git(&dir, &["init", "-q"]); + git(&dir, &["checkout", "-q", "-b", "fresh"]); + let state = GitState::read(&dir); + assert_eq!(state, GitState { branch: Some("fresh".into()), head: None }); + } + + /// A repository at `dir` on `branch` with one commit; returns that commit. + fn repo(dir: &Path, branch: &str) -> String { + git(dir, &["init", "-q"]); + git(dir, &["checkout", "-q", "-b", branch]); + git(dir, &["commit", "-q", "--allow-empty", "-m", "init"]); + git(dir, &["rev-parse", "HEAD"]) + } + + #[cfg(unix)] + #[test] + fn git_state_follows_a_symlinked_folder_to_its_repository() { + let dir = scratch("symlink"); + let main = dir.join("repo"); + std::fs::create_dir_all(main.join("sub")).unwrap(); + let head = repo(&main, "linked"); + let link = dir.join("link"); + std::os::unix::fs::symlink(main.join("sub"), &link).unwrap(); + let state = GitState::read(&link); + assert_eq!(state, GitState { branch: Some("linked".into()), head: Some(head) }); + } + + #[test] + fn git_state_of_a_missing_folder_is_empty() { + let dir = scratch("missing"); + repo(&dir, "topic"); + assert_eq!(GitState::read(&dir.join("gone")), GitState::default()); + } + + #[test] + fn git_state_drops_a_branch_name_git_would_refuse() { + let dir = scratch("badref"); + repo(&dir, "topic"); + for name in ["refs/heads/../../x", "refs/heads/a\u{1b}[31mb", "refs/heads/a\\..\\b"] { + std::fs::write(dir.join(".git/HEAD"), format!("ref: {name}\n")).unwrap(); + assert_eq!(GitState::read(&dir).branch, None, "{name:?}"); + } + } + + /// A HEAD that is a FIFO (or a device) is skipped, not read: reading it + /// would block session/new forever. + #[cfg(unix)] + #[test] + fn git_state_skips_a_head_that_is_not_a_regular_file() { + let dir = scratch("fifo"); + repo(&dir, "topic"); + let head = dir.join(".git/HEAD"); + std::fs::remove_file(&head).unwrap(); + let path = std::ffi::CString::new(head.to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(GitState::read(&dir)).unwrap()); + let state = + rx.recv_timeout(std::time::Duration::from_secs(5)).expect("read hung on a FIFO"); + assert_eq!(state, GitState::default()); + } +} diff --git a/cmux-tui/crates/acpmux/src/harness_health/auto.rs b/cmux-tui/crates/acpmux/src/harness_health/auto.rs index 9874257e2d1e..a1ca12e83ea8 100644 --- a/cmux-tui/crates/acpmux/src/harness_health/auto.rs +++ b/cmux-tui/crates/acpmux/src/harness_health/auto.rs @@ -9,6 +9,10 @@ //! //! The choice is a list of harness ids in `/harness-auto-update.json`, //! set with `_acpmux/harness/health {autoUpdate: {id, on}}`. +//! +//! Update Everything (cx-1mbnu, `_acpmux/harness/update`) runs the same +//! updaters on request (`run_updates`), whatever the choice, under the same +//! no-sudo rule. use super::{HarnessHealth, HealthService, Settings, now_ms, probe}; use serde::{Deserialize, Serialize}; @@ -51,12 +55,45 @@ fn read(path: Option<&Path>) -> BTreeSet { .unwrap_or_default() } -/// The updater a row runs on its own, if any. -fn runnable(row: &HarnessHealth) -> Option> { +/// The updater cmux may run for an outdated row, if any: a plan that needs +/// no elevated rights and whose program does not raise them. +pub fn updatable(row: &HarnessHealth) -> Option> { let plan = row.update.as_ref().filter(|p| !p.elevated)?; let program = plan.argv.first()?; let name = Path::new(program).file_name().and_then(|n| n.to_str()).unwrap_or(program); - (row.auto_update && row.outdated && !ELEVATING.contains(&name)).then(|| plan.argv.clone()) + (row.outdated && !ELEVATING.contains(&name)).then(|| plan.argv.clone()) +} + +/// The updater a row runs on its own, if any. +fn runnable(row: &HarnessHealth) -> Option> { + row.auto_update.then(|| updatable(row)).flatten() +} + +/// Runs one updater: whether it succeeded, and its last output line (or why +/// it did not finish). +async fn run_updater(argv: &[String]) -> (bool, String) { + let run = argv.to_vec(); + let outcome = + tokio::task::spawn_blocking(move || probe::run(&run, &BTreeMap::new(), UPDATE_LIMIT)) + .await + .unwrap_or_else(|e| Err(e.to_string())); + match outcome { + Ok((ok, text)) => { + (ok, text.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_owned()) + } + Err(e) => (false, e), + } +} + +/// What a requested update did for one harness (`run_updates`). +#[derive(Clone, Debug, PartialEq)] +pub enum Requested { + /// The updater ran: whether it succeeded, and its last output line. + Ran { ok: bool, detail: String }, + /// Nothing to run: the installed version is the newest known. + Current, + /// Not run, and why (an update that needs sudo, none known, no such harness). + Refused(String), } impl HealthService { @@ -104,6 +141,10 @@ impl HealthService { /// (one at a time, each command once, and not again for the same versions), /// and returns the ids it ran for. Callers wait for a run already going. pub async fn run_auto_updates(&self) -> Vec { + // A build session with agent launches off changes no installed CLI (cx-9ettq). + if crate::launch_policy::agent_launch_disabled() { + return Vec::new(); + } let _one = self.updating.lock().await; let mut done: BTreeMap, AutoUpdateResult> = BTreeMap::new(); let mut ran = Vec::new(); @@ -124,24 +165,7 @@ impl HealthService { let result = match done.get(&argv) { Some(result) => result.clone(), None => { - let run = argv.clone(); - let outcome = tokio::task::spawn_blocking(move || { - probe::run(&run, &BTreeMap::new(), UPDATE_LIMIT) - }) - .await - .unwrap_or_else(|e| Err(e.to_string())); - let (ok, detail) = match outcome { - Ok((ok, text)) => ( - ok, - text.lines() - .rev() - .find(|l| !l.trim().is_empty()) - .unwrap_or("") - .trim() - .to_owned(), - ), - Err(e) => (false, e), - }; + let (ok, detail) = run_updater(&argv).await; tracing::info!(harness = %row.id, ok, "auto-update ran"); let result = AutoUpdateResult { at: now_ms(), @@ -159,4 +183,65 @@ impl HealthService { } ran } + + /// Runs now, for the person (Update Everything, cx-1mbnu), the updater of + /// each row named in `ids` (every outdated row when None), whatever its + /// auto-update choice and earlier runs, under the same rule: never one + /// that needs sudo or whose program raises rights. One at a time, shared + /// with auto-update, and each command once. `check` runs once the + /// updates are this caller's (after any auto-update ends), so a version + /// an auto-update just installed is not installed again; its rows are + /// returned with the outcomes. + pub async fn run_updates( + &self, + check: impl std::future::Future>, + ids: Option<&BTreeSet>, + ) -> (Vec, Vec<(String, Requested)>) { + let _one = self.updating.lock().await; + let rows = check.await; + let rows = &rows[..]; + let asked: Vec = match ids { + Some(ids) => ids.iter().cloned().collect(), + None => rows.iter().filter(|r| r.outdated).map(|r| r.id.clone()).collect(), + }; + let mut done: BTreeMap, (bool, String)> = BTreeMap::new(); + let mut out = Vec::new(); + for id in asked { + let Some(row) = rows.iter().find(|r| r.id == id) else { + out.push((id, Requested::Refused("no such harness on this machine".into()))); + continue; + }; + if !row.outdated { + let unknown = || row.problem.clone().unwrap_or("its version is unknown".into()); + let answer = if row.version.is_some() { + Requested::Current + } else { + Requested::Refused(unknown()) + }; + out.push((id, answer)); + continue; + } + let Some(argv) = updatable(row) else { + let why = match &row.update { + Some(plan) => { + format!("its update needs elevated rights: run `{}`", plan.command) + } + None => "cmux cannot update it: update it the way it was installed".to_owned(), + }; + out.push((id, Requested::Refused(why))); + continue; + }; + let (ok, detail) = match done.get(&argv) { + Some(result) => result.clone(), + None => { + let result = run_updater(&argv).await; + tracing::info!(harness = %id, ok = result.0, "requested update ran"); + done.insert(argv, result.clone()); + result + } + }; + out.push((id, Requested::Ran { ok, detail })); + } + (rows.to_vec(), out) + } } diff --git a/cmux-tui/crates/acpmux/src/harness_health/mod.rs b/cmux-tui/crates/acpmux/src/harness_health/mod.rs index eea7fd9ac77d..a2bed4003c82 100644 --- a/cmux-tui/crates/acpmux/src/harness_health/mod.rs +++ b/cmux-tui/crates/acpmux/src/harness_health/mod.rs @@ -142,6 +142,11 @@ fn builtin_login(family: &str) -> Option<&'static str> { /// (`claude_auth`) only ever prove a sign-in, so none found stays unknown, /// never a guess from a missing file. fn builtin_auth(s: &Subject) -> Option<(Auth, Option)> { + // No credential read (Keychain, `codex login status`) while agent + // launches are off (cx-9ettq): the sign-in stays unknown. + if crate::launch_policy::agent_launch_disabled() { + return None; + } let program = Path::new(&s.program).file_name()?.to_string_lossy().to_lowercase(); let home_named = |key: &str| !s.unresolved.iter().any(|k| k == key); match s.family.as_str() { diff --git a/cmux-tui/crates/acpmux/src/harness_health/probe.rs b/cmux-tui/crates/acpmux/src/harness_health/probe.rs index 364cdacab417..0ddd1cfb05d3 100644 --- a/cmux-tui/crates/acpmux/src/harness_health/probe.rs +++ b/cmux-tui/crates/acpmux/src/harness_health/probe.rs @@ -30,6 +30,11 @@ pub(super) fn run_for( limit: Duration, family: Option<&str>, ) -> Result<(bool, String), String> { + // A sign-in check (`family` set) reads the user's credentials: none while + // agent launches are off (cx-9ettq). `--version` checks still run. + if family.is_some() && crate::launch_policy::agent_launch_disabled() { + return Err(crate::launch_policy::DISABLED_MESSAGE.to_owned()); + } let (program, args) = argv.split_first().ok_or("no command")?; let mut cmd = Command::new(program); crate::login_env::apply_std_for(&mut cmd, family, Some(env)); diff --git a/cmux-tui/crates/acpmux/src/hub/adoption.rs b/cmux-tui/crates/acpmux/src/hub/adoption.rs index 8c74042cbc09..eb5216f2621b 100644 --- a/cmux-tui/crates/acpmux/src/hub/adoption.rs +++ b/cmux-tui/crates/acpmux/src/hub/adoption.rs @@ -246,6 +246,20 @@ pub(super) fn session_cwd( Ok((cwd, remapped_from)) } +/// `session_cwd` and that folder's git branch and HEAD as the session starts +/// (`git_state.rs`). Both read the disk, so `session/new` runs this off the +/// async runtime. +pub(super) fn start_folder( + cwd: Option, + recorded: Option, + family: &str, + cwd_map: &[(PathBuf, PathBuf)], +) -> Result<(PathBuf, Option, crate::git_state::GitState), RpcError> { + let (cwd, remapped_from) = session_cwd(cwd, recorded, family, cwd_map)?; + let git = crate::git_state::GitState::read(&cwd); + Ok((cwd, remapped_from, git)) +} + /// Ok when `given` stands in for the gone `recorded` folder of a chat moved /// from another machine; else why not. `recorded` maps here through the first /// `cwd_map` entry (`FROM=TO`) that holds it, else through a home swap diff --git a/cmux-tui/crates/acpmux/src/hub/fork.rs b/cmux-tui/crates/acpmux/src/hub/fork.rs index 1b5787267aee..92199163571e 100644 --- a/cmux-tui/crates/acpmux/src/hub/fork.rs +++ b/cmux-tui/crates/acpmux/src/hub/fork.rs @@ -38,6 +38,13 @@ impl Hub { .clone() .ok_or_else(|| RpcError::internal("no agent session"))?; let cwd = cwd.unwrap_or_else(|| parent_meta.cwd.clone()); + // The fork's own folder as it is now, not the parent's start (`git_state.rs`). + let git = { + let cwd = cwd.clone(); + tokio::task::spawn_blocking(move || crate::git_state::GitState::read(&cwd)) + .await + .unwrap_or_default() + }; let id = uuid::Uuid::now_v7().to_string(); let turn_cut = { let cfg = self.config.read().await; @@ -144,6 +151,8 @@ impl Hub { adopted_cwd: None, harness_roots: vec![], composer_draft: None, + // It runs the parent's mode: a spawn from other preset contents leaves it too. + preset_mode_approval: parent_meta.preset_mode_approval.clone(), // Filled below with the fork's own seqs of the marked prompts it copies. bookmarks: Default::default(), // The fork keeps the chat's tools off (`hub/tools.rs`); its spawn lists them again. @@ -151,6 +160,8 @@ impl Hub { off: parent_meta.tools.off.clone(), ..Default::default() }, + git_branch: git.branch, + git_head: git.head, }; let new = self.make_session(meta); if is_claude && cut.is_none() { diff --git a/cmux-tui/crates/acpmux/src/hub/health_view.rs b/cmux-tui/crates/acpmux/src/hub/health_view.rs index 336338e473e0..ae9d0b89f945 100644 --- a/cmux-tui/crates/acpmux/src/hub/health_view.rs +++ b/cmux-tui/crates/acpmux/src/hub/health_view.rs @@ -80,6 +80,74 @@ impl Hub { Ok(json!({"harnesses": rows})) } + /// `_acpmux/harness/update {ids?}` (Update Everything, cx-1mbnu): checks + /// this machine's harnesses again (newest versions as last read), runs + /// now the updater of each named one (every outdated one without `ids`) + /// that needs no sudo (`harness_health::auto::run_updates`), waits for + /// them and checks again. Answers `{results: [{id, ok, version?, + /// error?}]}`; a harness already current is `ok` without a run. The + /// caller names only harness ids, never a command. Sessions keep their + /// process (`restart_harnesses` moves them); pooled ones end, so new + /// chats start the new version. + pub async fn update_harnesses(&self, params: &Value) -> Result { + let ids = match params.get("ids") { + None | Some(Value::Null) => None, + Some(Value::Array(list)) => Some( + list.iter() + .map(|v| v.as_str().map(str::to_owned)) + .collect::>>() + .ok_or_else(|| RpcError::invalid_params("ids must be harness ids"))?, + ), + Some(_) => return Err(RpcError::invalid_params("ids must be harness ids")), + }; + let check = async { self.health.check(self.health_subjects().await, false).await }; + let (rows, ran) = self.health.run_updates(check, ids.as_ref()).await; + use crate::harness_health::auto::Requested; + let any_ran = ran.iter().any(|(_, r)| matches!(r, Requested::Ran { .. })); + let rows = if any_ran { + if ran.iter().any(|(_, r)| matches!(r, Requested::Ran { ok: true, .. })) { + self.drain_pool(); + } + self.health.check(self.health_subjects().await, false).await + } else { + rows + }; + let row = |id: &str| rows.iter().find(|r| r.id == id); + let version = |id: &str| row(id).and_then(|r| r.version.clone()); + let results: Vec = ran + .into_iter() + .map(|(id, requested)| { + let (ok, error) = match requested { + // An updater that exits 0 but leaves the old version on PATH (an npm + // prefix other than the one PATH finds) did not update it. + Requested::Ran { ok: true, .. } if row(&id).is_some_and(|r| r.outdated) => ( + false, + Some(format!( + "the update ran, but {} still reports {}", + row(&id).map_or(id.as_str(), |r| r.program.as_str()), + version(&id).unwrap_or_default() + )), + ), + Requested::Ran { ok: true, .. } | Requested::Current => (true, None), + Requested::Ran { ok: false, detail } if detail.is_empty() => { + (false, Some("the update failed".to_owned())) + } + Requested::Ran { ok: false, detail } => (false, Some(detail)), + Requested::Refused(why) => (false, Some(why)), + }; + let mut out = json!({"id": id, "ok": ok}); + if let Some(v) = version(&id) { + out["version"] = json!(v); + } + if let Some(e) = error { + out["error"] = json!(e); + } + out + }) + .collect(); + Ok(json!({"results": results})) + } + /// Runs auto-update for the harnesses that want it, and checks again after any ran. pub async fn auto_update_harnesses(&self) -> Vec { let ran = self.health.run_auto_updates().await; diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index b679938a3e40..9d55907ebfe6 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -548,6 +548,14 @@ impl Hub { }; let _ = hub.finish_turn(&s, &c, result, &prompt_id, &turn_id, turn_seq).await; + // A route switch or an update asked for a restart + // during the turn (route_switch.rs): the process ends + // now and the next prompt starts it again, resuming + // the chat. Not started here: this task runs inside + // the adoption a start itself may run. + if s.route_switch.swap(false, Ordering::SeqCst) { + hub.detach_child(&s).await; + } drop(guard); }); } @@ -713,8 +721,10 @@ impl Hub { } /// Run new agents under agent hosts (see `agent_hosts_enabled`). + /// Windows: agent hosts are not ported yet, so agents stay direct + /// children in job objects (sessions do not outlive the daemon there). pub fn enable_agent_hosts(&self) { - self.agent_hosts.store(true, Ordering::SeqCst); + self.agent_hosts.store(cfg!(unix), Ordering::SeqCst); } pub(super) async fn spawn_hosted_child( @@ -726,7 +736,9 @@ impl Hub { translator: Option, tap: crate::agent::Tap, ) -> Result, RpcError> { - let internal = |e: anyhow::Error| RpcError::internal(format!("{e:#}")); + let internal = |e: anyhow::Error| { + crate::launch_policy::spawn_error(e, |e| RpcError::internal(format!("{e:#}"))) + }; // From the profile, before its CODEX_PATH picks the binary. let codex_bridge = command_line.is_none() && translator.is_none() diff --git a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs index b7deb8122f49..d7fa32810d97 100644 --- a/cmux-tui/crates/acpmux/src/hub/lifecycle.rs +++ b/cmux-tui/crates/acpmux/src/hub/lifecycle.rs @@ -2,7 +2,7 @@ use super::*; -use super::adoption::{Adoption, adopted_in, session_cwd}; +use super::adoption::{Adoption, adopted_in, start_folder}; use super::resolve::{Draft, Resolved, draft_meta}; impl Hub { @@ -28,7 +28,7 @@ impl Hub { let harness = harness.or_else(|| adopt.as_ref().and_then(|a| a.harness.clone())); // Resolution is a lookup, never a guess: preset → head (family or // profile) → defaults chain → explicit values on top. - let Resolved { agent, profile, defaults, head, preset_name, folder_root } = { + let Resolved { agent, profile, defaults, head, preset_name, folder_root, preset_digest } = { let cfg = self.config.read().await; self.resolve_new(&cfg, harness, &preset, model.as_deref(), remote, cwd.as_deref())? }; @@ -84,14 +84,13 @@ impl Hub { Adoption::Existing(existing) => return Ok(existing), Adoption::Found { cwd, fork, file, store } => (cwd, fork, file, store), }; - // A fork resumes nothing as itself: its process forks the adopted id - // into a new conversation, so the adopted id is not this session's. + // A fork resumes nothing as itself: it forks the adopted id into a new conversation. let fork_from = adopt.as_ref().filter(|_| fork).map(|a| a.agent_session_id.clone()); // A gone recorded folder may be remapped: git runs to compare repositories. let cwd_map = adopt.as_ref().map(|a| a.cwd_map.clone()).unwrap_or_default(); let fam = family.clone(); - let (cwd, adopted_cwd) = - tokio::task::spawn_blocking(move || session_cwd(cwd, recorded, &fam, &cwd_map)) + let (cwd, adopted_cwd, git) = + tokio::task::spawn_blocking(move || start_folder(cwd, recorded, &fam, &cwd_map)) .await .map_err(|e| RpcError::internal(e.to_string()))??; // A folder profile runs only in chats whose folder is inside its folder (H4). @@ -119,8 +118,8 @@ impl Hub { policy, remote, }); - (meta.session_env, meta.chat_store, meta.adopted_cwd) = - (session_env, chat_store, adopted_cwd); + (meta.session_env, meta.chat_store, meta.adopted_cwd, meta.git_branch, meta.git_head) = + (session_env, chat_store, adopted_cwd, git.branch, git.head); // A pooled session of exactly this shape (`pool/`) gives the session // its id; `ensure_child` then takes it instead of starting cold. // A pooled harness started without this session's env: never claimed. @@ -188,6 +187,7 @@ impl Hub { }; self.append(&session, "mux", "adopted", adopted); } + self.note_preset_resolved(&session, preset_digest); let spawned = match self.spawn_profile(&session, &profile, &defaults.env).await { Ok(spawn) => self.ensure_child(&session, &spawn).await, Err(e) => Err(e), @@ -303,7 +303,7 @@ impl Hub { pub(super) async fn ensure_child( self: &Arc, session: &Arc, - profile: &HarnessProfile, + profile: &super::spawn::Spawn, ) -> Result, RpcError> { // One spawn at a time per session; a caller that waited here finds // the child the previous holder started. @@ -339,8 +339,7 @@ impl Hub { self.record_spawn_inputs(session, profile).await; // A fresh agent: what acpmux set on the last one is replayed, not assumed. - *session.applied.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = - Default::default(); + self.fresh_agent(session, profile.preset_digest.clone()); // A pooled session claimed for this id (`pool/`): its host already // runs with the harness initialized and its session created. if let Some(pooled) = self.pool_take_claimed(&session.id) { @@ -447,7 +446,7 @@ impl Hub { }; let fresh_id = if resume.is_none() { Some(uuid::Uuid::now_v7().to_string()) } else { None }; - let effort = current_option(&meta, "effort").unwrap_or_else(|| "default".into()); + let effort = current_effort(&meta).unwrap_or_else(|| "default".into()); let fast = current_option(&meta, "fast-mode").as_deref() == Some("on"); let mode = meta .modes @@ -516,7 +515,9 @@ impl Hub { Some((&session.id, &meta.name)), ) .await - .map_err(|e| RpcError::internal(e.to_string()))? + .map_err(|e| { + crate::launch_policy::spawn_error(e, |e| RpcError::internal(e.to_string())) + })? } } else if self.agent_hosts_enabled() { self.spawn_hosted_child(session, profile, &meta, None, None, tap).await? @@ -532,7 +533,9 @@ impl Hub { Some((&session.id, &meta.name)), ) .await - .map_err(|e| RpcError::internal(e.to_string()))? + .map_err(|e| { + crate::launch_policy::spawn_error(e, |e| RpcError::internal(e.to_string())) + })? }; { // The shutdown marks itself started, then takes each session's @@ -817,6 +820,11 @@ impl Hub { wait: bool, only: Option>, ) { + // A probe starts the agent: none while launches are off (cx-9ettq). + if crate::launch_policy::agent_launch_disabled() { + tracing::info!("agent launch is disabled; model probes skipped"); + return; + } let picked = |n: &str| only.as_ref().is_none_or(|o| o.contains(n)); let agents: Vec<(String, HarnessProfile)> = { let cfg = self.config.read().await; diff --git a/cmux-tui/crates/acpmux/src/hub/live_models.rs b/cmux-tui/crates/acpmux/src/hub/live_models.rs index ffa88ec6e22d..046250acedf3 100644 --- a/cmux-tui/crates/acpmux/src/hub/live_models.rs +++ b/cmux-tui/crates/acpmux/src/hub/live_models.rs @@ -71,6 +71,12 @@ impl Hub { wait: bool, only: Option>, ) { + // A live list starts the CLI with its credentials: none while + // launches are off (cx-9ettq). + if crate::launch_policy::agent_launch_disabled() { + tracing::info!("agent launch is disabled; live model probes skipped"); + return; + } let handles: Vec<_> = { let cfg = self.config.read().await; targets(&cfg.harnesses) diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index e081cb2aa4f8..a5dc3b877005 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -24,7 +24,9 @@ mod hosts; mod lifecycle; pub(crate) mod model_availability; mod model_options; -pub use model_options::{current_model, current_option, resolve_config_id, web_url}; +pub use model_options::{ + current_effort, current_model, current_option, resolve_config_id, web_url, +}; mod model_hint; mod models_view; mod paging; @@ -36,6 +38,7 @@ pub use queue::QueuedPrompt; mod resolve; mod route_switch; pub use pool::{PrewarmRequest, RssProbe, tree_rss_bytes}; +pub use route_switch::HarnessRestarts; pub(crate) use route_switch::route_error; mod offers; mod secrets; @@ -60,6 +63,7 @@ pub use lifecycle::{ pub use paging::{EventFilter, EventPage}; pub use spawn::expand_env_value; mod peers; +pub(crate) use peers::PEER_PERMISSION_PENDING; mod permission_groups; mod permissions; pub mod person; @@ -67,6 +71,7 @@ mod processes; mod questions; mod remote_floor; mod remote_sandbox; +mod start_gate; pub use permission_groups::PERMISSION_GROUP_OPERATIONS; pub mod rules; mod transfer; diff --git a/cmux-tui/crates/acpmux/src/hub/model_options.rs b/cmux-tui/crates/acpmux/src/hub/model_options.rs index e81d29a07b9e..a5086e123fad 100644 --- a/cmux-tui/crates/acpmux/src/hub/model_options.rs +++ b/cmux-tui/crates/acpmux/src/hub/model_options.rs @@ -21,6 +21,15 @@ pub fn current_option(m: &SessionMeta, id: &str) -> Option { .and_then(|o| o.get("currentValue").and_then(Value::as_str).map(str::to_owned)) } +/// The effort a Claude Code session (re)starts with: its Reasoning level, or +/// `ultracode` while that switch is on. +pub fn current_effort(m: &SessionMeta) -> Option { + if current_option(m, crate::claude_stdio::ULTRACODE_OPTION).as_deref() == Some("on") { + return Some("ultracode".into()); + } + current_option(m, "effort") +} + /// The option id a harness uses for thinking effort, given the name a /// client asked for. `effort` is the portable name; Codex calls it /// `reasoning_effort`. diff --git a/cmux-tui/crates/acpmux/src/hub/peers.rs b/cmux-tui/crates/acpmux/src/hub/peers.rs index dfb6f7157546..6e332e675f0e 100644 --- a/cmux-tui/crates/acpmux/src/hub/peers.rs +++ b/cmux-tui/crates/acpmux/src/hub/peers.rs @@ -6,6 +6,10 @@ use super::*; /// ssh peer may take its ssh connect, tunnel and WebSocket timeouts in turn. const PEER_SETTLE_BUDGET: std::time::Duration = std::time::Duration::from_secs(30); +/// The record kind of a peer's `_acpmux/permission_pending`, relayed to +/// this daemon's watchers (`server/mod.rs` `deliver`); never stored. +pub(crate) const PEER_PERMISSION_PENDING: &str = "peer_permission_pending"; + impl Hub { // ------------------------------------------------------------- peers @@ -375,7 +379,27 @@ impl Hub { msg: params.get("msg").cloned().unwrap_or(Value::Null), host_seq: None, }, - // permission_pending is derived from the permission_request event locally. + // A prompt the person must answer, to watchers of this + // daemon that did not attach the session (attached + // clients derive it from the permission_request event): + // the peer's decision nonce and run come with it, so the + // app that holds the peer's key can answer + // (`hub/person.rs` (c)). Only the peer's own session + // in its own run, never one of this daemon's. + method::MUX_PERMISSION_PENDING + if !self.peer_pending_counts(&peer, &id, ¶ms) => + { + tracing::warn!(peer = %peer, session = %id, "a peer's pending prompt for a session or run that is not its own: dropped"); + return; + } + method::MUX_PERMISSION_PENDING => EventRecord { + seq: params.get("seq").and_then(Value::as_u64).unwrap_or(0), + at: now_ms(), + dir: "peer".into(), + kind: PEER_PERMISSION_PENDING.into(), + msg: params.clone(), + host_seq: None, + }, _ => return, }; let _ = self.events.send(HubEvent { @@ -388,6 +412,32 @@ impl Hub { } } + /// Whether peer `peer`'s `_acpmux/permission_pending` for session `id` + /// may reach this daemon's watchers (`hub/person.rs` (c)): a session the + /// peer listed as its own and that is not one of this daemon's, in the + /// daemon run the peer named at its handshake, which is not this run and + /// no other peer's. A prompt with this daemon's session or run would let + /// the peer show the person other text for one of this daemon's real + /// prompts, whose allow the app proves with this daemon's key. + fn peer_pending_counts(&self, peer: &str, id: &str, params: &Value) -> bool { + let Some(instance) = params.get("personInstance").and_then(Value::as_str) else { + return false; + }; + let listed = self + .remote_sessions + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(id) + .is_some_and(|r| r.peer == peer); + let local = self.sessions.lock().unwrap_or_else(|e| e.into_inner()).contains_key(id); + let peers: Vec<_> = + self.peers.lock().unwrap_or_else(|e| e.into_inner()).values().cloned().collect(); + let named = |p: &crate::peer::Peer| p.person_instance().as_deref() == Some(instance); + let own_run = peers.iter().any(|p| p.name == peer && named(p)); + let others = peers.iter().any(|p| p.name != peer && named(p)); + listed && !local && own_run && !others && instance != self.person.instance() + } + /// Look a key up among peer sessions: exact id, `peer/name`, or a unique name. pub fn resolve_remote(&self, key: &str) -> Option<(Arc, String, Value)> { let map = self.remote_sessions.lock().unwrap(); diff --git a/cmux-tui/crates/acpmux/src/hub/permission_groups.rs b/cmux-tui/crates/acpmux/src/hub/permission_groups.rs index 7da22b6f1946..7bd0448ff49d 100644 --- a/cmux-tui/crates/acpmux/src/hub/permission_groups.rs +++ b/cmux-tui/crates/acpmux/src/hub/permission_groups.rs @@ -74,16 +74,46 @@ impl Group { } fn value(&self, session_id: &str) -> Value { + use crate::protocol::permission::{ + PermissionDecision, PermissionGroup, PermissionGroupItem, + }; let can_allow = self .items .iter() .filter(|i| i.state == "pending") .all(|i| option(&i.request, "allow_once").is_some()); - json!({"groupId":self.id, "sessionId":session_id, "turnId":self.turn_id, - "revision":self.revision, "state":self.state, - "items":self.items.iter().map(|i| json!({"permissionId":i.id,"request":i.request,"state":i.state})).collect::>(), - "decisions":if can_allow {json!(["allow_once","allow_chat","deny"])} else {json!(["deny"])}, - "decision":self.receipt.as_ref().map(|(body,_)| body["decision"].clone())}) + let group = PermissionGroup { + group_id: self.id.clone(), + session_id: session_id.to_owned(), + turn_id: self.turn_id.clone(), + revision: self.revision, + state: self.state, + items: self + .items + .iter() + .map(|i| PermissionGroupItem { + permission_id: i.id.clone(), + request: i.request.clone(), + state: i.state, + }) + .collect(), + decisions: if can_allow { + vec![ + PermissionDecision::AllowOnce, + PermissionDecision::AllowChat, + PermissionDecision::Deny, + ] + } else { + vec![PermissionDecision::Deny] + }, + // Validated when it was taken (`respond_permission_group`). + decision: self + .receipt + .as_ref() + .and_then(|(body, _)| body["decision"].as_str()) + .and_then(PermissionDecision::from_wire), + }; + serde_json::to_value(group).unwrap_or(Value::Null) } } diff --git a/cmux-tui/crates/acpmux/src/hub/person.rs b/cmux-tui/crates/acpmux/src/hub/person.rs index eac8af350fc7..7fcb6f739538 100644 --- a/cmux-tui/crates/acpmux/src/hub/person.rs +++ b/cmux-tui/crates/acpmux/src/hub/person.rs @@ -82,6 +82,96 @@ //! table does not list; a spawn env). Such a request gets //! [`REASON`] and changes nothing: a prompt stays pending. //! +//! Starting in a mode that does not ask (a `session/new` with +//! `_meta.acpmux.mode`, or a preset's `mode`), without the person, needs +//! one of these; each grants a mode the person already approved, never a +//! wider one, and nothing else: +//! - (a) Inherit: the caller is an acpmux session whose current mode is +//! that exact mode on that same harness family (mode ids are per family). +//! The daemon, not the request, says which session calls: each harness it +//! starts gets `ACPMUX_SESSION_TOKEN` = lowercase hex HMAC-SHA256(run +//! secret, "acpmux-caller-session-v1" 0 session id) ([`caller_token`]), a +//! secret made at each daemon start and kept in memory only. A unix +//! socket connection whose FIRST request is an `initialize` with +//! `_meta.acpmux.callerSession {sessionId, token}` that matches (constant +//! time) is that session for its whole life ([`take_caller`]); the field +//! is stripped from every request and never read anywhere else, so a +//! `parentSessionId` or a `callerSession` in any other request counts for +//! nothing. The check reads the caller's mode at the time of the request: +//! a caller the person moved back to asking inherits nothing. A peer or +//! Web connection never has a caller. The harness also gets +//! `ACPMUX_SESSION_TOKEN_SOCKET`, this daemon's socket, and the CLI sends +//! the token only to that socket ([`caller_env`], `client.rs`). +//! - No start mode that does not ask applies to an adopt without the +//! person: an adopt may find a session already running +//! (`hub/adoption.rs`, `ifLive: "open"`), and only the person widens a +//! running session (`session/set_mode` above). Such a request is refused +//! before anything starts. +//! - (b) A preset the person approved: `_acpmux/presets {name, approveMode: +//! true}` from the person records `modeApproval`, the sha256 of the +//! preset's contents (everything but its description and the approval +//! itself, `Preset::approval_digest`). The approval counts only while the +//! preset still hashes to it and only for that preset's own mode, with no +//! other harness, no adopted conversation and no model, effort, policy or +//! env of the request's own (a folder is fine). The contents are read +//! three times for one start (the request check, the resolve of model, +//! effort and env, the spawn of args, env and system prompt): each read +//! records the digest of what it used, and the start counts the approval +//! only when the resolve's and the spawn's digests are the approved one +//! (`hub/start_gate/preset_bind.rs`), so an edit that is put back between +//! reads gains nothing. The spawn's digest is recorded only by the cold +//! start of the agent, under the session's spawn lock, for the profile it +//! actually started; a request that finds the agent running or re-adopts +//! its host records nothing. Such a preset never takes a pooled harness. +//! The session records the approved digest; a later cold start from other +//! contents (an edit, then a respawn) starts in the harness's own mode +//! instead of replaying the approved one, and a running agent keeps the +//! mode it runs, which its saved mode keeps saying. Bound to the +//! contents, not cleared on a write, so every way the preset can change +//! (a `_acpmux/presets` write from any client, a hand edit of config.json +//! and a reload, a future write path) drops it without having to know +//! about it; the exact contents the person approved are approved again. +//! Without the person, `approveMode: true` and a preset `mode` that does +//! not ask are refused; `approveMode: false` and an asking mode narrow and +//! stay open. +//! - (d) The refusal ([`mode_person_required`]) is one sentence that names +//! where to approve: cmux NEXT on this host ([`host_label`]), or the +//! preset to approve there once; `error.data` carries `host`, `mode` and +//! `preset` so an app can show its own (localized) text. A Web or peer +//! client never gets this host's name, in the text or in `host`. +//! - (c) A pending prompt reaches every app that sees the session: a peer's +//! `_acpmux/permission_pending` reaches the watchers of this daemon that +//! did not attach the session (`hub/peers.rs`), with the peer's +//! `decisionNonce` and `personInstance`; an answer still goes to the peer, +//! which counts an allow only with its own person's proof (above). The +//! proof covers the prompt's ids, not the text the app shows, so a peer +//! that re-sends a real prompt with other text could get the person's +//! allow of that text counted for the real one. A peer's prompt is +//! therefore relayed only for a session that peer listed as its own and +//! that is not this daemon's, in the run the peer named at its handshake, +//! which is neither this daemon's run nor one another peer named; and only +//! to unix socket and app watchers, never to a Web client or on to +//! another peer daemon. The app holds the key of the daemons it enrolled, +//! so this daemon's prompts (whose proofs it can make) never arrive with +//! a peer's text. Binding the proof to the prompt's text would need the +//! app to hash what it shows; not done. +//! Residual: the run a peer names at its handshake is a claim. A peer can +//! name a third daemon's run (one the app enrolled directly, say over +//! ssh) and re-send that daemon's prompt with other text; this daemon +//! cannot tell. The app has to refuse to prove a `via: "peer"` prompt whose +//! `personInstance` is a daemon it enrolled directly (an app follow-up). +//! +//! The default start mode (`hub/start_mode.rs`, cx-wx93i) is not a request: +//! with no `mode` asked, a new chat starts in the person's latest pick, else +//! `defaultPermissionMode` / `agents.defaultPermissionMode`, else the +//! harness's full-access mode, for every non-Web origin (also the unix +//! socket CLI). It never widens a proven caller session: one in a mode +//! that asks starts its chats in the harness's own mode +//! (`Hub::keeps_harness_mode`, as a handoff target of a source that asks +//! does). A request that names a mode skips the default; (a) and (b) gate +//! it as above. Presets, folder profiles, adopted chats and profiles that +//! pin a mode in their argv keep the harness's own mode. +//! //! Also without the person: no rules change under a session policy that //! does not ask (a rule is then what keeps a tool asking), no removal of a //! stored family-default or preset policy, no write of the `handoffKey` @@ -105,12 +195,37 @@ //! See docs/security/threat-model.md "Remote approvals (acpmux person key)". //! - Same-uid file writes: an agent that edits config.json, a profile file //! or the store directly. That needs an OS boundary between the agent and -//! the user's files. +//! the user's files. A preset's `modeApproval` is an unkeyed sha256 in +//! config.json, so such a write can approve a preset itself. +//! - A preset approval covers the preset's `harness` string, not the +//! profile it resolves to: `_acpmux/defaults` `prefer` or +//! `_acpmux/harness/add` can point that name at another program, which +//! `_acpmux/harness/add` can run anyway. +//! - The person's own pick of a no-ask mode on a preset session (not through +//! an approval) stays on respawn even when the preset changed meanwhile. //! - `_acpmux/harness/add` (a new harness profile), `_acpmux/defaults` //! `prefer` and a preset's `harness` (which profile, and so which profile //! default policy, a new session gets), a preset's `args`, //! `acp.trust.set`, `_acpmux/harness_enable` and `_acpmux/import` stay //! open to the unix socket. +//! - A caller token lives in its harness's environment, which any process +//! of the same user may read (`/proc//environ` on Linux, `ps -E` or +//! `KERN_PROCARGS2` on macOS). A process that reads it starts sessions in +//! that session's current mode only, which that session's own agent can +//! already do without asking (a no-ask mode runs any command). The +//! process still has to run somewhere: an agent of a session that asks +//! needs an allowed command or read to get there. Binding the token to the +//! connecting process's ancestry (peer pid) is a later hardening. +//! - A token dies with its daemon run: a harness that outlives a restart +//! (an agent host) inherits nothing until the next spawn. +//! - `session/new` with a `policy` other than ask or deny-all is open to the +//! unix socket (it predates this check); a mode is not. +//! - Open question (cx-wx93i): while the person's start mode is full +//! access (the default), a unix socket client that is no proven session, +//! such as `acpmux new` from a script, or an agent of a session that asks +//! that leaves out its caller token, gets a full-access chat without the +//! person. Limiting the default to the person (or LocalApp) would close +//! it; the person's pick of `standard` or `ask` closes it today. //! - DEV only: a same-uid process that starts the unsigned daemon before the //! app does gives it its own spawn key; the app then hands that daemon off //! (when its agents run under agent hosts), but the process held the @@ -118,6 +233,11 @@ use super::*; +pub use super::start_gate::{ + CALLER_FIELD, CALLER_SOCKET_ENV, CALLER_TOKEN_ENV, START_MODE_FIELD, StartMode, caller_env, + caller_proves, caller_token, host_label, mode_person_required, take_caller, +}; + /// The old place of the key in `initialize` (`_meta.acpmux`): stripped, never read. pub const KEY_FIELD: &str = "personKey"; /// The challenge in a local connection's `initialize` result (`_meta.acpmux`). @@ -525,10 +645,17 @@ impl Hub { /// "remote connection must be authed, and should be able to pretty much /// use yolo mode"; `Hub::any_mode_for_web`). The unix socket (the /// CLI, scripts, agents) still needs the person for a mode that does not ask. + /// `caller`: the acpmux session the connection proved it runs in + /// (module docs, (a)), never a value from the request. `starts_modes`: + /// a `session/new` on this connection starts in its mode (the unix + /// socket and the local app; a Web or peer one is settled to an asking + /// mode instead, `server/remote_guard.rs`). pub async fn person_check( &self, person: bool, remote_authenticated: bool, + caller: Option<&str>, + starts_modes: bool, m: &str, params: &Value, ) -> Result { @@ -542,7 +669,7 @@ impl Hub { // handler forwards it and never handles it here. return Ok(PersonVerdict::PeerOnly); } - self.person_check_local(remote_authenticated, m, params) + self.person_check_local(remote_authenticated, caller, starts_modes, m, params) .await .map(|()| PersonVerdict::Handle) } @@ -551,10 +678,13 @@ impl Hub { async fn person_check_local( &self, remote_authenticated: bool, + caller: Option<&str>, + starts_modes: bool, m: &str, params: &Value, ) -> Result<(), RpcError> { match m { + method::SESSION_NEW if starts_modes => self.session_new_check(caller, params).await, method::MUX_PERMISSION_RESPOND => { let option = params.get("optionId").filter(|v| !v.is_null()); let proved = option.is_some_and(|o| self.person_decided(params, o)); @@ -601,6 +731,9 @@ impl Hub { if !asking_policy_value(set.and_then(|s| s.get("policy"))) { return Err(person_required("a permission policy other than ask or deny-all")); } + if m == method::MUX_PRESETS { + self.preset_mode_check(params).await?; + } // Removing a stored policy (a clear, or `policy: null`) lets // the next layer's policy apply, which may not ask. let removes = params.get("clear").and_then(Value::as_bool) == Some(true) @@ -670,6 +803,8 @@ impl Hub { } if self.web_modes().config_value_asks(&family, id, value) { Ok(()) + } else if let (None | Some("mode"), Some(mode)) = (id, value) { + Err(mode_person_required(mode, None, false, starts_modes)) } else { Err(person_required("a mode that does not ask")) } diff --git a/cmux-tui/crates/acpmux/src/hub/pool/mod.rs b/cmux-tui/crates/acpmux/src/hub/pool/mod.rs index ba0f1f4c2c3e..301340f26415 100644 --- a/cmux-tui/crates/acpmux/src/hub/pool/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/pool/mod.rs @@ -312,6 +312,11 @@ impl Hub { "a remote-origin connection never uses the session pool", )); } + if crate::launch_policy::agent_launch_disabled() { + return Ok( + json!({"accepted": false, "reason": crate::launch_policy::DISABLED_MESSAGE}), + ); + } if !self.pool_enabled().await { return Ok(json!({"accepted": false, "reason": "the session pool is off"})); } @@ -505,6 +510,17 @@ impl Hub { if meta.remote_origin || !self.pool_enabled().await { return None; } + // A preset with a start mode or an approval starts cold: the approval + // holds for the contents this session's own spawn reads, not the ones + // a pooled harness started with (`hub/start_gate/preset_bind.rs`). Only + // the person sets an approval, so no client can clear it for a claim + // and get it back for the start. + let approved = |p: &crate::config::Preset| p.mode.is_some() || p.mode_approval.is_some(); + if let Some(name) = &meta.preset + && self.config.read().await.presets.get(name).is_some_and(approved) + { + return None; + } let spec = self.pool_spec_for(meta, profile, defaults_env).await.ok()?; loop { let took = lock(&self.pool.pool).take(&spec.key); diff --git a/cmux-tui/crates/acpmux/src/hub/pool/start.rs b/cmux-tui/crates/acpmux/src/hub/pool/start.rs index bc7ac0054e80..5bbbe1cdf51a 100644 --- a/cmux-tui/crates/acpmux/src/hub/pool/start.rs +++ b/cmux-tui/crates/acpmux/src/hub/pool/start.rs @@ -14,7 +14,7 @@ impl Hub { let (command_line, translator) = if claude { // As `ensure_child` starts a fresh Claude session. let fresh_id = uuid::Uuid::now_v7().to_string(); - let effort = current_option(draft, "effort").unwrap_or_else(|| "default".into()); + let effort = current_effort(draft).unwrap_or_else(|| "default".into()); let mode = "default".to_owned(); let model = current_model(draft).unwrap_or_else(|| "default".into()); let plan = crate::claude_stdio::spawn_plan( diff --git a/cmux-tui/crates/acpmux/src/hub/resolve.rs b/cmux-tui/crates/acpmux/src/hub/resolve.rs index 7f57867e35ee..6998e19fc842 100644 --- a/cmux-tui/crates/acpmux/src/hub/resolve.rs +++ b/cmux-tui/crates/acpmux/src/hub/resolve.rs @@ -91,6 +91,7 @@ impl Hub { head, preset_name: preset.clone(), folder_root, + preset_digest: preset_cfg.as_ref().and_then(|p| p.approval_digest()), }) } } @@ -134,6 +135,8 @@ pub(super) struct Resolved { pub(super) preset_name: Option, /// The folder of a folder profile (H4): the session's cwd must be inside it. pub(super) folder_root: Option, + /// The approval digest of the preset contents read here (`hub/start_gate/preset_bind.rs`). + pub(super) preset_digest: Option, } /// The inputs of a new session's meta (`draft_meta`). @@ -194,7 +197,11 @@ pub(super) fn draft_meta(d: Draft<'_>) -> SessionMeta { adopted_cwd: None, harness_roots: vec![], composer_draft: None, + preset_mode_approval: None, bookmarks: Default::default(), tools: Default::default(), + // Read off the async runtime by `session/new` (`adoption::start_folder`). + git_branch: None, + git_head: None, } } diff --git a/cmux-tui/crates/acpmux/src/hub/route_switch.rs b/cmux-tui/crates/acpmux/src/hub/route_switch.rs index 1133e2d2726d..0ddb117959c3 100644 --- a/cmux-tui/crates/acpmux/src/hub/route_switch.rs +++ b/cmux-tui/crates/acpmux/src/hub/route_switch.rs @@ -118,6 +118,86 @@ impl Hub { self.detach_child(session).await; self.child_for(session).await.map(|_| ()) } + + /// `_acpmux/sessions/restart_harness {harnesses?, sessions?}` (Update + /// Everything, cx-1mbnu; a chat's "Restart chat"): every live session of + /// this daemon (none mirrored from a peer; none of the Home Chief's unless + /// named in `sessions`), or those of the named harnesses and/or the named + /// sessions, restarts its harness process and resumes the same agent + /// session, as a route switch does but with no binding written: so it runs + /// the version an update just installed. A session between turns restarts + /// now; one in a turn when the turn ends (never cancelled). Returns how + /// many restarted now, how many restart after their turn, and each one + /// whose harness could not start again. + pub async fn restart_harnesses( + self: &Arc, + harnesses: Option<&std::collections::BTreeSet>, + sessions: Option<&std::collections::BTreeSet>, + ) -> HarnessRestarts { + let live = self.sessions().into_iter().filter(|session| { + let meta = session.meta(); + matches!( + meta.status, + SessionStatus::Ready | SessionStatus::Running | SessionStatus::Waiting + ) && (sessions.is_some() || !is_chief(&meta)) + && harnesses.is_none_or(|ids| ids.contains(&meta.harness)) + && sessions.is_none_or(|ids| ids.contains(session.id.as_str())) + }); + // Each restart waits for its new process (spawn, initialize, load): + // all at once, so many chats take as long as the slowest one. + let outcomes = futures::future::join_all(live.map(|session| async move { + // The turn lock is held through a turn and its route-switch + // restart (`turns.rs`, `hosts.rs`); holding it, no turn starts + // meanwhile. + let idle = || { + if session.turn().is_none() { session.turn_lock.try_lock().ok() } else { None } + }; + let mut turn = idle(); + if turn.is_none() { + session.route_switch.store(true, Ordering::SeqCst); + // The turn may have ended before the flag was seen: then now. + turn = idle().filter(|_| session.route_switch.load(Ordering::SeqCst)); + } + let Some(_turn) = turn else { return Ok(false) }; + match self.restart_on_route(&session).await { + Ok(()) => Ok(true), + Err(e) => { + tracing::warn!(session = %session.id, error = %e.message, "harness restart"); + Err((session.id.clone(), e.message)) + } + } + })) + .await; + let mut out = HarnessRestarts::default(); + for outcome in outcomes { + match outcome { + Ok(true) => out.now += 1, + Ok(false) => out.after_turn += 1, + Err(failed) => out.failed.push(failed), + } + } + out + } +} + +/// What `restart_harnesses` did: how many sessions restarted now, how many +/// restart when their turn ends, and `(session, error)` for each whose +/// harness could not start again. +#[derive(Debug, Default)] +pub struct HarnessRestarts { + pub now: usize, + pub after_turn: usize, + pub failed: Vec<(String, String)>, +} + +/// A Home Chief session (its turn, compactor or subagent): tagged +/// `cmux.chief`, or named `optchat-…`, or of an `optchat-compact-…` preset +/// harness (the app's `AcpmuxSessionCensus.isChief`). Update Everything +/// never counts or restarts one; only a caller naming it in `sessions` does. +fn is_chief(meta: &SessionMeta) -> bool { + live_tags(meta).get("cmux.chief").is_some() + || meta.name.starts_with("optchat-") + || meta.harness.starts_with("optchat-compact-") } impl Hub { diff --git a/cmux-tui/crates/acpmux/src/hub/session.rs b/cmux-tui/crates/acpmux/src/hub/session.rs index 490a6a406be0..755d02ca57ce 100644 --- a/cmux-tui/crates/acpmux/src/hub/session.rs +++ b/cmux-tui/crates/acpmux/src/hub/session.rs @@ -167,4 +167,9 @@ pub(crate) fn prompt_text(blocks: &[Value]) -> String { pub(super) struct Applied { pub model: Option, pub effort: Option, + /// The approval digest of the preset contents `session/new` resolved + /// with, and the one its harness last spawned with + /// (`start_gate/preset_bind.rs`). + pub preset_resolved: Option, + pub preset_spawned: Option, } diff --git a/cmux-tui/crates/acpmux/src/hub/spawn.rs b/cmux-tui/crates/acpmux/src/hub/spawn.rs index f6bb21e9e137..5712dc10b681 100644 --- a/cmux-tui/crates/acpmux/src/hub/spawn.rs +++ b/cmux-tui/crates/acpmux/src/hub/spawn.rs @@ -30,14 +30,19 @@ impl Hub { /// every env value and argv word. The preset's args (checked against the /// allowlist again) and its system prompt file (checked against its /// recorded sha256) are appended here, at every spawn; a remote-origin - /// session never gets either. + /// session never gets either. The approval digest of the preset contents + /// read here goes with the profile, and only a cold start records it + /// (`Hub::ensure_child`, `hub/start_gate/preset_bind.rs`): a request that + /// finds the agent running spawns nothing and records nothing. pub(super) async fn spawn_profile( &self, session: &Session, profile: &HarnessProfile, defaults_env: &std::collections::BTreeMap, - ) -> Result { - self.spawn_profile_for(&session.meta(), profile, defaults_env).await + ) -> Result { + let (profile, preset_digest) = + self.spawn_profile_read(&session.meta(), profile, defaults_env).await?; + Ok(Spawn { profile, preset_digest }) } /// `spawn_profile` for a session's meta, also before the session exists @@ -48,7 +53,19 @@ impl Hub { profile: &HarnessProfile, defaults_env: &std::collections::BTreeMap, ) -> Result { + self.spawn_profile_read(meta, profile, defaults_env).await.map(|(p, _)| p) + } + + /// `spawn_profile_for`, and the approval digest of the preset contents + /// it read (under the one config lock it read them with). + async fn spawn_profile_read( + &self, + meta: &SessionMeta, + profile: &HarnessProfile, + defaults_env: &std::collections::BTreeMap, + ) -> Result<(HarnessProfile, Option), RpcError> { let mut p = profile.clone(); + let mut digest = None; for (k, v) in defaults_env { p.env.entry(k.clone()).or_insert_with(|| v.clone()); } @@ -58,6 +75,7 @@ impl Hub { if let Some(name) = &meta.preset && let Some(preset) = cfg.presets.get(name) { + digest = preset.approval_digest(); if meta.remote_origin && preset.shapes_command() { return Err(RpcError::invalid_params(format!( "preset {name:?} now carries harness args or a system prompt, which a remote-origin session never starts with" @@ -113,7 +131,21 @@ impl Hub { if profile.kind == crate::config::HarnessKind::ClaudeStdio { p.argv.extend(super::tools::disallowed_args(&meta.tools)); } - Ok(p) + Ok((p, digest)) + } +} + +/// A session's spawn: the profile as it is spawned, and the approval digest +/// of the preset contents that profile was built from (under one config read). +pub(super) struct Spawn { + pub profile: HarnessProfile, + pub preset_digest: Option, +} + +impl std::ops::Deref for Spawn { + type Target = HarnessProfile; + fn deref(&self) -> &HarnessProfile { + &self.profile } } diff --git a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs index 5c916444e2d1..384e9dcd131b 100644 --- a/cmux-tui/crates/acpmux/src/hub/start_defaults.rs +++ b/cmux-tui/crates/acpmux/src/hub/start_defaults.rs @@ -41,6 +41,16 @@ pub(super) fn is_effort_id(id: &str) -> bool { EFFORT_IDS.contains(&id) } +/// The effort a `session/set_config_option` sets, if it sets one: an effort +/// option's value, or Claude Code's Ultracode switch (on runs `ultracode`, +/// off leaves xhigh). +pub(super) fn effort_of<'a>(config_id: &str, value: &'a Value) -> Option<&'a str> { + if config_id == crate::claude_stdio::ULTRACODE_OPTION { + return Some(if value == "on" { "ultracode" } else { "xhigh" }); + } + if is_effort_id(config_id) { value.as_str() } else { None } +} + /// Records that `session` runs `model` now. A model change can reset the /// harness's effort (Codex does), so the recorded effort is dropped. pub(super) fn model_applied(session: &Session, model: &str) { @@ -130,7 +140,9 @@ fn effort_fits(curated: Option<&CatalogHarness>, model: Option<&str>, effort: &s else { return true; }; - entry.efforts.as_ref().is_none_or(|list| list.iter().any(|e| e == effort)) + // Claude Code's Ultracode runs at xhigh. + let level = if effort == "ultracode" { "xhigh" } else { effort }; + entry.efforts.as_ref().is_none_or(|list| list.iter().any(|e| e == level)) } impl Hub { @@ -175,8 +187,9 @@ impl Hub { .clone(); let r = self.set_config(session, config_id, value.clone()).await?; let resolved = super::model_options::resolve_config_id(&session.meta(), config_id); - if pick && is_effort_id(&resolved) && before.as_deref() != value.as_str() { - self.note_pick(session, None, value.as_str()).await; + let effort = effort_of(&resolved, &value); + if pick && effort.is_some() && before.as_deref() != effort { + self.note_pick(session, None, effort).await; } // The mode as a config option (Claude Code's `mode`): a mode pick. if pick diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate.rs b/cmux-tui/crates/acpmux/src/hub/start_gate.rs new file mode 100644 index 000000000000..64514f8f4d1b --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/start_gate.rs @@ -0,0 +1,368 @@ +//! Starting a session in a mode that does not ask without the person: +//! the caller token (inherit), approved presets and the refusal that names +//! where to approve. The rules and the threat model are in `hub/person.rs` +//! ("Starting in a mode that does not ask"), (a), (b) and (d). + +use super::person::{REASON, person_required, valid_key}; +use super::*; + +mod preset_bind; +pub(crate) use preset_bind::StartGrant; + +/// The env var that carries a session's caller token to its harness (and +/// so to the acpmux CLI its agent runs). +pub const CALLER_TOKEN_ENV: &str = "ACPMUX_SESSION_TOKEN"; +/// The env var that names the socket of the daemon that issued the caller +/// token: the CLI sends the token to that socket only (`client.rs`). +pub const CALLER_SOCKET_ENV: &str = "ACPMUX_SESSION_TOKEN_SOCKET"; +/// `_meta.acpmux` field of a first `initialize`: `{sessionId, token}`. +pub const CALLER_FIELD: &str = "callerSession"; +/// `_meta.acpmux` field of `session/new`: the mode the session starts in. +pub const START_MODE_FIELD: &str = "mode"; + +/// This daemon run's caller secret: 32 random bytes, in memory only. +fn caller_secret() -> &'static [u8; 32] { + static SECRET: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new(); + SECRET.get_or_init(|| { + let mut bytes = [0u8; 32]; + // No randomness means no safe token: the daemon cannot serve. + getrandom::fill(&mut bytes).expect("the OS random generator for the caller secret"); // crash-allow: no randomness, no safe token + bytes + }) +} + +/// The caller token of session `session_id` on this daemon run (`hub/person.rs` +/// (a)): lowercase hex HMAC-SHA256(run secret, +/// "acpmux-caller-session-v1" 0 session id). +pub fn caller_token(session_id: &str) -> String { + let mut message = b"acpmux-caller-session-v1".to_vec(); + message.push(0); + message.extend_from_slice(session_id.as_bytes()); + cmux_local_auth::frontend_proof::hex(&cmux_local_auth::frontend_proof::hmac_sha256( + caller_secret(), + &message, + )) +} + +/// The env a harness of session `session_id` gets for (a): its caller token +/// and this daemon's socket, the one place the token is sent. +pub fn caller_env(session_id: &str) -> [(&'static str, String); 2] { + let socket = crate::config::socket_path().to_string_lossy().into_owned(); + [(CALLER_TOKEN_ENV, caller_token(session_id)), (CALLER_SOCKET_ENV, socket)] +} + +/// Whether `token` is the caller token of `session_id` (constant time). +pub fn caller_proves(session_id: &str, token: &str) -> bool { + valid_key(token) && cmux_local_auth::tokens_match(token, &caller_token(session_id)) +} + +/// Remove `_meta.acpmux.callerSession` from any request's params and return +/// its `(sessionId, token)`. Only a first `initialize` is ever read +/// (`server/mod.rs`); everywhere else it is dropped unread. +pub fn take_caller(params: &mut Value) -> Option<(String, String)> { + let taken = params + .pointer_mut("/_meta/acpmux") + .and_then(Value::as_object_mut) + .and_then(|m| m.remove(CALLER_FIELD))?; + let field = |k: &str| taken.get(k).and_then(Value::as_str).map(str::to_owned); + Some((field("sessionId")?, field("token")?)) +} + +/// The name of this host the refusal names (module docs, (d)): the host +/// name without a trailing `.local`, as the person sees it in cmux NEXT. +pub fn host_label() -> &'static str { + static HOST: std::sync::OnceLock = std::sync::OnceLock::new(); + HOST.get_or_init(|| { + let name = read_host_name().unwrap_or_default(); + let name = name.trim(); + let name = name.strip_suffix(".local").unwrap_or(name); + if name.is_empty() { "this machine".to_owned() } else { name.to_owned() } + }) +} + +#[cfg(unix)] +fn read_host_name() -> Option { + let mut buf = [0u8; 256]; + // SAFETY: a writable buffer of the length passed; gethostname writes at + // most that many bytes. + let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + if rc != 0 { + return None; + } + let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); + Some(String::from_utf8_lossy(&buf[..end]).into_owned()) +} + +#[cfg(not(unix))] +fn read_host_name() -> Option { + std::env::var("COMPUTERNAME").ok() +} + +/// The refusal of a mode that does not ask (module docs, (d)): one +/// sentence that names where the person approves it. `start`: a +/// `session/new`; else a mode change of a running session. `local`: the +/// unix socket or the local app; a Web or peer client never learns this +/// host's name (it reads "the Mac that runs it" instead, and no `host`). +pub fn mode_person_required( + mode: &str, + preset: Option<&str>, + start: bool, + local: bool, +) -> RpcError { + let host = if local { host_label() } else { "the Mac that runs it" }; + let message = match (start, preset) { + (true, Some(p)) => format!( + "mode {mode} does not ask before it acts, so only the person starts a session in it: approve preset {p} once in cmux NEXT on {host}, or start the session there" + ), + (true, None) => format!( + "mode {mode} does not ask before it acts, so only the person starts a session in it: start it in cmux NEXT on {host}, or approve a preset with this mode there once and use acpmux new --preset NAME" + ), + (false, _) => format!( + "mode {mode} does not ask before it acts, so only the person sets it: set it in cmux NEXT on {host}, or start the session from a preset approved there once" + ), + }; + let mut data = json!({"reason": REASON, "mode": mode, "preset": preset}); + if local { + data["host"] = json!(host); + } + RpcError::new(-32000, message).with_data(data) +} + +/// What a `session/new` asks to start in (module docs, (a) and (b)). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StartMode { + /// `_meta.acpmux.mode`, else the preset's mode. + pub mode: String, + /// The preset the request names, for the refusal. + pub preset: Option, + /// The request starts the named preset as it is: its own mode, its + /// harness, no adopted conversation and no model, effort, policy or env + /// of its own (module docs, (b)); a folder is fine. + pub preset_fits: bool, + /// `preset_fits`, and the person approved the preset's current contents. + /// The start checks it again against the contents the session resolved + /// and spawned with (`start_gate/preset_bind.rs`). + pub preset_approved: bool, + /// The request adopts a conversation (`_meta.acpmux.adopt`), which may be + /// a session already running: a mode that does not ask never applies to + /// it without the person. + pub adopts: bool, +} + +impl StartMode { + /// From `session/new` params against `cfg`; None when no mode is asked. + pub fn of(params: &Value, cfg: &crate::config::Config) -> Option { + let meta = params.pointer("/_meta/acpmux"); + let pick = |key: &str| -> Option { + meta.and_then(|m| m.get(key)) + .and_then(Value::as_str) + .or_else(|| params.get(key).and_then(Value::as_str)) + .map(str::to_owned) + }; + let requested = + meta.and_then(|m| m.get(START_MODE_FIELD)).and_then(Value::as_str).map(str::to_owned); + let preset_name = pick("preset"); + let preset = preset_name.as_deref().and_then(|n| cfg.presets.get(n)); + let mode = requested.or_else(|| preset.and_then(|p| p.mode.clone()))?; + let adopts = meta.and_then(|m| m.get("adopt")).is_some_and(|v| !v.is_null()); + let harness = pick("harness"); + let given = |key: &str| meta.and_then(|m| m.get(key)).or_else(|| params.get(key)); + let overrides = ["model", "effort", "policy"].into_iter().any(|k| pick(k).is_some()) + || given("env") + .is_some_and(|e| !e.is_null() && e.as_object().is_none_or(|o| !o.is_empty())); + let preset_fits = preset.is_some_and(|p| { + p.mode.as_deref() == Some(mode.as_str()) + && harness.as_deref().is_none_or(|h| h == p.harness) + && !adopts + && !overrides + }); + let preset_approved = preset_fits && preset.is_some_and(|p| p.mode_approved()); + Some(Self { mode, preset: preset_name, preset_fits, preset_approved, adopts }) + } +} + +impl Hub { + /// The session a first unix socket `initialize` proved it runs in + /// (`hub/person.rs` (a)): `(sessionId, token)` from [`take_caller`], + /// when the token is that session's on this run and the session is here. + pub fn proven_caller(&self, (id, token): (String, String)) -> Option { + if caller_proves(&id, &token) && self.resolve(&id).is_ok_and(|s| s.id == id) { + Some(id) + } else { + None + } + } + + /// A `session/new` without the person (module docs, (a), (b)): a mode + /// that does not ask only through an approved preset or from a caller + /// session in that mode. Checked against the harness the request + /// resolves to now; the handler checks the started session again. + pub(super) async fn session_new_check( + &self, + caller: Option<&str>, + params: &Value, + ) -> Result<(), RpcError> { + let meta = params.pointer("/_meta/acpmux"); + let pick = |key: &str| { + meta.and_then(|m| m.get(key)) + .and_then(Value::as_str) + .or_else(|| params.get(key).and_then(Value::as_str)) + .map(str::to_owned) + }; + // A session on a peer: that peer checks it. + if pick("peer").is_some_and(|p| !p.is_empty()) { + return Ok(()); + } + let (family, start) = { + let cfg = self.config.read().await; + let Some(start) = StartMode::of(params, &cfg) else { return Ok(()) }; + let adopted = + crate::adopt::AdoptRequest::from_meta(meta).ok().flatten().and_then(|a| a.harness); + let harness = pick("harness").or(adopted); + let cwd = params.get("cwd").and_then(Value::as_str).map(std::path::Path::new); + match self.resolve_new( + &cfg, + harness, + &pick("preset"), + pick("model").as_deref(), + false, + cwd, + ) { + Ok(r) => (crate::config::derive_family(&r.agent, &r.profile), start), + // Not startable: the handler answers why. + Err(_) => return Ok(()), + } + }; + self.start_mode_check(caller, &family, &start).map(|_| ()) + } + + /// Whether a client that is not the person may start a session of + /// `family` in `start` (module docs, (a), (b)), and why; the refusal + /// names where to approve it (d). An adopt never takes a mode that does + /// not ask: it may find a session already running, which only the + /// person widens (`session/set_mode`). + pub(crate) fn start_mode_check( + &self, + caller: Option<&str>, + family: &str, + start: &StartMode, + ) -> Result { + if self.web_modes().config_value_asks(family, None, Some(start.mode.as_str())) { + return Ok(StartGrant::Asks); + } + if start.adopts { + return Err(mode_person_required(&start.mode, start.preset.as_deref(), true, true)); + } + if start.preset_approved { + return Ok(StartGrant::Preset); + } + // The caller's mode now, on the session the daemon bound it to. + let inherits = + caller.and_then(|id| self.resolve(id).ok().filter(|s| s.id == id)).is_some_and(|s| { + let m = s.meta(); + crate::web_modes::family_of(&m) == family + && crate::web_modes::mode_of(&m).as_deref() == Some(start.mode.as_str()) + }); + if inherits { + Ok(StartGrant::Inherit) + } else { + Err(mode_person_required(&start.mode, start.preset.as_deref(), true, true)) + } + } + + /// Whether a `session/new` keeps the harness's own start mode rather + /// than the default one (`hub/start_mode.rs`): it names its own + /// (`StartMode`, set after the start), or a caller session that is not + /// the person runs in a mode that asks, so a chat it starts is never + /// wider than it ((a)). + pub(crate) async fn keeps_harness_mode( + &self, + person: bool, + caller: Option<&str>, + params: &Value, + ) -> bool { + if StartMode::of(params, &*self.config.read().await).is_some() { + return true; + } + let caller = caller.filter(|_| !person).and_then(|id| self.resolve(id).ok()); + caller.is_some_and(|s| { + let m = s.meta(); + let family = crate::web_modes::family_of(&m); + self.web_modes().config_value_asks( + &family, + None, + crate::web_modes::mode_of(&m).as_deref(), + ) + }) + } + + /// A `_acpmux/presets` write without the person (module docs, (b)): no + /// approval, and no `mode` that does not ask for the preset's family. + pub(super) async fn preset_mode_check(&self, params: &Value) -> Result<(), RpcError> { + if params.get("approveMode").and_then(Value::as_bool) == Some(true) { + return Err(person_required("approving a preset's mode")); + } + let Some(mode) = params.pointer("/set/mode").filter(|v| !v.is_null()) else { + return Ok(()); + }; + let refused = || person_required("a preset mode that does not ask"); + let mode = mode.as_str().ok_or_else(refused)?; + let family = { + let cfg = self.config.read().await; + let name = params.get("name").and_then(Value::as_str); + let harness = params + .pointer("/set/harness") + .and_then(Value::as_str) + .map(str::to_owned) + .or_else(|| name.and_then(|n| cfg.presets.get(n)).map(|p| p.harness.clone())); + harness + .and_then(|h| cfg.resolve_harness(&h).ok()) + .map(|profile| crate::config::derive_family(&profile, &cfg.harnesses[&profile])) + }; + match family { + Some(f) if self.web_modes().config_value_asks(&f, None, Some(mode)) => Ok(()), + _ => Err(refused()), + } + } + + /// A new session's start mode (`hub/person.rs` (a), (b)): + /// `_meta.acpmux.mode`, else its preset's. The request check ran + /// against the harness the request resolved to; this checks again + /// against the session as it started (`person`: the connection is the + /// person), with a preset's approval held against the contents the + /// session resolved and spawned with, then sets the mode. A refused or + /// failed mode ends a session this request created (never one an adopt + /// found already running). + pub(crate) async fn start_in_mode( + self: &Arc, + person: bool, + caller: Option<&str>, + params: &Value, + s: &Arc, + adopting: bool, + ) -> Result<(), RpcError> { + let Some((start, bound)) = self.bound_start(params, s).await else { return Ok(()) }; + let checked = if person { + Ok(StartGrant::Person) + } else { + self.start_mode_check(caller, &crate::web_modes::family_of(&s.meta()), &start) + }; + let applied = match checked { + Ok(grant) => match self.set_mode(s, &start.mode).await { + Ok(_) => { + self.note_start_grant(s, grant, bound); + Ok(()) + } + Err(e) => Err(e), + }, + Err(e) => Err(e), + }; + if let Err(e) = applied { + if !adopting { + let _ = self.kill(s, true).await; + } + return Err(e); + } + Ok(()) + } +} diff --git a/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs new file mode 100644 index 000000000000..a6d384aed806 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/hub/start_gate/preset_bind.rs @@ -0,0 +1,135 @@ +//! A preset's approval bound to what a session actually runs (`hub/person.rs` +//! (b)). The approval is checked when a request comes in, but the contents +//! are read again when the session resolves (model, effort, env) and at +//! every spawn (args, env, system prompt). The resolve and each cold start +//! of the agent record the [`crate::config::Preset::approval_digest`] of the +//! contents they used (a cold start under the session's spawn lock, with the +//! digest the profile was built from), and the start counts the approval +//! only when both match it. A later cold start from contents the person did +//! not approve does not get the mode the approval gave back. + +use super::*; + +/// Why a `session/new` may start in its mode (`Hub::start_mode_check`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StartGrant { + /// The connection is the person. + Person, + /// The mode asks. + Asks, + /// A preset the person approved, as the session runs it. + Preset, + /// The caller session's own mode. + Inherit, +} + +impl Hub { + /// The approval digest of the preset contents `session/new` resolved + /// with (model, effort, policy, env), before its first spawn. + pub(in crate::hub) fn note_preset_resolved(&self, s: &Session, digest: Option) { + s.applied.lock().unwrap_or_else(|e| e.into_inner()).preset_resolved = digest; + } + + /// A cold start of `s`'s agent (`Hub::ensure_child`, under the session's + /// spawn lock, after a live or re-adopted agent was ruled out): nothing + /// acpmux set on the last agent is assumed, and `digest` is the approval + /// digest of the preset contents this agent is spawned from. A session + /// that got its mode from a preset approval and now spawns from other + /// contents leaves that mode for the harness's own (module docs): the + /// saved mode is not replayed. Only here: a request that finds the agent + /// running (or re-adopts its host) spawns nothing, so it changes neither + /// the record nor the saved mode, which stays what that agent runs. + pub(in crate::hub) fn fresh_agent(&self, s: &Session, digest: Option) { + { + let mut a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); + *a = crate::hub::session::Applied { + preset_resolved: a.preset_resolved.take(), + preset_spawned: digest.clone(), + ..Default::default() + }; + } + let dropped = { + let mut m = s.meta.lock().unwrap_or_else(|e| e.into_inner()); + let kept = match (m.preset_mode_approval.as_deref(), digest.as_deref()) { + (None, _) => true, + (Some(approved), Some(now)) => cmux_local_auth::tokens_match(now, approved), + (Some(_), None) => false, + }; + if !kept { + m.preset_mode_approval = None; + let family = crate::web_modes::family_of(&m); + let mode = crate::web_modes::mode_of(&m); + if !self.web_modes().config_value_asks(&family, None, mode.as_deref()) { + forget_mode(&mut m); + } + } + !kept + }; + if dropped { + self.append(s, "mux", "preset_mode_dropped", json!({"preset": s.meta().preset})); + self.save_meta(s); + } + } + + /// `session/new`'s start mode, with the preset approval held against + /// the contents session `s` resolved and spawned with: the approved + /// digest when both match it (`StartMode::preset_approved` is then + /// `preset_fits` and that). None when the request asks for no mode. + pub(in crate::hub) async fn bound_start( + &self, + params: &Value, + s: &Session, + ) -> Option<(StartMode, Option)> { + let cfg = self.config.read().await; + let mut start = StartMode::of(params, &cfg)?; + let approval = start + .preset + .as_deref() + .and_then(|n| cfg.presets.get(n)) + .filter(|p| p.mode.is_some()) + .and_then(|p| p.mode_approval.clone()); + let bound = { + let a = s.applied.lock().unwrap_or_else(|e| e.into_inner()); + let same = |d: &Option, approved: &str| { + d.as_deref().is_some_and(|d| cmux_local_auth::tokens_match(d, approved)) + }; + approval.filter(|d| same(&a.preset_resolved, d) && same(&a.preset_spawned, d)) + }; + start.preset_approved = start.preset_fits && bound.is_some(); + Some((start, bound)) + } + + /// After the start mode is set: a mode a preset approval gave is + /// recorded with the approved digest, so a respawn from other contents + /// leaves it (`fresh_agent`). + pub(in crate::hub) fn note_start_grant( + &self, + s: &Session, + grant: StartGrant, + bound: Option, + ) { + if grant == StartGrant::Preset { + s.meta.lock().unwrap_or_else(|e| e.into_inner()).preset_mode_approval = bound; + self.save_meta(s); + } + } +} + +/// Forget a session's saved mode, so a spawn starts in the harness's own and +/// replays none: its ACP mode and every config option that is a mode, an +/// approval policy or a sandbox (`web_modes::MODE_FIELDS`). +fn forget_mode(m: &mut crate::store::SessionMeta) { + if let Some(modes) = m.modes.as_mut().and_then(Value::as_object_mut) { + modes.remove("currentModeId"); + } + let mode_like = |o: &Value| { + o.get("id") + .and_then(Value::as_str) + .is_some_and(|id| id != "modeId" && crate::web_modes::MODE_FIELDS.contains(&id)) + }; + if let Some(opts) = m.config_options.as_mut().and_then(Value::as_array_mut) { + for o in opts.iter_mut().filter(|o| mode_like(o)) { + o["currentValue"] = Value::Null; + } + } +} diff --git a/cmux-tui/crates/acpmux/src/hub/turns.rs b/cmux-tui/crates/acpmux/src/hub/turns.rs index 845e0dc1848f..4175890aad62 100644 --- a/cmux-tui/crates/acpmux/src/hub/turns.rs +++ b/cmux-tui/crates/acpmux/src/hub/turns.rs @@ -848,8 +848,8 @@ impl Hub { { let mut applied = session.applied.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - if super::start_defaults::is_effort_id(config_id) { - applied.effort = value.as_str().map(str::to_owned); + if let Some(effort) = super::start_defaults::effort_of(config_id, &value) { + applied.effort = Some(effort.to_owned()); } } Ok(r) diff --git a/cmux-tui/crates/acpmux/src/hub/views.rs b/cmux-tui/crates/acpmux/src/hub/views.rs index 6e92bf882782..9671b6bf4ade 100644 --- a/cmux-tui/crates/acpmux/src/hub/views.rs +++ b/cmux-tui/crates/acpmux/src/hub/views.rs @@ -110,18 +110,19 @@ pub fn decide(summary: &mut Value) { /// The summary's `lastMessage`, one sidebar line: the last line of words in /// the agent's reply, or the prompt while the turn has not answered yet. /// Null before the first prompt. -fn last_message(m: &SessionMeta) -> Value { +fn last_message(m: &SessionMeta) -> Option { + use crate::protocol::session::{LastMessage, MessageRole}; let reply = m .preview .as_deref() .and_then(|p| p.lines().rev().find(|l| l.chars().any(char::is_alphanumeric))); if let Some(line) = reply { - return json!({"role": "agent", "text": short_text(line, 160)}); - } - match m.last_prompt.as_deref().filter(|p| !p.trim().is_empty()) { - Some(prompt) => json!({"role": "user", "text": short_text(prompt, 160)}), - None => Value::Null, + return Some(LastMessage { role: MessageRole::Agent, text: short_text(line, 160) }); } + m.last_prompt + .as_deref() + .filter(|p| !p.trim().is_empty()) + .map(|prompt| LastMessage { role: MessageRole::User, text: short_text(prompt, 160) }) } /// What limits a session's agent as far as acpmux can tell. `policy` is @@ -159,60 +160,89 @@ impl Hub { } pub fn session_summary(&self, session: &Session) -> Value { + use crate::protocol::session::{QueuedPromptSummary, SessionSummary, TurnSummary}; let m = session.meta(); let turn = session.turn(); - let mut summary = json!({ - "sessionId": m.id, - "name": m.name, - "harness": m.harness, - "family": m.family, - "cwd": m.cwd, - "status": m.status.to_string(), - "agentSessionId": m.agent_session_id, - "createdAt": m.created_at, - "updatedAt": m.updated_at, - "lastSeq": m.last_seq, - "eventCount": m.event_count, - "turnCount": m.turn_count, - "parentId": m.parent_id, + let summary = SessionSummary { + session_id: m.id.clone(), + name: m.name.clone(), + harness: m.harness.clone(), + family: m.family.clone(), + cwd: m.cwd.to_string_lossy().into_owned(), + status: m.status.to_string(), + agent_session_id: m.agent_session_id.clone(), + created_at: m.created_at, + updated_at: m.updated_at, + last_seq: m.last_seq, + event_count: m.event_count, + turn_count: m.turn_count, + parent_id: m.parent_id.clone(), // Where a fork branched off its parent (the pane's < 1/2 > arrows, cx-ncc.61). - "forkSeq": m.fork_seq, - "title": m.title, - "lastPrompt": m.last_prompt, - "preview": m.preview.as_deref().map(|p| short_text(p, 160)), - "lastMessage": last_message(&m), - "queued": session.queued(), + fork_seq: m.fork_seq, + title: m.title.clone(), + last_prompt: m.last_prompt.clone(), + preview: m.preview.as_deref().map(|p| short_text(p, 160)), + last_message: last_message(&m), + queued: session.queued(), // A turn failed: the prompts that waited behind it wait for a client to resume them. - "queuePaused": session.queue_paused(), + queue_paused: session.queue_paused(), // Being moved to another daemon (`transfer_out.rs`): it takes no prompt meanwhile. - "moving": session.moving().is_some(), + moving: session.moving().is_some(), // The agent takes a prompt mid-turn (`_meta.acpmux.steer`); else a steer waits, or // with `interrupt` stops the turn and runs next. - "steering": session.steering.load(Ordering::SeqCst), - "turn": turn.map(|t| json!({"startedAt": t.started_at, "client": t.client, "prompt": t.prompt_preview, "turnId": t.turn_id, "promptId": t.prompt_id, "turnSeq": t.turn_seq})), - "queue": session.queue().into_iter().map(|q| json!({"promptId": q.prompt_id, "turnId": q.turn_id, "client": q.client, "prompt": q.preview, "queuedAt": q.queued_at, "held": q.held})).collect::>(), - "pendingPermissions": session.pending_permissions().len(), + steering: session.steering.load(Ordering::SeqCst), + turn: turn.map(|t| TurnSummary { + started_at: t.started_at, + client: t.client, + prompt: t.prompt_preview, + turn_id: t.turn_id, + prompt_id: t.prompt_id, + turn_seq: t.turn_seq, + }), + queue: session + .queue() + .into_iter() + .map(|q| QueuedPromptSummary { + prompt_id: q.prompt_id, + turn_id: q.turn_id, + client: q.client, + prompt: q.preview, + queued_at: q.queued_at, + held: q.held, + }) + .collect(), + pending_permissions: session.pending_permissions().len() as u64, // Since when it waits on the person (`lane`); null while it does not. - "inputSince": match session.input_since.load(Ordering::SeqCst) { - 0 => Value::Null, - since => json!(since), + input_since: match session.input_since.load(Ordering::SeqCst) { + 0 => None, + since => Some(since), }, - "currentModeId": m.modes.as_ref().and_then(|x| x.get("currentModeId")).cloned(), - "model": current_model(&m), - "policy": m.permission_policy, - "enforcement": self.session_enforcement(&m), - "rules": m.permission_rules.is_some(), + current_mode_id: m + .modes + .as_ref() + .and_then(|x| x.get("currentModeId")) + .cloned() + .unwrap_or(Value::Null), + model: current_model(&m), + policy: m.permission_policy.clone(), + enforcement: self.session_enforcement(&m), + rules: m.permission_rules.is_some(), // The chat's tools and whether `_acpmux/set_tools` can change them (`hub/tools.rs`). - "tools": m.tools.rows, - "toolsMutable": m.tools.mutable, - "tags": live_tags(&m), - "stateSeq": session.state_seq.load(Ordering::SeqCst), - "unread": m.unread, - "lastTurn": m.last_turn, - "attached": session.attached.load(Ordering::SeqCst), + tools: m.tools.rows.iter().map(Into::into).collect(), + tools_mutable: m.tools.mutable, + tags: live_tags(&m), + state_seq: session.state_seq.load(Ordering::SeqCst), + unread: m.unread, + last_turn: m.last_turn.clone(), + attached: session.attached.load(Ordering::SeqCst) as u64, // Some attached client shows the chat (`hub/seen.rs`). - "shown": self.shown(session), - }); + shown: self.shown(session), + // Set from the summary's own fields by `decide`. + indicator: None, + lane: None, + peer: None, + }; + let mut summary = serde_json::to_value(summary).unwrap_or(Value::Null); decide(&mut summary); summary } @@ -228,6 +258,9 @@ impl Hub { v["usage"] = m.usage.clone().unwrap_or(Value::Null); v["forkSeq"] = json!(m.fork_seq); v["rulesJson"] = m.permission_rules.clone().unwrap_or(Value::Null); + // The folder's branch and HEAD when the session started (`git_state.rs`). + v["gitBranch"] = json!(m.git_branch); + v["gitHead"] = json!(m.git_head); let mut pending = session.permissions.lock().unwrap().pending_records(); for p in &mut pending { p["personInstance"] = Value::String(self.person.instance().to_owned()); @@ -260,6 +293,8 @@ impl Hub { "store": cfg.store, "sessions": sessions.len(), "liveAgents": live, + // `deny`: this daemon starts no agent process (cx-9ettq). + "agentLaunch": crate::launch_policy::status_word(), // Hidden pre-created sessions; never counted above. "pool": self.pool_view_json(), // New agents outlive this daemon (agent hosts): a restart for an diff --git a/cmux-tui/crates/acpmux/src/job.rs b/cmux-tui/crates/acpmux/src/job.rs new file mode 100644 index 000000000000..1cc918039076 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/job.rs @@ -0,0 +1,105 @@ +//! A job object per agent on Windows: what the agent's own process group is +//! on Unix. Everything the agent starts (shells, tools, servers) is in its +//! job, so stopping the session stops all of it, and the job is closed with +//! kill-on-close, so nothing outlives the daemon's handle to it. +//! +//! The agent starts suspended (CREATE_SUSPENDED), joins its job, then +//! resumes: no process it starts can begin before it is in the job. + +use std::io; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; +use std::ptr::null; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, TerminateJobObject, +}; +use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED}; + +/// The creation flags of an agent: suspended (`contain` resumes it in its +/// job) and the leader of its own process group, so Ctrl+Break reaches it +/// and what it starts, and nothing else. +pub(crate) const AGENT_FLAGS: u32 = CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP; + +/// `CTRL_BREAK_EVENT`. +const CTRL_BREAK_EVENT: u32 = 1; + +#[link(name = "ntdll")] +unsafe extern "system" { + /// Resumes every thread of a process (ntdll; stable since Windows XP). + /// std and tokio give the process handle only, not the main thread's. + fn NtResumeProcess(process: HANDLE) -> i32; +} + +#[link(name = "kernel32")] +unsafe extern "system" { + /// Sends a console control event to a process group sharing our console. + fn GenerateConsoleCtrlEvent(event: u32, group: u32) -> i32; +} + +/// The polite stop (SIGTERM to the group on Unix): Ctrl+Break to the +/// agent's process group (`group` is its leader's pid). The daemon and its +/// agents share one console (hidden for a started daemon). False when no +/// event could be sent (no console); the caller ends the job after its +/// grace either way. +pub(crate) fn ctrl_break(group: u32) -> bool { + // SAFETY: no pointers; an unknown group only fails. + unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, group) != 0 } +} + +/// One agent's job. +pub(crate) struct Job(OwnedHandle); + +impl Job { + /// Puts the suspended `process` in a new kill-on-close job, then + /// resumes it. On an error the caller kills the process. + pub(crate) fn contain(process: RawHandle) -> io::Result { + // SAFETY: no name, default security. + let handle = unsafe { CreateJobObjectW(null(), null()) }; + if handle.is_null() { + return Err(io::Error::last_os_error()); + } + // SAFETY: a handle just created and owned here. + let job = Job(unsafe { OwnedHandle::from_raw_handle(handle) }); + // SAFETY: plain data. + let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: a job handle and a structure of the size passed. + if unsafe { + SetInformationJobObject( + job.raw(), + JobObjectExtendedLimitInformation, + (&raw const limits).cast(), + size_of::() as u32, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: valid job and process handles. + if unsafe { AssignProcessToJobObject(job.raw(), process) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: a process handle with resume rights (the creator's). + let status = unsafe { NtResumeProcess(process) }; + if status < 0 { + return Err(io::Error::other(format!("NtResumeProcess: NTSTATUS {status:#x}"))); + } + Ok(job) + } + + /// Ends every process in the job (SIGKILL to the group on Unix). + pub(crate) fn kill(&self) { + // SAFETY: a job handle owned here. + unsafe { TerminateJobObject(self.raw(), 1) }; + } + + fn raw(&self) -> HANDLE { + self.0.as_raw_handle() + } +} + +// The job handle is used only through its own calls. +unsafe impl Send for Job {} +unsafe impl Sync for Job {} diff --git a/cmux-tui/crates/acpmux/src/launch_policy.rs b/cmux-tui/crates/acpmux/src/launch_policy.rs new file mode 100644 index 000000000000..13ae582b9e72 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/launch_policy.rs @@ -0,0 +1,75 @@ +//! The build-session kill switch for agent launches (cx-9ettq). +//! +//! GUI test jobs run tagged apps with the user's real credentials. With +//! `ACPMUX_AGENT_LAUNCH=deny` (or `CMUX_AGENTS_DISABLED=1`) in the daemon's +//! environment, acpmux starts no agent process at all: the spawn points +//! (`ChildAgent::spawn_with`, `agent_host::link::spawn_within`) refuse, model +//! probes and pool prewarms skip, and `session/new` fails with +//! [`rpc_error`], which says why and is not retryable. +//! +//! The variable is read at each check, never cached, so the daemon reads the +//! value it was started with (the app passes its own environment on). + +use crate::rpc::RpcError; + +/// The variable that names the policy; `deny` turns launches off. +pub const LAUNCH_ENV: &str = "ACPMUX_AGENT_LAUNCH"; +/// The app-wide switch; `1` turns launches off too. +pub const DISABLED_ENV: &str = "CMUX_AGENTS_DISABLED"; +/// What a refused launch says, everywhere. +pub const DISABLED_MESSAGE: &str = "agent launch is disabled in this build session"; +/// `data.reason` of the refusal, for clients that branch on it. +pub const DISABLED_REASON: &str = "agent_launch_disabled"; +/// JSON-RPC code of the refusal (implementation-defined server error range). +pub const DISABLED_CODE: i64 = -32010; + +/// A spawn refused by the kill switch; callers map it with [`spawn_error`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AgentLaunchDisabled; + +impl std::fmt::Display for AgentLaunchDisabled { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(DISABLED_MESSAGE) + } +} + +impl std::error::Error for AgentLaunchDisabled {} + +/// Whether this process must not start agents. +pub fn agent_launch_disabled() -> bool { + disabled_by( + std::env::var(LAUNCH_ENV).ok().as_deref(), + std::env::var(DISABLED_ENV).ok().as_deref(), + ) +} + +fn disabled_by(launch: Option<&str>, disabled: Option<&str>) -> bool { + launch.is_some_and(|v| v.trim().eq_ignore_ascii_case("deny")) + || disabled.is_some_and(|v| v.trim() == "1") +} + +/// `Err(AgentLaunchDisabled)` when launches are off; called before any spawn. +pub fn refuse_if_disabled() -> anyhow::Result<()> { + if agent_launch_disabled() { Err(AgentLaunchDisabled.into()) } else { Ok(()) } +} + +/// The ACP error a refused launch returns to the client. +pub fn rpc_error() -> RpcError { + RpcError::new(DISABLED_CODE, DISABLED_MESSAGE) + .with_data(serde_json::json!({"reason": DISABLED_REASON, "retryable": false})) +} + +/// Maps a spawn failure: the kill switch keeps its own error, any other +/// failure goes through `other`. +pub fn spawn_error(e: anyhow::Error, other: impl FnOnce(anyhow::Error) -> RpcError) -> RpcError { + if e.chain().any(|c| c.downcast_ref::().is_some()) { + rpc_error() + } else { + other(e) + } +} + +/// `"deny"` or `"allow"`, for `_acpmux/status`. +pub fn status_word() -> &'static str { + if agent_launch_disabled() { "deny" } else { "allow" } +} diff --git a/cmux-tui/crates/acpmux/src/lib.rs b/cmux-tui/crates/acpmux/src/lib.rs index 3116c2a9c7b9..00eb4c89e073 100644 --- a/cmux-tui/crates/acpmux/src/lib.rs +++ b/cmux-tui/crates/acpmux/src/lib.rs @@ -31,11 +31,15 @@ pub mod cua_socket; pub mod cua_v2; pub mod daemon; pub mod deliver; +pub mod git_state; pub mod harness_admin; pub mod harness_config; pub mod harness_health; pub mod harness_limits; pub mod hub; +#[cfg(windows)] +mod job; +pub mod launch_policy; pub mod live_models; pub mod local_stream; pub mod login_env; @@ -50,6 +54,7 @@ pub mod probe_cleanup; pub mod prompt_context; pub mod prompt_provenance; pub mod protected_folders; +pub mod protocol; pub mod question_answer; pub mod registry; mod router_socket; diff --git a/cmux-tui/crates/acpmux/src/peer.rs b/cmux-tui/crates/acpmux/src/peer.rs index ea8fe0f2d2e5..ee1acfa5ebb1 100644 --- a/cmux-tui/crates/acpmux/src/peer.rs +++ b/cmux-tui/crates/acpmux/src/peer.rs @@ -63,6 +63,10 @@ pub struct Peer { /// The origin the peer serves this daemon as (its initialize reply): /// `peer` when it accepted the peer token, `remote` (Web) otherwise. served_as: StdMutex>, + /// The daemon run the peer named in its initialize reply + /// (`personInstance`): the only run its pending prompts may carry + /// (`hub/person.rs` (c)). A claim, not a proof. + person_instance: StdMutex>, /// Whether the peer advertised the folder-trust gate in initialize. trust_gate: AtomicBool, /// The peer token was held back: the transport is plain `ws://` to a @@ -102,6 +106,7 @@ impl Peer { last_error: StdMutex::new(None), remote_version: StdMutex::new(None), served_as: StdMutex::new(None), + person_instance: StdMutex::new(None), trust_gate: AtomicBool::new(false), withheld: AtomicBool::new(false), attached: StdMutex::new(HashSet::new()), @@ -199,6 +204,11 @@ impl Peer { self.remote_version.lock().unwrap().as_ref().map(|(_, b)| b.clone()) } + /// The daemon run the peer named at its last handshake. + pub fn person_instance(&self) -> Option { + self.person_instance.lock().unwrap_or_else(|e| e.into_inner()).clone() + } + pub fn supports_trust_gate(&self) -> bool { self.trust_gate.load(Ordering::SeqCst) } @@ -375,6 +385,10 @@ impl Peer { *me.remote_version.lock().unwrap() = Some((v, b)); *me.served_as.lock().unwrap_or_else(|e| e.into_inner()) = init.pointer("/_meta/acpmux/origin").and_then(Value::as_str).map(str::to_owned); + *me.person_instance.lock().unwrap_or_else(|e| e.into_inner()) = init + .pointer("/_meta/acpmux/personInstance") + .and_then(Value::as_str) + .map(str::to_owned); let advertised = init.pointer("/_meta/acpmux/features").and_then(Value::as_array).is_some_and( |features| features.iter().any(|f| f.as_str() == Some("trustGate")), diff --git a/cmux-tui/crates/acpmux/src/protocol/mod.rs b/cmux-tui/crates/acpmux/src/protocol/mod.rs new file mode 100644 index 000000000000..5194e2533871 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/protocol/mod.rs @@ -0,0 +1,322 @@ +//! The acpmux JSON-RPC surface as one registry: every method and +//! notification name, and for the typed ones the serde types of their +//! params, result or payload. `ir()` emits it as `spec/acpmux-protocol.json` +//! (JSON Schema 2020-12 subschemas from schemars); the web generates its +//! types and typed client from that file (`webviews/scripts/acpmux-protocol-codegen.ts`). +//! +//! A method whose types are not declared yet is listed with `params` and +//! `result` of `true` (any JSON), so the registry names the whole surface. +//! Tests fail when the committed IR is stale or when the dispatcher serves +//! a name this registry lacks (`protocol/tests.rs`). + +pub mod permission; +pub mod session; +#[cfg(test)] +mod tests; + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use schemars::generate::{SchemaGenerator, SchemaSettings}; +use serde_json::{Map, Value, json}; + +use crate::rpc::method; + +/// The IR format version. Bump on a change to the IR's own shape. +pub const IR_VERSION: &str = "1"; + +/// Who sends a message, and whether it is answered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// A client asks the daemon and gets a result. + Request, + /// A client tells the daemon; no answer (`session/cancel`). + ClientNotification, + /// The daemon tells clients; no answer. + Notification, + /// The daemon asks a client and gets a result (`session/request_permission`). + ServerRequest, + /// Never on the wire: the client library puts it on its own stream. + ClientLocal, +} + +impl Kind { + fn as_str(self) -> &'static str { + match self { + Kind::Request => "request", + Kind::ClientNotification => "client_notification", + Kind::Notification => "notification", + Kind::ServerRequest => "server_request", + Kind::ClientLocal => "client_local", + } + } +} + +type SchemaFn = fn(&mut SchemaGenerator) -> schemars::Schema; + +/// One registry entry. +#[derive(Clone, Copy)] +pub struct MethodDecl { + pub name: &'static str, + pub kind: Kind, + /// Standard ACP, passed through to the session's agent. + pub acp: bool, + /// `None`: not declared yet (any JSON). + pub params: Option, + /// `None`: not declared yet, or a notification. + pub result: Option, +} + +fn schema_of(generator: &mut SchemaGenerator) -> schemars::Schema { + generator.subschema_for::() +} + +const fn untyped(name: &'static str, kind: Kind) -> MethodDecl { + MethodDecl { name, kind, acp: false, params: None, result: None } +} + +const fn acp(name: &'static str, kind: Kind) -> MethodDecl { + MethodDecl { name, kind, acp: true, params: None, result: None } +} + +fn request(name: &'static str) -> MethodDecl { + MethodDecl { + name, + kind: Kind::Request, + acp: false, + params: Some(schema_of::

), + result: Some(schema_of::), + } +} + +fn notification(name: &'static str) -> MethodDecl { + MethodDecl { + name, + kind: Kind::Notification, + acp: false, + params: Some(schema_of::

), + result: None, + } +} + +/// Every method and notification acpmux serves or sends, in wire-name order +/// within each group. +pub fn registry() -> Vec { + use Kind::*; + use permission::*; + use session::*; + vec![ + // Standard ACP. + acp(method::INITIALIZE, Request), + acp(method::AUTHENTICATE, Request), + acp(method::SESSION_NEW, Request), + acp(method::SESSION_LOAD, Request), + acp(method::SESSION_RESUME, Request), + acp(method::SESSION_LIST, Request), + acp(method::SESSION_FORK, Request), + acp(method::SESSION_CLOSE, Request), + acp(method::SESSION_DELETE, Request), + acp(method::SESSION_PROMPT, Request), + acp(method::SESSION_CANCEL, ClientNotification), + acp(method::SESSION_SET_MODE, Request), + acp(method::SESSION_SET_MODEL, Request), + acp(method::SESSION_SET_CONFIG_OPTION, Request), + acp(method::SESSION_UPDATE, Notification), + acp(method::SESSION_REQUEST_PERMISSION, ServerRequest), + acp(method::CANCEL_REQUEST, ClientNotification), + // ACP extensions acpmux names in the acp.* space. + untyped(method::ACP_SESSION_FORK, Request), + untyped(method::ACP_TRUST_GET, Request), + untyped(method::ACP_TRUST_SET, Request), + untyped("catalog.get", Request), + untyped("catalog.refresh", Request), + untyped("catalog.changed", Notification), + // Sessions. + untyped(method::MUX_STATUS, Request), + request::(method::MUX_SESSIONS), + untyped(method::MUX_ATTACH, Request), + untyped(method::MUX_DETACH, Request), + untyped(method::MUX_VIEW, Request), + untyped(method::MUX_MARK_UNREAD, Request), + untyped(method::MUX_WATCH, Request), + untyped(method::MUX_RENAME, Request), + untyped(method::MUX_OFFER_SESSION, Request), + untyped(method::MUX_OFFER_RESPOND, Request), + untyped(method::MUX_SET_TOOLS, Request), + untyped(method::MUX_SUBAGENT_STOP, Request), + untyped(method::MUX_STATS, Request), + untyped(method::MUX_BOOKMARK_GET, Request), + untyped(method::MUX_BOOKMARK_SET, Request), + untyped(method::MUX_PROMPT_WITHDRAW, Request), + untyped(method::MUX_PROCESS_LIST, Request), + untyped(method::MUX_PROCESS_KILL, Request), + untyped(method::MUX_PROCESS_UPDATE, Notification), + untyped(method::MUX_KILL, Request), + untyped(method::MUX_ARCHIVE, Request), + untyped(method::MUX_SIDE, Request), + untyped(method::MUX_TAG, Request), + untyped(method::MUX_INFO, Request), + untyped(method::MUX_EVENTS, Request), + untyped(method::MUX_HISTORY, Request), + untyped(method::MUX_WAIT, Request), + untyped(method::MUX_ASK, Request), + untyped(method::MUX_WARM, Request), + untyped(method::MUX_PREWARM, Request), + untyped(method::MUX_QUEUE_REMOVE, Request), + untyped(method::MUX_QUEUE_EDIT, Request), + untyped(method::MUX_DRAFT_GET, Request), + untyped(method::MUX_DRAFT_SET, Request), + untyped(method::MUX_EXPORT, Request), + untyped(method::MUX_IMPORT, Request), + // Permissions. + request::(method::MUX_PERMISSION_RESPOND), + request::(method::MUX_PERMISSION_GROUPS), + request::( + method::MUX_PERMISSION_GROUP_RESPOND, + ), + request::( + method::MUX_PERMISSION_CHAT_REVOKE, + ), + untyped(method::MUX_SET_POLICY, Request), + untyped("_acpmux/set_default_policy", Request), + untyped(method::MUX_SET_RULES, Request), + untyped(method::MUX_PERSON_ENROLL, Request), + untyped(method::MUX_PERSON_PROVE, Request), + untyped(method::MUX_PERSON_HOLDS, Request), + untyped(method::MUX_SECRET_REQUEST, Request), + untyped(method::MUX_SECRET_SUBMIT, Request), + untyped(method::MUX_SECRET_RUN, Request), + // Chats (`server/chats.rs`). + untyped("_acpmux/chats", Request), + untyped("_acpmux/chats_watch", Request), + untyped("_acpmux/chat_open", Request), + untyped("_acpmux/chat_roots", Request), + untyped("_acpmux/chat_roots_record", Request), + untyped("_acpmux/chat_settings", Request), + untyped("_acpmux/chat_settled", Request), + untyped("_acpmux/chat_transcript", Request), + untyped("_acpmux/chat_changed", Notification), + untyped("_acpmux/chats_lagged", Notification), + // Moving a chat to another daemon (`transfer_out.rs`). + untyped("_acpmux/transfer_abort", Request), + untyped("_acpmux/transfer_begin", Request), + untyped("_acpmux/transfer_commit", Request), + untyped("_acpmux/transfer_finish", Request), + untyped("_acpmux/transfer_forget", Request), + untyped("_acpmux/transfer_read", Request), + untyped("_acpmux/transfer_receive", Request), + untyped("_acpmux/transfer_write", Request), + // Handoff. + untyped(method::MUX_HANDOFF_PREPARE, Request), + untyped(method::MUX_HANDOFF_GET, Request), + untyped(method::MUX_HANDOFF_DRAFT, Request), + untyped(method::MUX_HANDOFF_START, Request), + untyped(method::MUX_HANDOFF_DISCARD, Request), + // Harnesses, models, routes. + untyped(method::MUX_HARNESSES, Request), + untyped(method::MUX_HARNESS_ENABLE, Request), + untyped(method::MUX_HARNESS_ADD, Request), + untyped(method::MUX_HARNESS_REMOVE, Request), + untyped(method::MUX_HARNESS_RESTORE, Request), + untyped(method::MUX_HARNESS_DOCTOR, Request), + untyped(method::MUX_HARNESS_HEALTH, Request), + untyped(method::MUX_REGISTRY, Request), + untyped(method::MUX_DEFAULTS, Request), + untyped(method::MUX_PRESETS, Request), + untyped("_acpmux/models", Request), + untyped("_acpmux/directories", Request), + untyped(method::MUX_ROUTE_LIST, Request), + untyped(method::MUX_ROUTE_SHOW, Request), + untyped(method::MUX_ROUTE_ADD, Request), + untyped(method::MUX_ROUTE_EDIT, Request), + untyped(method::MUX_ROUTE_REMOVE, Request), + untyped(method::MUX_ROUTE_RESTORE, Request), + untyped(method::MUX_ROUTE_TEST, Request), + untyped(method::MUX_ROUTE_DEFAULT_SET, Request), + untyped(method::MUX_CHAT_ROUTE_SET, Request), + // Daemon. + untyped(method::MUX_WEB_MODES, Request), + untyped(method::MUX_WEB_TOKEN_ROTATE, Request), + untyped(method::MUX_RELOAD_CONFIG, Request), + untyped(method::MUX_SCHEMA, Request), + untyped(method::MUX_SHUTDOWN, Request), + untyped("_acpmux/peers", Request), + untyped("_acpmux/peer_add", Request), + untyped("_acpmux/peer_reconnect", Request), + untyped("_acpmux/peer_remove", Request), + // Notifications. + untyped(method::MUX_EVENT, Notification), + notification::(method::MUX_SESSION_CHANGED), + notification::(method::MUX_PERMISSION_PENDING), + untyped(method::MUX_HARNESSES_CHANGED, Notification), + untyped(method::MUX_PROMPT_ACCEPTED, Notification), + untyped("_acpmux/lagged", Notification), + untyped(method::MUX_DISCONNECTED, ClientLocal), + ] +} + +/// The registry as the IR document: `{version, methods[], types{}}`. Each +/// method's `params` and `result` is `{"$ref": "#/types/"}`, `true` +/// (not declared yet) or absent (a notification has no result). +pub fn ir() -> Value { + let mut settings = SchemaSettings::draft2020_12(); + settings.definitions_path = "/types".into(); + let mut generator = settings.into_generator(); + let mut methods = Vec::new(); + for decl in registry() { + let mut entry = Map::new(); + entry.insert("name".into(), json!(decl.name)); + entry.insert("kind".into(), json!(decl.kind.as_str())); + if decl.acp { + entry.insert("acp".into(), json!(true)); + } + let typed = |schema: Option, generator: &mut SchemaGenerator| { + schema.map_or(Value::Bool(true), |f| f(generator).to_value()) + }; + entry.insert("params".into(), typed(decl.params, &mut generator)); + if matches!(decl.kind, Kind::Request | Kind::ServerRequest) { + entry.insert("result".into(), typed(decl.result, &mut generator)); + } + methods.push(Value::Object(entry)); + } + let types: BTreeMap = generator + .definitions() + .iter() + .map(|(name, schema)| (name.clone(), schema.clone())) + .collect(); + canonical(&json!({"version": IR_VERSION, "methods": methods, "types": types})) +} + +/// The IR as committed: two-space JSON with a final newline. +pub fn ir_text() -> String { + let mut text = serde_json::to_string_pretty(&ir()).unwrap_or_default(); + text.push('\n'); + text +} + +/// A schemars transform: every property is required, `Option` ones too +/// (they stay nullable). For shapes whose nullable fields are always on +/// the wire, as `json!` wrote them. +pub fn require_all(schema: &mut schemars::Schema) { + let Some(object) = schema.as_object_mut() else { return }; + let names: Vec = object + .get("properties") + .and_then(Value::as_object) + .map(|properties| properties.keys().map(|name| Value::String(name.clone())).collect()) + .unwrap_or_default(); + object.insert("required".into(), Value::Array(names)); +} + +/// `value` with every object's keys sorted, so the IR does not depend on +/// serde_json's map order in this build. +fn canonical(value: &Value) -> Value { + match value { + Value::Object(map) => { + let sorted: BTreeMap<&String, Value> = + map.iter().map(|(key, value)| (key, canonical(value))).collect(); + Value::Object(sorted.into_iter().map(|(key, value)| (key.clone(), value)).collect()) + } + Value::Array(items) => Value::Array(items.iter().map(canonical).collect()), + other => other.clone(), + } +} diff --git a/cmux-tui/crates/acpmux/src/protocol/permission.rs b/cmux-tui/crates/acpmux/src/protocol/permission.rs new file mode 100644 index 000000000000..a66bce704bb7 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/protocol/permission.rs @@ -0,0 +1,257 @@ +//! Permission shapes: single answers, grouped batches +//! (`hub/permission_groups.rs`, docs/grouped-permissions.md) and the +//! `_acpmux/permission_pending` notification. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use super::require_all; + +/// `_meta.acpmux` on an answer. Every field is optional; a relayed allow +/// carries the decision proof, the person's app may say whose answer it +/// relays, and a peer forwarding for its Web client says `via: "web"`. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(deny_unknown_fields)] +pub struct AnswerMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_nonce: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub person_decision: Option, + /// `{device, feedItem}`, from the person's connection only. + #[serde(skip_serializing_if = "Option::is_none")] + pub answered_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub via: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(deny_unknown_fields)] +pub struct AnsweredBy { + pub device: String, + pub feed_item: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(deny_unknown_fields)] +pub struct RequestMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub acpmux: Option, + /// The app's gesture ticket for this exact pick (`transport.gesture`); + /// the app's relay reads it, acpmux does not. + #[serde(skip_serializing_if = "Option::is_none")] + pub cmux_gesture: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(deny_unknown_fields)] +pub struct PermissionRespondParams { + pub session_id: String, + pub permission_id: String, + /// The option chosen; absent cancels the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub option_id: Option, + /// A question's answers, by question. + #[serde(skip_serializing_if = "Option::is_none")] + pub answers: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct PermissionRespondResult {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PermissionDecision { + AllowOnce, + AllowChat, + Deny, +} + +impl PermissionDecision { + pub fn from_wire(text: &str) -> Option { + Some(match text { + "allow_once" => Self::AllowOnce, + "allow_chat" => Self::AllowChat, + "deny" => Self::Deny, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PermissionGroupState { + Collecting, + Pending, + Resolved, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PermissionItemState { + Pending, + Resolved, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PermissionGroupItem { + pub permission_id: String, + /// The agent's `session/request_permission` params. + #[schemars(with = "Map")] + pub request: Value, + #[schemars(with = "PermissionItemState")] + pub state: &'static str, +} + +/// A batch of permission requests from one turn, decided together. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(transform = require_all)] +pub struct PermissionGroup { + pub group_id: String, + pub session_id: String, + pub turn_id: Option, + pub revision: u64, + #[schemars(with = "PermissionGroupState")] + pub state: &'static str, + pub items: Vec, + /// What a respond may choose: all three, or `deny` alone when a pending + /// item has no single allow-once option. + pub decisions: Vec, + /// The decision taken, once one was. + pub decision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(deny_unknown_fields)] +pub struct PermissionGroupsParams { + pub session_id: String, + /// One group; absent lists them all. Never null. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(with = "String")] + pub group_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ChatAllowance { + pub active: bool, + pub expires: AllowanceExpiry, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AllowanceExpiry { + SessionStopOrDaemonRestart, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CoverageLabel { + AcpRequestsOnly, +} + +/// acpmux never claims host isolation. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Isolation { + Unverified, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PermissionCoverage { + pub label: CoverageLabel, + pub isolation: Isolation, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PermissionBatching { + pub window_ms: u64, + pub max_items: u64, + pub max_pending_groups: u64, + pub max_receipts: u64, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PermissionGroupList { + pub groups: Vec, + pub chat_allowance: ChatAllowance, + pub coverage: PermissionCoverage, + pub batching: PermissionBatching, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(deny_unknown_fields)] +pub struct PermissionGroupRespondParams { + pub session_id: String, + pub group_id: String, + /// The group revision the decision was made on. + pub revision: u64, + /// 1 to 256 bytes; the same key with the same body replays the receipt. + pub decision_key: String, + pub decision: PermissionDecision, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PermissionGroupReceipt { + pub group: PermissionGroup, + /// An earlier respond with the same key and body answered this. + pub replayed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(deny_unknown_fields)] +pub struct PermissionChatRevokeParams { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct PermissionChatRevokeReceipt { + /// Always false. + pub active: bool, +} + +/// To attached clients (and watchers not attached) when a permission or +/// question waits: the log record's message (the agent's request with +/// acpmux's `permissionId`) plus where it came from. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PermissionPending { + pub session_id: String, + pub seq: u64, + /// `attach` or `watch`. + pub via: String, + pub permission_id: String, + /// The agent's `session/request_permission` params. + #[schemars(with = "Map")] + pub request: Value, + /// Signs an allow relayed for the person (`hub/person.rs`). + #[serde(skip_serializing_if = "Option::is_none")] + pub decision_nonce: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub person_instance: Option, + /// The batch it joined, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub group_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + /// Anything else the log record holds. + #[serde(flatten)] + pub record: Map, +} diff --git a/cmux-tui/crates/acpmux/src/protocol/session.rs b/cmux-tui/crates/acpmux/src/protocol/session.rs new file mode 100644 index 000000000000..1ded3c5ac785 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/protocol/session.rs @@ -0,0 +1,254 @@ +//! Session shapes: `_acpmux/sessions` and `_acpmux/session_changed`. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::require_all; + +/// One session as every list shows it (`Hub::session_summary`). A peer's +/// session arrives as the peer daemon's own summary plus `peer`, with +/// `status` `unreachable` while the peer is gone; a peer on an older +/// acpmux may lack newer fields, so nullable ones are optional here. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionSummary { + pub session_id: String, + pub name: String, + pub harness: String, + pub family: Option, + /// Lossy when the path is not UTF-8. + pub cwd: String, + /// `idle`, `ready`, `running`, `waiting`, `disconnected` or `closed`; + /// a peer's session also `unreachable`. + pub status: String, + pub agent_session_id: Option, + pub created_at: u64, + pub updated_at: u64, + pub last_seq: u64, + pub event_count: u64, + pub turn_count: u64, + pub parent_id: Option, + /// Where a fork branched off its parent. + pub fork_seq: Option, + pub title: Option, + pub last_prompt: Option, + pub preview: Option, + /// One sidebar line; null before the first prompt. + pub last_message: Option, + pub queued: u64, + /// A turn failed: the prompts behind it wait for a client to resume them. + pub queue_paused: bool, + /// Being moved to another daemon; it takes no prompt meanwhile. + pub moving: bool, + /// The agent takes a prompt mid-turn. + pub steering: bool, + pub turn: Option, + pub queue: Vec, + pub pending_permissions: u64, + /// Since when it waits on the person (`lane`); null while it does not. + pub input_since: Option, + #[schemars(with = "Option")] + pub current_mode_id: Value, + pub model: Option, + pub policy: Option, + #[schemars(with = "Enforcement")] + pub enforcement: Value, + /// The session has its own permission rules. + pub rules: bool, + /// Tags that have not expired. + #[schemars(with = "BTreeMap")] + pub tags: Value, + pub state_seq: u64, + pub unread: bool, + #[schemars(with = "Option")] + pub last_turn: Option, + /// Attached clients. + pub attached: u64, + /// Some attached client shows the chat. + pub shown: bool, + /// The chat's tools (`hub/tools.rs`). + pub tools: Vec, + /// `_acpmux/set_tools` can change them. + pub tools_mutable: bool, + /// The status dot every client draws (`status_indicator`); null for none. + pub indicator: Option, + /// What it is doing and since when (`views::lane`); null for a closed session. + pub lane: Option, + /// The peer daemon a remote session runs on; absent for this daemon's own. + #[serde(skip_serializing_if = "Option::is_none")] + pub peer: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Indicator { + Input, + Working, + Failed, + Lost, + Done, +} + +/// One row of a chat's tool list (`store::ToolRow`). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(transform = require_all)] +pub struct ToolSummary { + /// `mcp:`, `skill:` or `plugin:`, unique in the chat. + pub id: String, + pub name: String, + /// `mcp`, `skill`, `plugin`, or `cmux` for cmux's own tools. + pub kind: String, + /// What gives it: `cmux`, or the harness that reported it (`claude`). + pub source: String, + pub enabled: bool, + /// `_acpmux/set_tools` can turn it on or off in this chat. + pub can_toggle: bool, +} + +impl From<&crate::store::ToolRow> for ToolSummary { + fn from(row: &crate::store::ToolRow) -> Self { + Self { + id: row.id.clone(), + name: row.name.clone(), + kind: row.kind.clone(), + source: row.source.clone(), + enabled: row.enabled, + can_toggle: row.can_toggle, + } + } +} + +/// The session's lane (cx-v9k2f), decided once by the daemon (`views::lane`). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(transform = require_all)] +pub struct Lane { + pub state: LaneState, + /// Unix ms the state began; null when the daemon does not know. + pub since: Option, + /// The failed or lost turn's error text. + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum LaneState { + Working, + Input, + Failed, + Lost, + Idle, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MessageRole { + Agent, + User, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct LastMessage { + pub role: MessageRole, + pub text: String, +} + +/// The running turn. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(transform = require_all)] +pub struct TurnSummary { + pub started_at: u64, + pub client: String, + pub prompt: String, + pub turn_id: String, + pub prompt_id: String, + pub turn_seq: u64, +} + +/// A prompt waiting for the running turn to end. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +#[schemars(transform = require_all)] +pub struct QueuedPromptSummary { + pub prompt_id: String, + pub turn_id: String, + pub client: String, + pub prompt: String, + pub queued_at: u64, + /// Waits behind a failed turn until the queue is resumed. + pub held: bool, +} + +/// What limits the session's agent as far as acpmux can tell. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(transform = require_all)] +pub struct Enforcement { + /// The acpmux permission policy (the session's own, else the daemon + /// default), or `unknown`. + pub policy: String, + /// Always `native_policy`. + pub label: String, + pub isolation: super::permission::Isolation, + pub detail: String, +} + +/// The outcome of the session's last turn. Keys the turn result lacked +/// are absent. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct LastTurn { + pub turn_id: Option, + pub prompt_id: Option, + /// `completed`, `failed` or `cancelled`. + pub status: Option, + pub stop_reason: Option, + pub error_text: Option, + pub error_source: Option, + /// Why a turn ended without an answer, e.g. `outcome_unknown`. + pub detail: Option, + pub ended_at: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SessionsParams {} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SessionsResult { + /// Local and peer sessions, newest first. + pub sessions: Vec, +} + +/// A purged session: only its id is left. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct PurgedSession { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum ChangedSession { + Summary(Box), + Purged(PurgedSession), +} + +/// To watchers when a session changed: its new summary (only its id when +/// purged), the coarse `kind` and the log record behind it. +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct SessionChanged { + pub session_id: String, + pub session: ChangedSession, + /// The record kind, or `queue`, `permission_resolved` or `purged`. + pub kind: String, + pub record_kind: String, + pub seq: u64, + /// The peer a remote session runs on. + #[serde(skip_serializing_if = "Option::is_none")] + pub peer: Option, +} diff --git a/cmux-tui/crates/acpmux/src/protocol/tests.rs b/cmux-tui/crates/acpmux/src/protocol/tests.rs new file mode 100644 index 000000000000..b0219c03ed4d --- /dev/null +++ b/cmux-tui/crates/acpmux/src/protocol/tests.rs @@ -0,0 +1,79 @@ +use std::collections::BTreeSet; +use std::path::PathBuf; + +use super::{ir_text, registry}; + +fn spec_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("spec/acpmux-protocol.json") +} + +/// The committed IR is what the registry emits. To refresh it: +/// `ACPMUX_PROTOCOL_BLESS=1 cargo test -p acpmux protocol_ir_is_current`, +/// then `bun run acpmux-protocol:codegen` in webviews. +#[test] +fn protocol_ir_is_current() { + let fresh = ir_text(); + let path = spec_path(); + if std::env::var_os("ACPMUX_PROTOCOL_BLESS").is_some() { + std::fs::create_dir_all(path.parent().unwrap_or(&path)).ok(); + std::fs::write(&path, &fresh).unwrap_or_else(|e| panic!("write {}: {e}", path.display())); + return; + } + let committed = std::fs::read_to_string(&path).unwrap_or_default(); + if committed != fresh { + // The whole IR, so a run without the tree's write access still yields it. + eprintln!( + "----- BEGIN acpmux-protocol.json -----\n{fresh}----- END acpmux-protocol.json -----" + ); + panic!( + "{} is stale: rerun with ACPMUX_PROTOCOL_BLESS=1 and regenerate the web types", + path.display() + ); + } +} + +#[test] +fn protocol_registry_names_are_unique() { + let mut seen = BTreeSet::new(); + for decl in registry() { + assert!(seen.insert(decl.name), "{} is declared twice", decl.name); + } +} + +/// Every name the dispatcher and its notifications use is in the registry: +/// the constants in `rpc::method` and the literal names in the server. +#[test] +fn protocol_registry_names_every_served_method() { + let names: BTreeSet<&str> = registry().iter().map(|d| d.name).collect(); + let rpc = include_str!("../rpc.rs"); + let module = rpc.split("pub mod method {").nth(1).unwrap_or_default(); + let constant = regex::Regex::new(r#"pub const [A-Z0-9_]+: &str = "([^"]+)";"#).unwrap(); + let literal = regex::Regex::new(r#""((?:_acpmux/|catalog\.|acp\.)[A-Za-z0-9_./]+)""#).unwrap(); + let mut missing = BTreeSet::new(); + for capture in constant.captures_iter(module) { + if !names.contains(&capture[1]) { + missing.insert(capture[1].to_owned()); + } + } + let sources = [ + include_str!("../server/requests.rs"), + include_str!("../server/mod.rs"), + include_str!("../server/chats.rs"), + include_str!("../server/secrets.rs"), + include_str!("../server/peer_forward.rs"), + include_str!("../server/remote_guard.rs"), + include_str!("../server/redact.rs"), + include_str!("../server/requests/composer.rs"), + ]; + // Prefixes the dispatcher matches on, not method names. + let prefixes = ["_acpmux/chat", "_acpmux/transfer_"]; + for source in sources { + for capture in literal.captures_iter(source) { + let name = &capture[1]; + if !names.contains(name) && !prefixes.contains(&name) { + missing.insert(name.to_owned()); + } + } + } + assert!(missing.is_empty(), "declare these in protocol::registry(): {missing:?}"); +} diff --git a/cmux-tui/crates/acpmux/src/rpc.rs b/cmux-tui/crates/acpmux/src/rpc.rs index 2341d94c49ec..1bb1c70962ce 100644 --- a/cmux-tui/crates/acpmux/src/rpc.rs +++ b/cmux-tui/crates/acpmux/src/rpc.rs @@ -281,6 +281,13 @@ pub mod method { /// Agent CLI health (hq #1875; `hub/health_view.rs`): the unix socket /// and the local app. pub const MUX_HARNESS_HEALTH: &str = "_acpmux/harness/health"; + /// Update Everything (cx-1mbnu; `hub/health_view.rs`): run now the + /// no-sudo updater of the named outdated harnesses. The unix socket only. + pub const MUX_HARNESS_UPDATE: &str = "_acpmux/harness/update"; + /// Update Everything: restart the live sessions' harness processes on + /// the installed version and resume them (`hub/route_switch.rs`). The + /// unix socket only. + pub const MUX_SESSIONS_RESTART_HARNESS: &str = "_acpmux/sessions/restart_harness"; // Routes: how a harness reaches its model provider (ROUTES R1-R3; // server/routes.rs). add, edit, remove, restore: the unix socket only; // the rest also the local app. Never Web or peer. diff --git a/cmux-tui/crates/acpmux/src/server/harness_admin.rs b/cmux-tui/crates/acpmux/src/server/harness_admin.rs index 5f20c790f93c..055fe5d1a74e 100644 --- a/cmux-tui/crates/acpmux/src/server/harness_admin.rs +++ b/cmux-tui/crates/acpmux/src/server/harness_admin.rs @@ -1,7 +1,10 @@ //! `_acpmux/harness/add|remove|restore|doctor` and `_acpmux/registry` //! (BRING-YOUR-OWN-HARNESS H2): the app's Settings, palette and MCP tools add //! and check a user's own harness through the daemon. The work is -//! `crate::harness_admin`, shared with the CLI. +//! `crate::harness_admin`, shared with the CLI. Also Update Everything's +//! `_acpmux/harness/update` and `_acpmux/sessions/restart_harness` +//! (cx-1mbnu), which the machine's cmux-tui daemon calls: like `add` and +//! `doctor` they run programs, so only the unix socket may. //! //! Who may call them (coordinator decision 2026-10-08): `add` writes a //! profile that runs a program and `doctor` starts a harness, so only the @@ -53,6 +56,8 @@ pub(super) const METHODS: &[&str] = &[ method::MUX_HARNESS_DOCTOR, method::MUX_REGISTRY, method::MUX_HARNESS_HEALTH, + method::MUX_HARNESS_UPDATE, + method::MUX_SESSIONS_RESTART_HARNESS, ]; pub(super) async fn handle( @@ -61,10 +66,16 @@ pub(super) async fn handle( m: &str, params: &Value, ) -> Result { - let spawns = matches!(m, method::MUX_HARNESS_ADD | method::MUX_HARNESS_DOCTOR); + let spawns = matches!( + m, + method::MUX_HARNESS_ADD + | method::MUX_HARNESS_DOCTOR + | method::MUX_HARNESS_UPDATE + | method::MUX_SESSIONS_RESTART_HARNESS + ); if spawns && origin != Origin::Local { return Err(RpcError::invalid_params( - "adding or checking a harness runs a program: only the unix socket may ask for it", + "adding, checking or updating a harness runs a program: only the unix socket may ask for it", )); } // A plain browser page on the listener (Web, never a peer) may read the @@ -168,7 +179,38 @@ pub(super) async fn handle( admin::registry_value(®, &crate::config::which) } method::MUX_HARNESS_HEALTH => return hub.harness_health(params).await, + method::MUX_HARNESS_UPDATE => return hub.update_harnesses(params).await, + method::MUX_SESSIONS_RESTART_HARNESS => { + let harnesses = id_set(params, "harnesses", "harnesses must be harness ids")?; + let sessions = id_set(params, "sessions", "sessions must be session ids")?; + let done = hub.restart_harnesses(harnesses.as_ref(), sessions.as_ref()).await; + let errors: Vec = done + .failed + .iter() + .map(|(session, error)| json!({"session": session, "error": error})) + .collect(); + json!({"restarted": done.now, "after_turn": done.after_turn, + "failed": errors.len(), "errors": errors}) + } other => return Err(RpcError::method_not_found(other)), }; Ok(reply) } + +/// An optional list of ids in `params[key]` (`restart_harness`'s filters): None when absent. +fn id_set( + params: &Value, + key: &str, + error: &str, +) -> Result>, RpcError> { + match params.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(list)) => list + .iter() + .map(|v| v.as_str().map(str::to_owned)) + .collect::>>() + .map(Some) + .ok_or_else(|| RpcError::invalid_params(error)), + Some(_) => Err(RpcError::invalid_params(error)), + } +} diff --git a/cmux-tui/crates/acpmux/src/server/mod.rs b/cmux-tui/crates/acpmux/src/server/mod.rs index 860a694bd35f..9b359dccd9ee 100644 --- a/cmux-tui/crates/acpmux/src/server/mod.rs +++ b/cmux-tui/crates/acpmux/src/server/mod.rs @@ -5,7 +5,6 @@ use crate::config::PermissionPolicy; use crate::hub::{EventFilter, Hub, HubEvent, VERSION}; use crate::rpc::{Message, RpcError, method}; -use crate::store::EventRecord; use anyhow::{Context, Result}; use cmux_local_auth::{ListenerPolicy, Refusal}; use futures_util::{SinkExt, StreamExt}; @@ -59,6 +58,8 @@ pub struct Conn { person: AtomicBool, /// `_acpmux/person_holds` calls on this connection (`hub/person.rs`). pub(super) holds_calls: std::sync::atomic::AtomicU32, + /// The acpmux session it runs in, proven at its first `initialize` (`hub/person.rs` (a)). + caller: std::sync::OnceLock, } impl Conn { @@ -67,6 +68,11 @@ impl Conn { self.person.load(Ordering::SeqCst) } + /// The acpmux session this connection proved it runs in (`hub/person.rs` (a)). + pub(super) fn caller(&self) -> Option<&str> { + self.caller.get().map(String::as_str) + } + /// Whether this connection gets the person challenge: the unix socket or the proven local app. pub(super) fn challengeable(&self) -> bool { matches!(self.origin, Origin::Local | Origin::LocalApp) @@ -79,10 +85,16 @@ impl Conn { /// the proof matches its own nonce and id. Nothing else ever does. fn bind_person(&self, hub: &Hub, m: &str, params: &mut Option) { let _ = params.as_mut().and_then(crate::hub::person::take_key); + // Stripped from every request; read from a first unix `initialize` only (person.rs (a)). + let caller = params.as_mut().and_then(crate::hub::person::take_caller); let n = self.requests.fetch_add(1, Ordering::SeqCst) + 1; if n == 1 && m == method::INITIALIZE && self.challengeable() { self.challenged.store(true, Ordering::SeqCst); } + let first_local = n == 1 && m == method::INITIALIZE && self.origin == Origin::Local; + if let Some(id) = caller.filter(|_| first_local).and_then(|c| hub.proven_caller(c)) { + let _ = self.caller.set(id); + } if n == 2 && m == method::MUX_PERSON_PROVE && self.challenged.load(Ordering::SeqCst) { let proof = params.as_ref().and_then(|p| p.get("proof")).and_then(Value::as_str); let transport = match self.origin { @@ -684,6 +696,7 @@ async fn serve_connection_from( nonce: crate::hub::person::new_nonce(), person: AtomicBool::new(false), holds_calls: std::sync::atomic::AtomicU32::new(0), + caller: std::sync::OnceLock::new(), }); tracing::debug!(conn = %conn.id, "client connected"); @@ -854,6 +867,18 @@ fn deliver(hub: &Hub, conn: &Conn, ev: HubEvent) { } if watching { if let Some(remote) = &ev.remote { + // A peer's prompt the person must answer, for a unix socket or app + // watcher that did not attach the session (`hub/person.rs` (c)): + // never relayed on to a Web client or another peer daemon. + if rec.kind == crate::hub::PEER_PERMISSION_PENDING { + if !attached && !conn.origin.web_class() { + conn.send(&Message::notification( + method::MUX_PERMISSION_PENDING, + peer_permission_pending(&ev.session_id, rec, &remote.peer), + )); + } + return; + } // Peers already filter to the interesting kinds and send a summary. if rec.dir == "peer" { let sid = remote @@ -921,35 +946,6 @@ fn deliver(hub: &Hub, conn: &Conn, ev: HubEvent) { } } -/// A record that asks a client to answer a prompt: a new one, or one the -/// next daemon adopted with a new decision nonce (`hub/hosts.rs`). -fn is_permission_ask(kind: &str) -> bool { - matches!(kind, "permission_request" | "permission_reissued") -} - -/// The `_acpmux/permission_pending` notification. The record's -/// `decisionNonce` and `personInstance` (the daemon run that asks, also for a -/// peer's session) are what a relayed allow's decision proof covers -/// (`hub/person.rs`, cx-0q7o). -fn permission_pending(session_id: &str, rec: &EventRecord, via: &str) -> Value { - let mut p = rec.msg.clone(); - p["sessionId"] = Value::String(session_id.to_owned()); - p["seq"] = json!(rec.seq); - p["via"] = Value::String(via.to_owned()); - p -} - -pub fn event_value(session_id: &str, rec: &EventRecord) -> Value { - json!({ - "sessionId": session_id, - "seq": rec.seq, - "at": rec.at, - "dir": rec.dir, - "kind": rec.kind, - "msg": rec.msg, - }) -} - fn str_param<'a>(params: &'a Value, key: &str) -> Option<&'a str> { params.get(key).and_then(Value::as_str) } @@ -1002,7 +998,10 @@ mod harness_enable; pub mod local_app; pub mod peer_auth; mod peer_forward; +mod pending; mod processes; +pub use pending::event_value; +use pending::{is_permission_ask, peer_permission_pending, permission_pending}; mod redact; pub(crate) mod remote_guard; mod requests; diff --git a/cmux-tui/crates/acpmux/src/server/pending.rs b/cmux-tui/crates/acpmux/src/server/pending.rs new file mode 100644 index 000000000000..a0ade60277c2 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/server/pending.rs @@ -0,0 +1,47 @@ +//! The `_acpmux/permission_pending` notifications a connection gets: a +//! prompt of this daemon's session, or a peer's (`hub/person.rs` (c)). + +use super::*; +use crate::store::EventRecord; + +/// A record that asks a client to answer a prompt: a new one, or one the +/// next daemon adopted with a new decision nonce (`hub/hosts.rs`). +pub(super) fn is_permission_ask(kind: &str) -> bool { + matches!(kind, "permission_request" | "permission_reissued") +} + +/// The `_acpmux/permission_pending` notification. The record's +/// `decisionNonce` and `personInstance` (the daemon run that asks, also for a +/// peer's session) are what a relayed allow's decision proof covers +/// (`hub/person.rs`, cx-0q7o). +pub(super) fn permission_pending(session_id: &str, rec: &EventRecord, via: &str) -> Value { + let mut p = rec.msg.clone(); + p["sessionId"] = Value::String(session_id.to_owned()); + p["seq"] = json!(rec.seq); + p["via"] = Value::String(via.to_owned()); + p +} + +/// A peer's `_acpmux/permission_pending` for a watcher of this daemon that +/// did not attach the session (`hub/person.rs` (c)): the peer's own +/// notification (its `decisionNonce` and `personInstance` included), for the +/// session it was checked for (`hub/peers.rs`), with which peer asks. +pub(super) fn peer_permission_pending(session_id: &str, rec: &EventRecord, peer: &str) -> Value { + let mut p = rec.msg.clone(); + p["sessionId"] = Value::String(session_id.to_owned()); + p["peer"] = Value::String(peer.to_owned()); + p["via"] = Value::String("peer".into()); + p +} + +/// A record as `_acpmux/event` sends it. +pub fn event_value(session_id: &str, rec: &EventRecord) -> Value { + json!({ + "sessionId": session_id, + "seq": rec.seq, + "at": rec.at, + "dir": rec.dir, + "kind": rec.kind, + "msg": rec.msg, + }) +} diff --git a/cmux-tui/crates/acpmux/src/server/requests.rs b/cmux-tui/crates/acpmux/src/server/requests.rs index 365824e129ee..d58b66f376b9 100644 --- a/cmux-tui/crates/acpmux/src/server/requests.rs +++ b/cmux-tui/crates/acpmux/src/server/requests.rs @@ -69,7 +69,16 @@ pub(super) async fn handle_request( let remote_authenticated = conn.auth.authenticated() && super::remote_guard::control_of(conn.origin, conn.auth, ¶ms) == crate::hub::Control::Web; - let verdict = hub.person_check(conn.is_person(), remote_authenticated, m, ¶ms).await?; + let verdict = hub + .person_check( + conn.is_person(), + remote_authenticated, + conn.caller(), + !conn.origin.web_class(), + m, + ¶ms, + ) + .await?; let resolved = super::session_key(¶ms).ok().and_then(|key| hub.resolve(key).ok()); super::trust_gate::check(hub, conn.origin, m, ¶ms, resolved.as_ref()).await?; // the folder-trust gate let key = super::session_key(¶ms).ok().map(str::to_owned); @@ -259,6 +268,7 @@ async fn dispatch_request( } let adopt = crate::adopt::AdoptRequest::from_meta(meta).map_err(RpcError::invalid_params)?; + let adopting = adopt.is_some(); let pick = |key: &str| { meta.and_then(|m| m.get(key)) .and_then(Value::as_str) @@ -280,11 +290,15 @@ async fn dispatch_request( env: crate::session_env::parse(meta)?, // LocalApp = same-user secret, equal to the unix socket for STARTING presets; writes stay unix-socket only. remote: conn.origin.web_class(), - keep_harness_mode: false, + keep_harness_mode: hub + .keeps_harness_mode(conn.is_person(), conn.caller(), ¶ms) + .await, }; let s = hub.new_session(req).await?; if conn.origin.web_class() { super::remote_guard::settle_web_session_mode(hub, &s).await?; + } else { + hub.start_in_mode(conn.is_person(), conn.caller(), ¶ms, &s, adopting).await?; } attach(hub, conn, &s.id); let meta = s.meta(); diff --git a/cmux-tui/crates/acpmux/src/server/requests/presets.rs b/cmux-tui/crates/acpmux/src/server/requests/presets.rs index a67900f82a24..e21b8c39e0b3 100644 --- a/cmux-tui/crates/acpmux/src/server/requests/presets.rs +++ b/cmux-tui/crates/acpmux/src/server/requests/presets.rs @@ -3,13 +3,18 @@ use super::*; +/// Read or change presets: {name?, set?: {harness, model, effort, policy, env, args, mode}, clear?: bool, +/// approveMode?: bool} (`approveMode: true` is the person's own, `hub/person.rs` (b)). pub(super) async fn handle( hub: &Arc, conn: &Arc, params: &Value, ) -> Result { let name = str_param(params, "name").map(str::to_owned); - let set = params.get("set").filter(|v| v.is_object()); + let approve = params.get("approveMode").and_then(Value::as_bool); + let no_fields = json!({}); + let set = + params.get("set").filter(|v| v.is_object()).or(approve.is_some().then_some(&no_fields)); let clear = params.get("clear").and_then(Value::as_bool).unwrap_or(false); if set.is_some() || clear { let name = name @@ -61,6 +66,8 @@ pub(super) async fn handle( args: Vec::new(), system_prompt_sha256: None, description: None, + mode: None, + mode_approval: None, }); p.harness = harness; for (k, v) in obj { @@ -76,6 +83,13 @@ pub(super) async fn handle( pol.parse::().map_err(RpcError::invalid_params)?, ) } + ("mode", Value::Null) => p.mode = None, + ("mode", Value::String(m)) => p.mode = Some(m.clone()), + ("mode", _) => { + return Err(RpcError::invalid_params( + "mode must be a mode id (a string) or null", + )); + } ("description", Value::Null) => p.description = None, ("description", Value::String(d)) => p.description = Some(d.clone()), ("systemPrompt", Value::Null) => new_prompt = Some(None), @@ -110,7 +124,7 @@ pub(super) async fn handle( } (other, _) => { return Err(RpcError::invalid_params(format!( - "unknown preset key {other:?}; use harness, model, effort, policy, env, args, systemPrompt, description" + "unknown preset key {other:?}; use harness, model, effort, policy, env, args, systemPrompt, description, mode" ))); } } @@ -169,6 +183,8 @@ pub(super) async fn handle( } None => {} } + // The person's approval binds these contents (`hub/person.rs` (b)). + p.apply_mode_approval(approve).map_err(RpcError::invalid_params)?; cfg.presets.insert(name.clone(), p); } if let Err(e) = cfg.save() { @@ -181,7 +197,7 @@ pub(super) async fn handle( Ok(x) => (Some(x), None), Err(e) => (None, Some(e)), }; - json!({"name": n, "harness": p.harness, "profile": profile, "error": error, "model": p.model, "effort": p.effort, "policy": p.policy, "env": p.env, "args": p.args, "systemPromptSha256": p.system_prompt_sha256, "description": p.description}) + json!({"name": n, "harness": p.harness, "profile": profile, "error": error, "model": p.model, "effort": p.effort, "policy": p.policy, "env": p.env, "args": p.args, "systemPromptSha256": p.system_prompt_sha256, "description": p.description, "mode": p.mode, "modeApproved": p.mode_approved()}) }; match name { Some(n) if !clear => cfg diff --git a/cmux-tui/crates/acpmux/src/store.rs b/cmux-tui/crates/acpmux/src/store.rs index 9c119f454347..354fe63ec6f6 100644 --- a/cmux-tui/crates/acpmux/src/store.rs +++ b/cmux-tui/crates/acpmux/src/store.rs @@ -175,6 +175,11 @@ pub struct SessionMeta { /// Unsent composer text owned by this acpmux session. #[serde(default, skip_serializing_if = "Option::is_none")] pub composer_draft: Option, + /// The session took its mode from the person's approval of its preset + /// (`hub/person.rs` (b)): the approved contents' digest. A spawn from + /// other contents leaves that mode (`hub/start_gate/preset_bind.rs`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preset_mode_approval: Option, /// The seqs of this chat's `user_message` records the person bookmarked (the pane's thread /// minimap; `hub/bookmarks.rs`). A fork renumbers its copies; a move keeps the log's seqs. #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] @@ -182,6 +187,15 @@ pub struct SessionMeta { /// The tools the chat has (`hub/tools.rs`). #[serde(default, skip_serializing_if = "ToolInventory::is_empty")] pub tools: ToolInventory, + /// The branch checked out in `cwd` when the session started (session/new, + /// or a fork at fork time; `git_state.rs`). `None` on a detached HEAD or + /// outside a repository. Never updated after: it joins the session to the + /// pull request it produced (hq#1920). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_branch: Option, + /// The full HEAD commit of `cwd` when the session started, as `git_branch`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub git_head: Option, } /// The tools a chat has (`hub/tools.rs`): cmux's own, recorded at each diff --git a/cmux-tui/crates/acpmux/src/tui/actions.rs b/cmux-tui/crates/acpmux/src/tui/actions.rs index d251c1dfbe6a..20beb87725eb 100644 --- a/cmux-tui/crates/acpmux/src/tui/actions.rs +++ b/cmux-tui/crates/acpmux/src/tui/actions.rs @@ -83,7 +83,7 @@ pub fn def(action: Action) -> &'static ActionDef { } /// Effort levels offered for a draft, before the harness can be asked. -/// Codex uses ultra as its top level; Claude Code adds Ultracode and Ultrathink after max. +/// Codex uses ultra as its top level; Claude Code's own levels end at max. pub fn draft_effort_levels(agent: &str) -> Vec<(&'static str, &'static str)> { if agent.contains("codex") { vec![ @@ -102,8 +102,6 @@ pub fn draft_effort_levels(agent: &str) -> Vec<(&'static str, &'static str)> { ("high", "High"), ("xhigh", "Extra High"), ("max", "Max"), - ("ultracode", "Ultracode"), - ("ultrathink", "Ultrathink"), ] } } diff --git a/cmux-tui/crates/acpmux/tests/agent_host.rs b/cmux-tui/crates/acpmux/tests/agent_host.rs index 9f7889674cd1..913a69359593 100644 --- a/cmux-tui/crates/acpmux/tests/agent_host.rs +++ b/cmux-tui/crates/acpmux/tests/agent_host.rs @@ -4,6 +4,9 @@ //! it logged without losing or repeating one. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use acpmux::agent_host::link::{self, Connect, HostLauncher, Link}; use acpmux::agent_host::{ self, ControllerFrame, Entry, HostFrame, HostRecord, Liveness, SpawnSpec, TapDir, @@ -24,7 +27,7 @@ fn scratch(tag: &str) -> PathBuf { } fn launcher() -> HostLauncher { - HostLauncher { exe: env!("CARGO_BIN_EXE_acpmux").into(), prefix: Vec::new() } + HostLauncher { exe: daemon_env::bin().into(), prefix: Vec::new() } } fn spec(dir: &Path, session: &str) -> SpawnSpec { @@ -33,7 +36,8 @@ fn spec(dir: &Path, session: &str) -> SpawnSpec { session_id: session.into(), program: "python3".into(), args: vec![FAKE.into()], - env: std::env::vars().collect(), + // The stand-in's environment: never the host's (its PATH has real agents). + env: daemon_env::vars(dir, daemon_env::Agents::StandIn, &[]), cwd: dir.to_path_buf(), translator: None, codex_bridge: false, diff --git a/cmux-tui/crates/acpmux/tests/agent_launch_deny.rs b/cmux-tui/crates/acpmux/tests/agent_launch_deny.rs new file mode 100644 index 000000000000..9fda7ab16324 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/agent_launch_deny.rs @@ -0,0 +1,212 @@ +//! The build-session kill switch (cx-9ettq), through the real daemon binary +//! and its unix socket: with `ACPMUX_AGENT_LAUNCH=deny` or +//! `CMUX_AGENTS_DISABLED=1`, `session/new` fails with the launch-disabled +//! error, model probes and prewarms start nothing, and no harness process +//! ever runs (the harness command writes a marker file before anything else, +//! and the daemon's own records show no session, live agent or agent host). +#![cfg(unix)] + +#[path = "support/daemon_env.rs"] +mod daemon_env; + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + +struct Rpc { + lines: tokio::io::Lines>, + wr: tokio::net::unix::OwnedWriteHalf, + next: i64, +} + +impl Rpc { + async fn connect(path: &Path) -> Self { + let s = tokio::net::UnixStream::connect(path).await.expect("connect to daemon socket"); + let (rd, wr) = s.into_split(); + Self { lines: tokio::io::BufReader::new(rd).lines(), wr, next: 0 } + } + + /// The whole response: `result` or `error`. + async fn send(&mut self, method: &str, params: Value) -> Value { + self.next += 1; + let id = self.next; + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + self.wr.write_all(format!("{line}\n").as_bytes()).await.unwrap(); + loop { + let line = tokio::time::timeout(Duration::from_secs(90), self.lines.next_line()) + .await + .expect("daemon answered in time") + .unwrap() + .expect("daemon closed the socket"); + let v: Value = serde_json::from_str(&line).unwrap(); + if v.get("id") == Some(&json!(id)) { + return v; + } + } + } + + async fn call(&mut self, method: &str, params: Value) -> Value { + let v = self.send(method, params).await; + assert!(v.get("error").is_none(), "{method} failed: {v}"); + v["result"].clone() + } +} + +fn scratch(tag: &str) -> PathBuf { + // Short: the socket path must stay under the macOS limit. + let dir = std::env::temp_dir().join(format!("amx-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +struct Daemon { + child: std::process::Child, + dir: PathBuf, + socket: PathBuf, + marker: PathBuf, +} + +impl Daemon { + /// The daemon with `switch` set (none: launches allowed), once ready. + /// `memory`: no store on disk, so no agent hosts (they outlive the daemon). + async fn start(tag: &str, switch: Option<(&str, &str)>, memory: bool) -> Self { + let dir = scratch(tag); + let marker = dir.join("harness-started"); + let fake = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); + // Any start of the harness (a session or a model probe) leaves the marker. + let argv = json!([ + "/bin/sh", + "-c", + "echo started >>\"$1\"; shift; exec python3 \"$@\"", + "sh", + marker, + fake + ]); + std::fs::write( + dir.join("config.json"), + json!({"harnesses": {"fake": {"argv": argv}}, "defaultHarness": "fake", "permissionPolicy": "approve-all"}).to_string(), + ) + .unwrap(); + let socket = dir.join("s.sock"); + // Launches allowed unless `switch` turns them off; the bare PATH holds + // no real agent either way. + let mut cmd = daemon_env::command(&dir, daemon_env::Agents::StandIn, &[]); + cmd.args(["daemon", "run", "--ready-fd", "1", "--log", "warn"]); + if memory { + cmd.arg("--memory"); + } + cmd.env("ACPMUX_HOME", &dir) + .env("ACPMUX_SOCKET", &socket) + .env("ACPMUX_LOGIN_ENV", "0") + // No network fetches from a test daemon. + .env("ACPMUX_REGISTRY_FETCH", "0") + .env("ACPMUX_HEALTH_FETCH", "0") + // Only the fake harness: never the developer's own claude or codex + // from PATH or ~/.acpmux, with their credentials. + .env("ACPMUX_PROBE_HARNESSES", "fake"); + if let Some((key, value)) = switch { + cmd.env(key, value); + } + let mut child = cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let ready = tokio::task::spawn_blocking(move || { + for line in BufReader::new(stdout).lines() { + let line = line.unwrap(); + if serde_json::from_str::(&line).is_ok_and(|v| v["ready"] == true) { + return; + } + } + panic!("daemon exited without a readiness line"); + }); + tokio::time::timeout(Duration::from_secs(20), ready).await.unwrap().unwrap(); + Self { child, dir, socket, marker } + } + + async fn stop(mut self) { + unsafe { + libc::kill(self.child.id() as i32, libc::SIGTERM); + } + let stop = Instant::now(); + while self.child.try_wait().unwrap().is_none() { + assert!(stop.elapsed() < Duration::from_secs(8), "daemon still running after SIGTERM"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Starts the daemon with `switch` set and a store on disk (agent hosts on, +/// as in the app), runs every check, and stops it. +async fn denied_daemon_starts_no_agent(tag: &str, switch: (&str, &str)) { + let daemon = Daemon::start(tag, Some(switch), false).await; + let (dir, marker) = (&daemon.dir, &daemon.marker); + let mut rpc = Rpc::connect(&daemon.socket).await; + assert_eq!(rpc.call("_acpmux/status", json!({})).await["agentLaunch"], "deny"); + + // session/new: the launch-disabled error, not retryable. + let reply = rpc + .send( + "session/new", + json!({"cwd": dir, "mcpServers": [], "_meta": {"acpmux": {"name": "denied"}}}), + ) + .await; + let error = &reply["error"]; + assert_eq!(error["message"], "agent launch is disabled in this build session", "{reply}"); + assert_eq!(error["code"], -32010, "{reply}"); + assert_eq!(error["data"]["reason"], "agent_launch_disabled", "{reply}"); + assert_eq!(error["data"]["retryable"], false, "{reply}"); + + // A refreshed model list waits for every probe; a probe would start the harness. + rpc.call("_acpmux/models", json!({"refresh": true})).await; + // A prewarm hint is refused, never queued. + let prewarm = + rpc.call("_acpmux/prewarm", json!({"harness": "fake", "cwd": dir, "wait": true})).await; + assert_eq!(prewarm["accepted"], false, "{prewarm}"); + assert_eq!(prewarm["reason"], "agent launch is disabled in this build session", "{prewarm}"); + + // The daemon's own records: no session, no live agent, no agent host. + let status = rpc.call("_acpmux/status", json!({})).await; + assert_eq!(status["sessions"], 0, "{status}"); + assert_eq!(status["liveAgents"], 0, "{status}"); + let hosts: Vec<_> = std::fs::read_dir(dir.join("hosts")) + .map(|d| d.filter_map(Result::ok).map(|e| e.file_name()).collect()) + .unwrap_or_default(); + assert!(hosts.is_empty(), "agent host records: {hosts:?}"); + // And the harness command never ran at all. + assert!(!marker.exists(), "the harness started: {:?}", std::fs::read_to_string(marker)); + daemon.stop().await; +} + +/// The control: the same daemon and harness without the switch starts the +/// harness (the marker appears), so the checks above can fail. +#[tokio::test] +async fn without_the_switch_the_harness_starts() { + let daemon = Daemon::start("allow", None, true).await; + let mut rpc = Rpc::connect(&daemon.socket).await; + assert_eq!(rpc.call("_acpmux/status", json!({})).await["agentLaunch"], "allow"); + rpc.call( + "session/new", + json!({"cwd": daemon.dir, "mcpServers": [], "_meta": {"acpmux": {"name": "allowed"}}}), + ) + .await; + assert!(daemon.marker.exists(), "the harness never started"); + daemon.stop().await; +} + +#[tokio::test] +async fn agent_launch_deny_starts_no_agent() { + denied_daemon_starts_no_agent("deny", ("ACPMUX_AGENT_LAUNCH", "deny")).await; +} + +#[tokio::test] +async fn cmux_agents_disabled_starts_no_agent() { + denied_daemon_starts_no_agent("off", ("CMUX_AGENTS_DISABLED", "1")).await; +} diff --git a/cmux-tui/crates/acpmux/tests/byoh_cli.rs b/cmux-tui/crates/acpmux/tests/byoh_cli.rs index 4b09b6c7bc0d..d2b1a7899143 100644 --- a/cmux-tui/crates/acpmux/tests/byoh_cli.rs +++ b/cmux-tui/crates/acpmux/tests/byoh_cli.rs @@ -5,6 +5,9 @@ //! or not executable fails the doctor's command step with the exact fix. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::os::unix::fs::PermissionsExt; @@ -39,16 +42,13 @@ impl User { } fn cmd(&self) -> Command { - let mut cmd = Command::new(env!("CARGO_BIN_EXE_acpmux")); + // The daemon runs the sample agent; `harness add` and `doctor` too. + let mut cmd = daemon_env::command(&self.root, daemon_env::Agents::StandIn, &[]); cmd.current_dir(self.root.join("work")) - .env("HOME", self.root.join("home")) .env("XDG_CONFIG_HOME", self.root.join("xdg")) .env("ACPMUX_HOME", self.root.join("amx")) .env("ACPMUX_SOCKET", self.root.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("ACPMUX_AGENT_HOSTS") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .stdin(Stdio::null()); cmd } diff --git a/cmux-tui/crates/acpmux/tests/chief_builtin_presets.rs b/cmux-tui/crates/acpmux/tests/chief_builtin_presets.rs index 8fada7848402..4a588bec2d6a 100644 --- a/cmux-tui/crates/acpmux/tests/chief_builtin_presets.rs +++ b/cmux-tui/crates/acpmux/tests/chief_builtin_presets.rs @@ -8,6 +8,9 @@ //! its unix socket. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -109,7 +112,8 @@ impl Daemon { ) .unwrap(); let socket = dir.join("s.sock"); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + // Stand-in agents (fclaude, fcodex) only; PATH stays the bare GUI one below. + let mut child = daemon_env::command(&dir, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", &dir) .env("ACPMUX_SOCKET", &socket) @@ -119,11 +123,6 @@ impl Daemon { .env("CMUX_CHIEF_OWNER_SOCKET", "/tmp/brain-owner.sock") .env("PATH", "/usr/bin:/bin") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("CMUX_BUNDLED_CLI_PATH") - .env_remove("CMUX_SOCKET_PATH") - .env_remove("CMUX_APP_DAEMON_SOCKET") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .envs(extra.iter().map(|(k, v)| (*k, dir.join(v)))) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -237,7 +236,7 @@ async fn a_chief_outside_the_app_installs_its_built_in_presets_without_env() { // Testbox's warm target disk) and keeps it on macOS. let path = env["PATH"].as_str().unwrap(); let (first, rest) = path.split_once(':').unwrap(); - let exe = std::fs::canonicalize(env!("CARGO_BIN_EXE_acpmux")).unwrap(); + let exe = std::fs::canonicalize(daemon_env::bin()).unwrap(); assert_eq!(std::fs::canonicalize(first).unwrap(), exe.parent().unwrap(), "{env}"); assert_eq!(rest, "/usr/bin:/bin", "{env}"); } diff --git a/cmux-tui/crates/acpmux/tests/claude_auth_env.rs b/cmux-tui/crates/acpmux/tests/claude_auth_env.rs index 1531a52bd8a4..aaaf48c9b04f 100644 --- a/cmux-tui/crates/acpmux/tests/claude_auth_env.rs +++ b/cmux-tui/crates/acpmux/tests/claude_auth_env.rs @@ -8,6 +8,9 @@ //! Claude Code does and says what it found, or fails like Claude Code. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -78,26 +81,6 @@ fn scratch(tag: &str, rc: &str) -> PathBuf { dir } -/// The daemon's PATH: python3 (linked into `

/bin`) and the system -/// directories, never the host's own PATH. A fleet host's PATH holds real -/// agent CLIs (codex, claude, gemini): the daemon would discover and probe -/// them, and Codex's shell snapshot sources the fake HOME's `~/.zshrc`, so -/// the login shell would seem to run more often than it does. -fn bare_path(dir: &Path) -> String { - let bin = dir.join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - let python = std::env::var_os("PATH") - .iter() - .flat_map(std::env::split_paths) - .map(|d| d.join("python3")) - .find(|p| p.is_file()) - .expect("python3 on the test's PATH"); - let link = bin.join("python3"); - let _ = std::fs::remove_file(&link); - std::os::unix::fs::symlink(python, &link).unwrap(); - format!("{}:/usr/bin:/bin:/usr/sbin:/sbin", bin.display()) -} - struct Daemon { child: std::process::Child, socket: PathBuf, @@ -121,8 +104,12 @@ impl Drop for Daemon { } } -/// The daemon the way the Chief or launchd starts it: a bare environment -/// (only PATH for python3, the fake HOME and its login shell) plus `extra`. +/// The daemon the way the Chief or launchd starts it: the hermetic test +/// environment (`daemon_env`: a bare PATH with python3, never the host's, so +/// no real codex or claude is found; the fake HOME) with its login shell, plus +/// `extra`. A fleet host's real agent CLIs would be probed, and Codex's shell +/// snapshot sources the fake `~/.zshrc`, so the login shell would seem to run +/// more often than it does (cx-w014j). async fn start(dir: &Path, harnesses: Value, extra: &[(&str, &str)]) -> Daemon { let config = json!({"harnesses": harnesses, "defaultHarness": "claude", "permissionPolicy": "approve-all"}); start_config(dir, config, extra).await @@ -131,13 +118,10 @@ async fn start(dir: &Path, harnesses: Value, extra: &[(&str, &str)]) -> Daemon { async fn start_config(dir: &Path, config: Value, extra: &[(&str, &str)]) -> Daemon { std::fs::write(dir.join("config.json"), config.to_string()).unwrap(); let socket = dir.join("s.sock"); - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + // The stand-in claude (fake_claude.py) is the only agent. + let mut cmd = daemon_env::command(dir, daemon_env::Agents::StandIn, &[]); cmd.args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "info"]) - .env_clear() - .env("PATH", bare_path(dir)) - .env("HOME", dir.join("home")) .env("SHELL", dir.join("loginsh")) - .env("TMPDIR", std::env::temp_dir()) .env("ACPMUX_HOME", dir) .env("ACPMUX_SOCKET", &socket) .env("ACPMUX_AGENT_HOSTS", "0") diff --git a/cmux-tui/crates/acpmux/tests/claude_reasoning.rs b/cmux-tui/crates/acpmux/tests/claude_reasoning.rs new file mode 100644 index 000000000000..0aecad72686d --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/claude_reasoning.rs @@ -0,0 +1,259 @@ +//! Claude Code's reasoning options, as the composer shows them. +//! +//! The Reasoning select lists only Claude Code's effort levels. Ultracode is +//! a Claude Code setting (workflow orchestration at Extra High), not a level, +//! so it is its own On/Off switch. No choice rewrites the person's prompt. + +#![cfg(unix)] + +use acpmux::config::{Config, HarnessProfile, PermissionPolicy, StoreMode}; +use acpmux::hub::Hub; +use acpmux::rpc::{Message, method}; +use acpmux::server::{Origin, serve_connection_with}; +use serde_json::{Value, json}; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +/// One local client of the hub (the app pane's connection). +struct TestClient { + tx: mpsc::Sender, + rx: mpsc::Receiver, + next: i64, +} + +impl TestClient { + async fn request(&mut self, m: &str, params: Value) -> Result { + self.next += 1; + let id = self.next; + self.tx.send(Message::request(id, m, params).to_line()).await.unwrap(); + loop { + let line = tokio::time::timeout(Duration::from_secs(20), self.rx.recv()) + .await + .expect("timeout waiting for response") + .expect("connection closed"); + if let Message::Response { id: rid, result, error } = Message::parse(&line).unwrap() + && rid == id + { + return match error { + Some(e) => Err(e.message), + None => Ok(result.unwrap_or(Value::Null)), + }; + } + } + } +} + +/// The person key this test's hub holds, as a daemon the app started does. +const PERSON_KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +fn cwd() -> String { + std::env::temp_dir().to_string_lossy().into_owned() +} + +/// A hub whose only harness runs `fake_claude.py` as Claude Code. +async fn claude_setup() -> (Arc, TestClient) { + let fake_claude = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_claude.py"); + let mut agents = BTreeMap::new(); + agents.insert( + "fakeclaude".to_owned(), + HarnessProfile { + kind: acpmux::config::HarnessKind::ClaudeStdio, + argv: vec!["python3".into(), fake_claude.into()], + env: BTreeMap::new(), + description: None, + fallback: None, + family: None, + models: vec![], + model: None, + effort: None, + policy: None, + }, + ); + let mut cfg = Config { + harnesses: agents, + default_harness: Some("fakeclaude".into()), + ..Default::default() + }; + cfg.store.mode = StoreMode::Memory; + cfg.permission_policy = PermissionPolicy::ApproveAll; + let store = acpmux::store::open(&cfg.store, std::path::Path::new("/nonexistent")).unwrap(); + let hub = Hub::new(cfg, store); + // The client is the person at the app (`hub/person.rs`): Fast mode and the + // `ultracode` option are theirs to switch (the app's pane rides `effort`). + hub.person.install_spawn_key(PERSON_KEY).unwrap(); + let (in_tx, in_rx) = mpsc::channel(64); + let (out_tx, out_rx) = mpsc::channel(4096); + tokio::spawn(serve_connection_with(hub.clone(), in_rx, out_tx, Origin::Local)); + let mut c = TestClient { tx: in_tx, rx: out_rx, next: 0 }; + let init = c + .request(method::INITIALIZE, json!({"protocolVersion": 1, "clientInfo": {"name": "test"}})) + .await + .unwrap(); + let challenge = &init["_meta"]["acpmux"]["personChallenge"]; + let proof = acpmux::hub::person::person_proof( + PERSON_KEY, + acpmux::hub::person::TRANSPORT_UNIX, + challenge["nonce"].as_str().unwrap(), + challenge["connection"].as_str().unwrap(), + ) + .unwrap(); + let r = c.request("_acpmux/person_prove", json!({"proof": proof})).await.unwrap(); + assert_eq!(r["person"], json!(true), "{r}"); + (hub, c) +} + +fn option<'a>(options: &'a Value, id: &str) -> &'a Value { + options + .as_array() + .and_then(|a| a.iter().find(|o| o["id"] == id)) + .unwrap_or_else(|| panic!("no {id} option: {options}")) +} + +fn values(option: &Value) -> Vec { + option["options"] + .as_array() + .unwrap() + .iter() + .map(|o| o["value"].as_str().unwrap().to_owned()) + .collect() +} + +async fn set(c: &mut TestClient, id: &str, config: &str, value: &str) -> Value { + let r = c + .request( + method::SESSION_SET_CONFIG_OPTION, + json!({"sessionId": id, "configId": config, "value": value}), + ) + .await + .unwrap(); + r["configOptions"].clone() +} + +/// What Claude Code read: the prompt text, every flag-settings change and its argv. +async fn probe(hub: &Arc, c: &mut TestClient, id: &str) -> Value { + let before = hub.events(id, 0, 10_000).unwrap().len(); + c.request( + method::SESSION_PROMPT, + json!({"sessionId": id, "prompt": [{"type": "text", "text": "settings-probe"}]}), + ) + .await + .unwrap(); + let text: String = hub + .events(id, 0, 10_000) + .unwrap() + .iter() + .skip(before) + .filter(|e| e.kind == "agent_message_chunk") + .filter_map(|e| e.msg.pointer("/params/update/content/text").and_then(Value::as_str)) + .collect(); + serde_json::from_str(&text).unwrap_or_else(|e| panic!("{e}: {text}")) +} + +#[tokio::test] +async fn claude_reasoning_lists_effort_levels_and_ultracode_is_its_own_switch() { + let (hub, mut c) = claude_setup().await; + let s = c + .request( + method::SESSION_NEW, + json!({"cwd": cwd(), "mcpServers": [], "_meta": {"acpmux": {"harness": "fakeclaude", "name": "think"}}}), + ) + .await + .unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + + let options = set(&mut c, &id, "effort", "high").await; + let effort = option(&options, "effort"); + assert_eq!(values(effort), ["default", "low", "medium", "high", "xhigh", "max"], "{effort}"); + assert_eq!(effort["currentValue"], "high"); + let ultracode = option(&options, "ultracode"); + assert_eq!(values(ultracode), ["off", "on"], "{ultracode}"); + assert_eq!(ultracode["currentValue"], "off"); + + // Ultracode on: Claude Code runs at Extra High with its ultracode setting. + let options = set(&mut c, &id, "ultracode", "on").await; + assert_eq!(option(&options, "ultracode")["currentValue"], "on"); + assert_eq!(option(&options, "effort")["currentValue"], "xhigh"); + let got = probe(&hub, &mut c, &id).await; + assert_eq!(got["said"], "settings-probe", "the prompt reaches Claude as typed: {got}"); + assert_eq!( + got["settings"].as_array().unwrap().last().unwrap(), + &json!({"effortLevel": "xhigh", "ultracode": true}), + "{got}" + ); + + // Picking a level turns Ultracode off. + let options = set(&mut c, &id, "effort", "medium").await; + assert_eq!(option(&options, "ultracode")["currentValue"], "off"); + assert_eq!(option(&options, "effort")["currentValue"], "medium"); + let got = probe(&hub, &mut c, &id).await; + assert_eq!( + got["settings"].as_array().unwrap().last().unwrap(), + &json!({"effortLevel": "medium", "ultracode": false}), + "{got}" + ); + + // Ultracode survives a respawn: Extra High on the command line, the + // setting on the live channel before the first turn. + set(&mut c, &id, "ultracode", "on").await; + let session = hub.resolve("think").unwrap(); + hub.detach_child(&session).await; + let got = probe(&hub, &mut c, &id).await; + let argv: Vec = serde_json::from_value(got["argv"].clone()).unwrap(); + let at = argv.iter().position(|a| a == "--effort").unwrap_or_else(|| panic!("{argv:?}")); + assert_eq!(argv[at + 1], "xhigh", "{argv:?}"); + assert_eq!(got["settings"], json!([{"effortLevel": "xhigh", "ultracode": true}]), "{got}"); + assert_eq!(got["said"], "settings-probe", "{got}"); +} + +#[tokio::test] +async fn claude_reasoning_a_saved_ultrathink_pick_runs_the_default_level_unrewritten() { + let (hub, mut c) = claude_setup().await; + let s = c + .request( + method::SESSION_NEW, + json!({"cwd": cwd(), "mcpServers": [], "_meta": {"acpmux": {"harness": "fakeclaude", "name": "legacy"}}}), + ) + .await + .unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + // An older pane (or a remembered pick) still sends the retired value. + let options = set(&mut c, &id, "effort", "ultrathink").await; + assert_eq!(option(&options, "effort")["currentValue"], "default", "{options}"); + let got = probe(&hub, &mut c, &id).await; + assert_eq!(got["said"], "settings-probe", "{got}"); + assert_eq!( + got["settings"].as_array().unwrap().last().unwrap(), + &json!({"effortLevel": "auto", "ultracode": false}), + "{got}" + ); +} + +#[tokio::test] +async fn claude_reasoning_a_new_chat_attaches_with_its_effort_and_fast_options() { + // The composer draws Reasoning and Fast mode from the attached chat's + // options (Lawrence 2026-10-14: "we lost effort"). + let (_hub, mut c) = claude_setup().await; + let s = c + .request( + method::SESSION_NEW, + json!({"cwd": cwd(), "mcpServers": [], "_meta": {"acpmux": {"harness": "fakeclaude", "name": "fresh"}}}), + ) + .await + .unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + for moment in ["before its first prompt", "after its first prompt"] { + let a = c.request(method::MUX_ATTACH, json!({"sessionId": id, "limit": 0})).await.unwrap(); + let options = &a["session"]["configOptions"]; + assert!(options.is_array(), "{moment}: no options: {a}"); + assert!(values(option(options, "effort")).len() > 1, "{moment}: {options}"); + assert_eq!(values(option(options, "fast-mode")), ["off", "on"], "{moment}: {options}"); + c.request( + method::SESSION_PROMPT, + json!({"sessionId": id, "prompt": [{"type": "text", "text": "hello"}]}), + ) + .await + .unwrap(); + } +} diff --git a/cmux-tui/crates/acpmux/tests/codex_app_server_live.rs b/cmux-tui/crates/acpmux/tests/codex_app_server_live.rs index 201a1353238b..ce7f573fcd53 100644 --- a/cmux-tui/crates/acpmux/tests/codex_app_server_live.rs +++ b/cmux-tui/crates/acpmux/tests/codex_app_server_live.rs @@ -11,6 +11,9 @@ //! Without ACPMUX_CODEX_LIVE each test says so and passes (CI has no model). #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::os::unix::process::CommandExt; @@ -88,7 +91,20 @@ impl Daemon { // SAFETY: closing our own write end. unsafe { libc::close(fds[1]) }; let read_end = fds[0]; - let mut command = Command::new(env!("CARGO_BIN_EXE_acpmux")); + // Live by design: the real codex named by ACPMUX_CODEX_LIVE (an absolute + // path in the harness argv), its own directory (an npm install keeps its + // node there) right after the tool links, before the system directories. + // That directory can hold other agent CLIs: only codex is probed. + let codex = std::env::var("ACPMUX_CODEX_LIVE").expect("ACPMUX_CODEX_LIVE"); + assert!(Path::new(&codex).is_absolute(), "ACPMUX_CODEX_LIVE must be absolute"); + let codex_dir = Path::new(&codex).parent().expect("codex directory").to_owned(); + let path = format!( + "{}:{}:/usr/bin:/bin:/usr/sbin:/sbin", + daemon_env::tool_dir(&[]).display(), + codex_dir.display() + ); + let mut command = daemon_env::command(&self.home, daemon_env::Agents::StandIn, &[]); + command.env("PATH", path).env("ACPMUX_PROBE_HARNESSES", "codex-app-server"); // SAFETY: only dup2 between fork and exec (async-signal-safe). unsafe { command.pre_exec(move || { @@ -104,8 +120,6 @@ impl Daemon { .env("ACPMUX_HOME", &self.home) .env("ACPMUX_SOCKET", &self.socket) .env("ACPMUX_AGENT_HOSTS", if self.direct { "0" } else { "1" }) - .env_remove("ACPMUX_LOGIN_ENV") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/cmux-tui/crates/acpmux/tests/codex_bridge_config.rs b/cmux-tui/crates/acpmux/tests/codex_bridge_config.rs index d73c67e2dbe4..ed1089642b1d 100644 --- a/cmux-tui/crates/acpmux/tests/codex_bridge_config.rs +++ b/cmux-tui/crates/acpmux/tests/codex_bridge_config.rs @@ -4,6 +4,9 @@ //! codex binary the bridge runs, and a family `prefer` list picks the profile. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use acpmux::config::{Config, StoreMode}; use acpmux::hub::Hub; use acpmux::rpc::Message; @@ -12,7 +15,7 @@ use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; @@ -213,25 +216,21 @@ async fn a_prefer_list_still_picks_the_codex_profile() { let _ = std::fs::remove_dir_all(&d); } -/// The real daemon discovers its harnesses on its own PATH. -#[tokio::test] -async fn codex_on_path_is_the_bridge_and_the_adapter_becomes_codex_acp() { - let d = dir("discover"); - shim(&d, "codex", FAKE_APP_SERVER); - shim(&d, "codex-acp", FAKE_AGENT); +/// The harnesses a real daemon discovers on the PATH `d/bin` (plus the system's). +async fn discovered_harnesses(d: &Path) -> Value { let home = d.join("home"); std::fs::create_dir_all(&home).unwrap(); let acp_home = d.join("acpmux"); std::fs::create_dir_all(&acp_home).unwrap(); let socket = acp_home.join("s.sock"); - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + // The stand-in codex in `d/bin`, first on the bare PATH, is only listed: + // no launch is needed. + let mut child = daemon_env::acpmux(d) .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "warn"]) - .env("PATH", format!("{}:/usr/bin:/bin", d.join("bin").display())) - .env("HOME", &home) + .env("PATH", format!("{}:{}", d.join("bin").display(), daemon_env::bare_path(&[]))) .env("ACPMUX_HOME", &acp_home) .env("ACPMUX_SOCKET", &socket) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -264,9 +263,41 @@ async fn codex_on_path_is_the_bridge_and_the_adapter_becomes_codex_acp() { }; let _ = child.kill(); let _ = child.wait(); - let h = &reply["result"]["harnesses"]; + reply["result"]["harnesses"].clone() +} + +/// The real daemon discovers its harnesses on its own PATH. +#[tokio::test] +async fn codex_on_path_is_the_bridge_and_the_adapter_becomes_codex_acp() { + let d = dir("discover"); + shim(&d, "codex", FAKE_APP_SERVER); + shim(&d, "codex-acp", FAKE_AGENT); + let h = &discovered_harnesses(&d).await; assert_eq!(h["codex"]["argv"][1], "app-server", "{h}"); assert!(h["codex"]["argv"][0].as_str().unwrap_or("").ends_with("/codex"), "{h}"); assert!(h["codex-acp"]["argv"][0].as_str().unwrap_or("").ends_with("/codex-acp"), "{h}"); let _ = std::fs::remove_dir_all(&d); } + +/// Without a codex-acp on PATH, the pinned npx adapter is not offered beside +/// the bridge: it reads the account through codex's `account/read`, which a +/// ChatGPT login behind a routing proxy (subrouter) fails, so its chats never +/// started ("Authentication required"). `codex-acp`, the name earlier chats +/// and model picks recorded, runs the bridge instead. +#[tokio::test] +async fn without_codex_acp_on_path_the_npx_adapter_gives_way_to_the_bridge() { + let d = dir("npx-adapter"); + shim(&d, "codex", FAKE_APP_SERVER); + // An npx that installs nothing: discovery only needs it to exist. + let npx = d.join("bin").join("npx"); + std::fs::write(&npx, "#!/bin/sh\nexit 1\n").unwrap(); + std::fs::set_permissions(&npx, std::fs::Permissions::from_mode(0o755)).unwrap(); + let h = &discovered_harnesses(&d).await; + for (name, profile) in h.as_object().unwrap() { + let program = profile["argv"][0].as_str().unwrap_or(""); + assert!(!program.ends_with("/npx"), "{name} launches through npx: {h}"); + } + assert_eq!(h["codex"]["argv"][1], "app-server", "{h}"); + assert_eq!(h["codex-acp"]["argv"], h["codex"]["argv"], "codex-acp runs the bridge: {h}"); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/cmux-tui/crates/acpmux/tests/conn_auth.rs b/cmux-tui/crates/acpmux/tests/conn_auth.rs index c317f51b2bab..1e069dbde686 100644 --- a/cmux-tui/crates/acpmux/tests/conn_auth.rs +++ b/cmux-tui/crates/acpmux/tests/conn_auth.rs @@ -6,11 +6,14 @@ //! (`_meta.acpmux.auth`, `server/conn_auth.rs`), which the mode rule reads. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::Duration; use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest}; @@ -37,13 +40,11 @@ impl Daemon { .to_string(), ) .unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(&home, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "error"]) .env("ACPMUX_HOME", &home) .env("ACPMUX_SOCKET", home.join("s.sock")) - .env_remove("ACPMUX_AGENT_HOSTS") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/cmux-tui/crates/acpmux/tests/cua_mcp_bridge.rs b/cmux-tui/crates/acpmux/tests/cua_mcp_bridge.rs index c4581fd8c748..48368bc27de7 100644 --- a/cmux-tui/crates/acpmux/tests/cua_mcp_bridge.rs +++ b/cmux-tui/crates/acpmux/tests/cua_mcp_bridge.rs @@ -12,12 +12,15 @@ //! The other-user socket case needs a second account (bead cx-8coo). #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Write}; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::UnixListener; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const SECRET: &str = "00112233445566778899aabbccddeeff"; @@ -72,7 +75,7 @@ fn stand_in(dir: &Path, mode: u32) -> std::sync::mpsc::Receiver { /// Runs `acpmux cua-mcp` for one `tools/list` and returns its reply. fn tools_list(dir: &Path) -> Value { - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(dir) .arg("cua-mcp") .env("CMUX_NEXT_CUA_V2_DIR", dir) .stdin(Stdio::piped()) diff --git a/cmux-tui/crates/acpmux/tests/daemon_lifecycle.rs b/cmux-tui/crates/acpmux/tests/daemon_lifecycle.rs index 2d74485a54ee..c31f361d1e2a 100644 --- a/cmux-tui/crates/acpmux/tests/daemon_lifecycle.rs +++ b/cmux-tui/crates/acpmux/tests/daemon_lifecycle.rs @@ -4,6 +4,9 @@ //! agents, and stops promptly on SIGTERM. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; @@ -68,16 +71,13 @@ async fn daemon_binds_before_login_env_reports_ready_and_stops_on_sigterm() { ); let socket = dir.join("s.sock"); let started = Instant::now(); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(&dir, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--memory", "--listen", "127.0.0.1:0", "--ready-fd", "1"]) .args(["--log", "warn"]) .env("ACPMUX_HOME", &dir) .env("ACPMUX_SOCKET", &socket) .env("ACPMUX_LOGIN_ENV", "1") .env("SHELL", &shell) - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") - .env_remove("SSL_CERT_DIR") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) @@ -186,14 +186,12 @@ async fn sigterm_is_bounded_with_busy_agents_and_attached_clients() { let mut fds = [0i32; 2]; assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); let (rfd, wfd) = (fds[0], fds[1]); - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + let mut cmd = daemon_env::command(&dir, daemon_env::Agents::StandIn, &[]); cmd.args(["daemon", "run", "--memory", "--listen", "127.0.0.1:0", "--ready-fd", "3"]) .args(["--log", "warn"]) .env("ACPMUX_HOME", &dir) .env("ACPMUX_SOCKET", &socket) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); @@ -295,13 +293,12 @@ async fn a_socket_path_past_the_unix_limit_still_serves() { std::fs::create_dir_all(&dir).unwrap(); let socket = dir.join("s.sock"); assert!(socket.as_os_str().len() > 104, "the path must be past the limit"); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(&dir) .args(["daemon", "run", "--memory", "--listen", "127.0.0.1:0", "--ready-fd", "1"]) .args(["--log", "warn"]) .env("ACPMUX_HOME", &dir) .env("ACPMUX_SOCKET", &socket) .env("TMPDIR", &dir) - .env_remove("XPC_SERVICE_NAME") .env("ACPMUX_LOGIN_ENV", "0") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) @@ -322,7 +319,7 @@ async fn a_socket_path_past_the_unix_limit_still_serves() { let bound = PathBuf::from(ready["socket"].as_str().unwrap()); assert!(bound.as_os_str().len() <= 103, "bound past the limit: {}", bound.display()); // A client knows only the long path. - let out = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let out = daemon_env::acpmux(&dir) .args(["daemon", "shutdown"]) .env("ACPMUX_HOME", &dir) .env("ACPMUX_SOCKET", &socket) @@ -397,7 +394,7 @@ async fn an_agent_runs_the_apps_bundled_cmux_and_reaches_the_app() { ); let socket = dir.join("s.sock"); let app_socket = dir.join("app.sock"); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(&dir, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", &dir) .env("ACPMUX_SOCKET", &socket) @@ -406,8 +403,6 @@ async fn an_agent_runs_the_apps_bundled_cmux_and_reaches_the_app() { .env("CMUX_BUNDLED_CLI_PATH", bundled.join("cmux")) .env("CMUX_SOCKET_PATH", &app_socket) .env("CMUX_BUNDLE_ID", "com.cmuxterm.app.next.debug.cli") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) diff --git a/cmux-tui/crates/acpmux/tests/durable_agents.rs b/cmux-tui/crates/acpmux/tests/durable_agents.rs index e1c3a91736ad..0f092c51e8f7 100644 --- a/cmux-tui/crates/acpmux/tests/durable_agents.rs +++ b/cmux-tui/crates/acpmux/tests/durable_agents.rs @@ -4,12 +4,15 @@ //! restart is still answerable after the new daemon adopts the host. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::os::unix::fs::OpenOptionsExt; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; @@ -58,7 +61,7 @@ impl Daemon { fn start(&mut self) { assert!(self.child.is_none()); - let mut command = Command::new(env!("CARGO_BIN_EXE_acpmux")); + let mut command = daemon_env::command(&self.home, daemon_env::Agents::StandIn, &[]); command .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", &self.home) @@ -66,10 +69,7 @@ impl Daemon { // No catalog fetch: a test never reaches the network, and the fetch's // warning (a catalog server 404) came on stdout before the ready line. .env("ACPMUX_CATALOG_FETCH", "0") - .env_remove("ACPMUX_AGENT_HOSTS") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") - .env_remove("ACPMUX_IDLE_CHILD_SECS") .envs(self.env.iter().map(|(k, v)| (k, v))) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -455,7 +455,7 @@ async fn shutdown_with_end_agents_ends_hosted_agents_and_records_the_cancelled_t /// `acpmux daemon shutdown` as the CLI runs it, against this daemon. fn cli_shutdown(daemon: &Daemon, extra: &[&str]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_acpmux")) + daemon_env::acpmux(&daemon.home) .args(["daemon", "shutdown"]) .args(extra) .env("ACPMUX_HOME", &daemon.home) diff --git a/cmux-tui/crates/acpmux/tests/fake_agent.py b/cmux-tui/crates/acpmux/tests/fake_agent.py index 48123a249dd1..6f8c18c62d4f 100644 --- a/cmux-tui/crates/acpmux/tests/fake_agent.py +++ b/cmux-tui/crates/acpmux/tests/fake_agent.py @@ -693,6 +693,9 @@ def main(): # FAKE_NO_MODES=1: an adapter that reports no session modes. if os.environ.get("FAKE_NO_MODES") == "1": del result["modes"] + # FAKE_FULL_ACCESS=1: a harness that also offers a full-access mode. + if os.environ.get("FAKE_FULL_ACCESS") == "1" and "modes" in result: + result["modes"]["availableModes"].append({"id": "full-access", "name": "Full access"}) # FAKE_ASK_MODE=1: the sample harness's one mode, "ask". if os.environ.get("FAKE_ASK_MODE") == "1": result["modes"] = {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}]} diff --git a/cmux-tui/crates/acpmux/tests/fake_claude.py b/cmux-tui/crates/acpmux/tests/fake_claude.py index 79af4f883871..0e1373d370b6 100755 --- a/cmux-tui/crates/acpmux/tests/fake_claude.py +++ b/cmux-tui/crates/acpmux/tests/fake_claude.py @@ -16,6 +16,8 @@ a user and an assistant line per prompt; "history-probe" replies with the prompts the conversation holds (a resumed or forked transcript's included). "hold: PATH" answers once PATH exists, so a test can act while a turn runs. +A prompt ending in "settings-probe" replies with the prompt text as Claude +read it, every `apply_flag_settings` it got, and its argv. A stop_task control request ends its task killed (task_updated); FAKE_CLAUDE_STOP_NOTIFY=1 sends the task's task_notification too. FAKE_CLAUDE_AUTH=1 looks for a sign-in the way Claude Code does (the @@ -56,6 +58,7 @@ def stored(sid): return os.path.join(STORE, sid + (LAYOUT or "")) SESSION = "fake-claude-session" RESUME = None +FLAG_SETTINGS = [] if STORE: for flag in ("--session-id", "--resume"): if flag in sys.argv[:-1]: @@ -144,6 +147,8 @@ def ask(rid, tool): if request.get("subtype") == "initialize" and SKILLS: answer = {"commands": COMMANDS} send({"type": "control_response", "response": {"subtype": "success", "request_id": msg.get("request_id"), "response": answer}}) + if request.get("subtype") == "apply_flag_settings": + FLAG_SETTINGS.append(request.get("settings")) # Like Claude Code, a stopped task ends killed (task_updated); FAKE_CLAUDE_STOP_NOTIFY=1 also # sends its task_notification, as a Claude Code that does not suppress it would. if request.get("subtype") == "stop_task": @@ -235,6 +240,8 @@ def ask(rid, tool): while not os.path.exists(said[len("hold: "):]): time.sleep(0.02) text = json.dumps(sys.argv[1:]) + if said.endswith("settings-probe"): + text = json.dumps({"said": said, "settings": FLAG_SETTINGS, "argv": sys.argv[1:]}) if said.strip() == "history-probe" and STORE and LAYOUT: with open(stored(SESSION)) as f: records = [json.loads(line) for line in f if line.strip()] diff --git a/cmux-tui/crates/acpmux/tests/harness_health.rs b/cmux-tui/crates/acpmux/tests/harness_health.rs index 281b74e0b928..ba5ab9d71cc0 100644 --- a/cmux-tui/crates/acpmux/tests/harness_health.rs +++ b/cmux-tui/crates/acpmux/tests/harness_health.rs @@ -621,6 +621,151 @@ async fn auto_update_runs_each_opted_in_harnesses_own_updater_then_checks_again( let _ = std::fs::remove_dir_all(&root); } +/// Update Everything (cx-1mbnu): `_acpmux/harness/update {ids}` runs now the updater of each named +/// outdated harness, whatever its auto-update choice, waits for it and checks again; a harness +/// whose update needs sudo is never run (its answer names the command), one already current is ok +/// without a run, and an unknown id is refused. Only the unix socket may ask, for the update and +/// for `_acpmux/sessions/restart_harness` alike: the local app, a WebSocket page and a peer may not. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn harness_update_runs_named_updaters_now_and_never_one_that_needs_sudo() { + let root = scratch("update"); + let home = root.join("home"); + let user = root.join("config").join("harnesses"); + + // Claude's native installer: `claude update` installs 2.0.9; each run is logged to `runs`. + let claude_file = home.join(".local/share/claude/versions/current"); + let claude = home.join(".local/bin/claude"); + let runs = root.join("claude-update-runs"); + std::fs::create_dir_all(claude_file.parent().unwrap()).unwrap(); + std::fs::write( + &claude_file, + format!( + "#!/bin/sh\nif [ \"$1\" = update ]; then echo run >> '{1}'; printf '#!/bin/sh\\necho \"2.0.9 (Claude Code)\"\\n' > '{0}.new' && chmod +x '{0}.new' && mv '{0}.new' '{0}'; echo updated; exit 0; fi\necho '2.0.5 (Claude Code)'\n", + claude_file.display(), + runs.display(), + ), + ) + .unwrap(); + std::fs::set_permissions(&claude_file, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); + symlink(&claude_file, &claude).unwrap(); + profile( + &user, + "claude", + &format!("protocol = \"claude-stdio\"\ncommand = \"{}\"\n", claude.display()), + ); + // npm, a prefix this user cannot write: its update needs sudo. + let npm_ro = root.join("npm-ro"); + let opencode = npm_ro.join("bin/opencode"); + program(&npm_ro.join("lib/node_modules/opencode-ai/bin/opencode"), &opencode, "0.15.0"); + std::fs::set_permissions( + npm_ro.join("lib/node_modules"), + std::fs::Permissions::from_mode(0o555), + ) + .unwrap(); + profile(&user, "opencode", &format!("command = \"{}\"\n", opencode.display())); + + let (port, _asked) = latest_server(vec![ + ("/npm/@anthropic-ai%2Fclaude-code/latest", json!({"version": "2.0.9"})), + ("/npm/opencode-ai/latest", json!({"version": "0.16.0"})), + ]); + let hub = hub_for(&root, &user, &home); + hub.health.attach(Settings { + sources: Some(Sources { + npm: format!("http://127.0.0.1:{port}/npm"), + brew: format!("http://127.0.0.1:{port}/brew"), + }), + cache: None, + home: home.clone(), + // Auto-update is off for every harness: the request runs anyway. + auto_update: Some(root.join("acpmux/harness-auto-update.json")), + }); + let mut local = Client::open(&hub, Origin::Local); + let first = local.call(method::MUX_HARNESS_HEALTH, json!({})).await.expect("health"); + assert_eq!( + ( + row(&first, "claude")["outdated"].as_bool(), + row(&first, "claude")["autoUpdate"].as_bool() + ), + (Some(true), Some(false)), + "{first}" + ); + + // Nobody but the unix socket may run an update or restart chats. + for origin in [Origin::LocalApp, Origin::Web, Origin::Peer] { + let mut other = Client::open(&hub, origin); + other + .call(method::MUX_HARNESS_UPDATE, json!({"ids": ["claude"]})) + .await + .expect_err("only the unix socket updates a harness"); + other + .call(method::MUX_SESSIONS_RESTART_HARNESS, json!({})) + .await + .expect_err("only the unix socket restarts chats"); + } + assert!(!runs.exists(), "an update ran for another origin"); + + // SAFETY: geteuid has no preconditions. Root writes any folder, so the sudo case is not one. + let rooted = unsafe { libc::geteuid() } == 0; + let ids = + if rooted { json!(["claude", "nope"]) } else { json!(["claude", "opencode", "nope"]) }; + let reply = local.call(method::MUX_HARNESS_UPDATE, json!({"ids": ids})).await.expect("update"); + let result = |id: &str| { + reply["results"] + .as_array() + .and_then(|all| all.iter().find(|r| r["id"] == id)) + .cloned() + .unwrap_or_else(|| panic!("no {id} result in {reply}")) + }; + let r = result("claude"); + assert_eq!((r["ok"].as_bool(), r["version"].as_str()), (Some(true), Some("2.0.9")), "{r}"); + assert_eq!(std::fs::read_to_string(&runs).unwrap().lines().count(), 1, "one run"); + if !rooted { + let r = result("opencode"); + assert_eq!( + (r["ok"].as_bool(), r["version"].as_str()), + (Some(false), Some("0.15.0")), + "{r}" + ); + assert!( + r["error"] + .as_str() + .is_some_and(|e| e.contains("sudo npm install -g opencode-ai@latest")), + "the sudo command is named, never run: {r}" + ); + } + let r = result("nope"); + assert_eq!(r["ok"], false, "{r}"); + // The health rows the answer came from are the new check's. + let after = local.call(method::MUX_HARNESS_HEALTH, json!({})).await.expect("health"); + assert_eq!(row(&after, "claude")["outdated"], false, "{after}"); + if !rooted { + assert_eq!(row(&after, "opencode")["version"], "0.15.0", "{after}"); + } + + // Already current: ok, nothing runs again. + let again = local + .call(method::MUX_HARNESS_UPDATE, json!({"ids": ["claude"]})) + .await + .expect("update again"); + assert_eq!(again["results"], json!([{"id": "claude", "ok": true, "version": "2.0.9"}])); + assert_eq!(std::fs::read_to_string(&runs).unwrap().lines().count(), 1, "ran again"); + // Never a command from the caller. + local + .call(method::MUX_HARNESS_UPDATE, json!({"ids": "claude"})) + .await + .expect_err("ids must be a list of ids"); + // No chat runs here: nothing restarts. + let moved = local + .call(method::MUX_SESSIONS_RESTART_HARNESS, json!({"harnesses": ["claude"]})) + .await + .expect("restart"); + assert_eq!(moved, json!({"restarted": 0, "after_turn": 0, "failed": 0, "errors": []})); + + let _ = std::process::Command::new("chmod").arg("-R").arg("u+w").arg(&root).status(); + let _ = std::fs::remove_dir_all(&root); +} + /// A fake `codex` at `dir/codex`: `codex login status` prints `says` and exits /// `code`, as Codex does (0 "Logged in using ChatGPT", 1 "Not logged in", and 1 /// for a check it could not make). diff --git a/cmux-tui/crates/acpmux/tests/harness_helpers.rs b/cmux-tui/crates/acpmux/tests/harness_helpers.rs index 1b6c069e1587..00a59c7104c6 100644 --- a/cmux-tui/crates/acpmux/tests/harness_helpers.rs +++ b/cmux-tui/crates/acpmux/tests/harness_helpers.rs @@ -6,10 +6,13 @@ //! gone it runs on with parent pid 1. Real binaries, a temporary home. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; @@ -50,7 +53,8 @@ impl Drop for Daemon { } fn start_daemon(home: &Path, probe: Option<&str>) -> Daemon { - let mut command = Command::new(env!("CARGO_BIN_EXE_acpmux")); + // Stand-in agents only: the probes and chats here run fake_agent.py. + let mut command = daemon_env::command(home, daemon_env::Agents::StandIn, &[]); command .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", home) @@ -59,10 +63,6 @@ fn start_daemon(home: &Path, probe: Option<&str>) -> Daemon { .env("ACPMUX_CATALOG_FETCH", "0") .env("ACPMUX_AGENT_HOSTS", "0") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("ACPMUX_LOCAL_ROUTER") - .env_remove("XPC_SERVICE_NAME") - .env_remove("ACPMUX_IDLE_CHILD_SECS") - .env_remove("ACPMUX_PROBE_HARNESSES") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); diff --git a/cmux-tui/crates/acpmux/tests/harness_restart.rs b/cmux-tui/crates/acpmux/tests/harness_restart.rs new file mode 100644 index 000000000000..4e5522504666 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/harness_restart.rs @@ -0,0 +1,179 @@ +//! Restart Chat / Update Everything (cx-1mbnu): over the daemon's unix +//! socket, `_acpmux/sessions/restart_harness {sessions?, harnesses?}` restarts +//! each named live chat's harness process and resumes the same agent session. +//! A restart that cannot spawn the harness again is counted as failed and +//! named in `errors` (never reported as success), a Home Chief chat restarts +//! when the caller names it, and a chat nobody named is left alone. + +// Unix only until the Windows port runs the daemon (cmux::local_socket). +#![cfg(unix)] + +use acpmux::config::{Config, StoreMode}; +use acpmux::hub::Hub; +use acpmux::rpc::{Message, method}; +use acpmux::server::{Origin, serve_connection_with}; +use serde_json::{Value, json}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +const FAKE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); + +fn scratch() -> PathBuf { + let root = std::env::temp_dir().join(format!("acpmux-restart-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join("home")).unwrap(); + std::fs::create_dir_all(root.join("config").join("harnesses")).unwrap(); + std::fs::canonicalize(&root).unwrap() +} + +/// The harness launcher at `path`: the fake agent, with its own pid in `RUN_PID`. +fn launcher(path: &Path) { + std::fs::write(path, format!("#!/bin/sh\nexport RUN_PID=$$\nexec python3 '{FAKE}' \"$@\"\n")) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +struct Client(mpsc::Sender, mpsc::Receiver, i64); + +impl Client { + fn open(hub: &Arc) -> Self { + let (in_tx, in_rx) = mpsc::channel(64); + let (out_tx, out_rx) = mpsc::channel(4096); + tokio::spawn(serve_connection_with(hub.clone(), in_rx, out_tx, Origin::Local)); + Client(in_tx, out_rx, 0) + } + + async fn call_text(&mut self, m: &str, params: Value) -> (Value, String) { + self.2 += 1; + let id = self.2; + self.0.send(Message::request(id, m, params).to_line()).await.unwrap(); + let mut text = String::new(); + loop { + let line = tokio::time::timeout(Duration::from_secs(60), self.1.recv()) + .await + .expect("no reply in 60 s") + .unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + if v.get("id") == Some(&json!(id)) { + return (v, text); + } + if let Some(t) = v.pointer("/params/update/content/text").and_then(Value::as_str) { + text.push_str(t); + } + } + } + + async fn ok(&mut self, m: &str, params: Value) -> Value { + let (reply, _) = self.call_text(m, params).await; + assert!(reply.get("error").is_none(), "{m}: {reply}"); + reply["result"].clone() + } + + async fn new_chat(&mut self, harness: &str) -> String { + let new = self + .ok( + "session/new", + json!({"cwd": std::env::temp_dir(), "mcpServers": [], + "_meta": {"acpmux": {"harness": harness}}}), + ) + .await; + new["sessionId"].as_str().unwrap().to_owned() + } + + /// The pid of the harness process that answers a turn in `session`. + async fn harness_pid(&mut self, session: &str) -> String { + let prompt = + json!({"sessionId": session, "prompt": [{"type": "text", "text": "env: RUN_PID"}]}); + let (reply, mut text) = self.call_text("session/prompt", prompt).await; + assert!(reply.get("error").is_none(), "{reply}"); + // The reply can overtake the turn's last streamed update. + while !text.contains("RUN_PID=") { + let line = tokio::time::timeout(Duration::from_secs(10), self.1.recv()) + .await + .unwrap_or_else(|_| panic!("no pid text: reply {reply}, text {text:?}")) + .unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + if let Some(t) = v.pointer("/params/update/content/text").and_then(Value::as_str) { + text.push_str(t); + } + } + let at = text.rfind("RUN_PID=").unwrap_or(0); + text[at + "RUN_PID=".len()..].trim().to_owned() + } + + async fn agent_session(&mut self, session: &str) -> Value { + self.ok("_acpmux/info", json!({"sessionId": session})).await["agentSessionId"].clone() + } +} + +fn hub(root: &Path, good: &Path, bad: &Path) -> Arc { + let mut cfg: Config = serde_json::from_value(json!({ + "harnesses": { + "good": {"argv": ["/bin/sh", good], "family": "claude"}, + "bad": {"argv": ["/bin/sh", bad], "family": "claude"}, + }, + "defaultHarness": "good", + "permissionPolicy": "approve-all", + })) + .unwrap(); + cfg.path = Some(root.join("home").join("config.json")); + cfg.profile_sources.user_dir = Some(root.join("config").join("harnesses")); + cfg.store.mode = StoreMode::Memory; + let store = acpmux::store::open(&cfg.store, Path::new("/nonexistent")).unwrap(); + Hub::new(cfg, store) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restart_harness_restarts_named_chats_and_reports_one_that_cannot_spawn() { + let root = scratch(); + let (good, bad) = (root.join("good.sh"), root.join("bad.sh")); + launcher(&good); + launcher(&bad); + let hub = hub(&root, &good, &bad); + let mut local = Client::open(&hub); + + // A: a Home Chief chat, named by the caller. B: its harness can no longer + // start. C: a live chat nobody names. + let a = local.new_chat("good").await; + let b = local.new_chat("bad").await; + let c = local.new_chat("good").await; + local.ok(method::MUX_TAG, json!({"sessionId": a, "set": {"cmux.chief": "1"}})).await; + let (a_pid, b_pid, c_pid) = + (local.harness_pid(&a).await, local.harness_pid(&b).await, local.harness_pid(&c).await); + assert!(!a_pid.is_empty() && !b_pid.is_empty() && !c_pid.is_empty()); + let a_agent = local.agent_session(&a).await; + assert!(a_agent.is_string(), "{a_agent}"); + std::fs::write(&bad, "#!/bin/sh\nexit 1\n").unwrap(); + + let reply = local.ok(method::MUX_SESSIONS_RESTART_HARNESS, json!({"sessions": [&a, &b]})).await; + assert_eq!( + (&reply["restarted"], &reply["after_turn"], &reply["failed"]), + (&json!(1), &json!(0), &json!(1)), + "A restarts now, B fails: {reply}" + ); + let errors = reply["errors"].as_array().unwrap_or_else(|| panic!("no errors list: {reply}")); + assert_eq!(errors.len(), 1, "{reply}"); + assert_eq!(errors[0]["session"], json!(&b), "{reply}"); + assert!(errors[0]["error"].as_str().is_some_and(|e| !e.is_empty()), "{reply}"); + + // A runs a new process on the same agent session; C still runs its first. + let a_new = local.harness_pid(&a).await; + assert_ne!(a_new, a_pid, "the named chat's harness restarted"); + assert_eq!(local.agent_session(&a).await, a_agent, "the same agent session resumed"); + assert_eq!(local.harness_pid(&c).await, c_pid, "an unnamed chat is untouched"); + + // Unnamed (Update Everything): every live chat of the harness but the Chief's. + let all = local.ok(method::MUX_SESSIONS_RESTART_HARNESS, json!({"harnesses": ["good"]})).await; + assert_eq!( + all, + json!({"restarted": 1, "after_turn": 0, "failed": 0, "errors": []}), + "only C restarts" + ); + assert_ne!(local.harness_pid(&c).await, c_pid, "C restarted"); + assert_eq!(local.harness_pid(&a).await, a_new, "the Chief's chat is never swept"); + + let _ = std::fs::remove_dir_all(&root); +} diff --git a/cmux-tui/crates/acpmux/tests/harness_startup.rs b/cmux-tui/crates/acpmux/tests/harness_startup.rs index 167a48441055..ac6cd4725276 100644 --- a/cmux-tui/crates/acpmux/tests/harness_startup.rs +++ b/cmux-tui/crates/acpmux/tests/harness_startup.rs @@ -6,6 +6,9 @@ //! ask" with Claude and Codex installed, until the app relaunched. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -100,16 +103,13 @@ impl Drop for Daemon { /// The daemon as the app starts it: the bare GUI PATH, the login shell read /// at startup. Returns once it reports ready. async fn start(dir: &Path) -> Daemon { - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(dir) .args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "warn"]) - .env("HOME", dir) .env("ACPMUX_HOME", dir) .env("ACPMUX_SOCKET", dir.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "1") .env("SHELL", dir.join("loginsh")) .env("PATH", "/usr/bin:/bin") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) diff --git a/cmux-tui/crates/acpmux/tests/hermetic_daemon_guard.rs b/cmux-tui/crates/acpmux/tests/hermetic_daemon_guard.rs new file mode 100644 index 000000000000..6a88a177335f --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/hermetic_daemon_guard.rs @@ -0,0 +1,67 @@ +//! Every `acpmux` process a test starts gets the hermetic test environment +//! (cx-s8wux): only `tests/support/daemon_env.rs` names the binary. A test +//! that starts it on its own keeps the host's environment, and on a fleet +//! host the daemon finds the real codex or claude on PATH and runs live +//! probes, sign-in checks and auto-updates with real accounts (cx-w014j). +//! Start it with `daemon_env::acpmux`, `daemon_env::command` or +//! `daemon_env::apply` instead. + +use std::path::{Path, PathBuf}; + +const HELPER: &str = "support/daemon_env.rs"; + +fn rust_files(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + rust_files(&path, out); + } else if path.extension().is_some_and(|e| e == "rs") { + out.push(path); + } + } +} + +#[test] +fn only_the_hermetic_helper_starts_the_acpmux_binary() { + let tests = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests"); + let binary = "CARGO_BIN_EXE_acpmux"; + // The helper's path starts a process without its environment, so each + // use of it is listed here with why it is safe. + let bin = "daemon_env::bin()"; + let allowed = [ + // Compares the daemon's own directory; starts nothing. + ("chief_builtin_presets.rs", "std::fs::canonicalize(daemon_env::bin())"), + // The agent host launcher: its stand-in child gets the hermetic + // `SpawnSpec.env` (the host process itself only runs that child). + ("agent_host.rs", "HostLauncher { exe: daemon_env::bin().into()"), + // Every start goes through `acpmux(exe, home)`, which calls `apply`. + ("windows_daemon.rs", "daemon_env::bin().to_path_buf()"), + ]; + let mut files = Vec::new(); + rust_files(&tests, &mut files); + let mut offenders = Vec::new(); + for file in &files { + let rel = file.strip_prefix(&tests).unwrap(); + if rel == Path::new(HELPER) || rel == Path::new("hermetic_daemon_guard.rs") { + continue; + } + let text = std::fs::read_to_string(file).unwrap(); + for (n, line) in text.lines().enumerate() { + let listed = allowed.iter().any(|(f, text)| rel == Path::new(f) && line.contains(text)); + if line.contains(binary) || (line.contains(bin) && !listed) { + offenders.push(format!("tests/{}:{}: {}", rel.display(), n + 1, line.trim())); + } + } + } + assert!( + offenders.is_empty(), + "start acpmux through tests/{HELPER} (daemon_env::acpmux, command or apply), \ + never with the host environment:\n{}", + offenders.join("\n") + ); + // The helper itself still clears the environment and denies launches. + let helper = std::fs::read_to_string(tests.join(HELPER)).unwrap(); + for needle in ["env_clear()", "\"ACPMUX_AGENT_LAUNCH\".into(), \"deny\".into()", binary] { + assert!(helper.contains(needle), "tests/{HELPER} lost `{needle}`"); + } +} diff --git a/cmux-tui/crates/acpmux/tests/host_lifetime.rs b/cmux-tui/crates/acpmux/tests/host_lifetime.rs index 73593ed3e098..dc5ade931f3d 100644 --- a/cmux-tui/crates/acpmux/tests/host_lifetime.rs +++ b/cmux-tui/crates/acpmux/tests/host_lifetime.rs @@ -5,10 +5,13 @@ //! with parent pid 1, most in deleted test homes. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; @@ -34,18 +37,14 @@ fn scratch(tag: &str) -> PathBuf { } fn start_daemon(home: &Path) -> Child { - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(home, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", home) .env("ACPMUX_SOCKET", home.join("s.sock")) // No catalog fetch: a test never reaches the network, and the fetch's // warning (a catalog server 404) came on stdout before the ready line. .env("ACPMUX_CATALOG_FETCH", "0") - .env_remove("ACPMUX_AGENT_HOSTS") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("ACPMUX_LOCAL_ROUTER") - .env_remove("XPC_SERVICE_NAME") - .env_remove("ACPMUX_IDLE_CHILD_SECS") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -212,3 +211,85 @@ async fn a_pooled_host_ends_with_its_daemon_and_a_taken_one_stays() { std::fs::remove_dir_all(&home).unwrap(); assert!(gone_within(taken_host, Duration::from_secs(10)), "taken host outlived its home"); } + +/// Read syscalls and CPU ticks the process has used so far (Linux /proc). +#[cfg(target_os = "linux")] +fn usage(pid: i64) -> (u64, u64) { + let io = std::fs::read_to_string(format!("/proc/{pid}/io")).expect("/proc io"); + let reads = io + .lines() + .find_map(|l| l.strip_prefix("syscr:")) + .and_then(|v| v.trim().parse().ok()) + .expect("syscr"); + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("/proc stat"); + // Fields after the command name: state is field 3, utime 14, stime 15. + let rest: Vec<&str> = stat.rsplit_once(')').unwrap().1.split_whitespace().collect(); + let ticks = rest[11].parse::().unwrap() + rest[12].parse::().unwrap(); + (reads, ticks) +} + +/// Idle usage of `pid` over `secs` seconds: (read syscalls, CPU ticks). +#[cfg(target_os = "linux")] +async fn idle_usage(pid: i64, secs: u64) -> (u64, u64) { + let (reads, ticks) = usage(pid); + tokio::time::sleep(Duration::from_secs(secs)).await; + let (reads_after, ticks_after) = usage(pid); + (reads_after - reads, ticks_after - ticks) +} + +/// Ends the daemon and the host and deletes the home when the test ends, +/// also on a failed assertion (a spinning host must not outlive the test). +#[cfg(target_os = "linux")] +struct Cleanup { + daemon: Child, + /// Set while the host may still run; None once it is gone (its pid + /// may then belong to another process). + host: Option, + home: PathBuf, +} + +#[cfg(target_os = "linux")] +impl Drop for Cleanup { + fn drop(&mut self) { + let _ = self.daemon.kill(); + let _ = self.daemon.wait(); + if let Some(host) = self.host.filter(|&host| alive(host)) { + // SAFETY: SIGKILL to the host this test started, still running. + unsafe { libc::kill(host as i32, libc::SIGKILL) }; + } + let _ = std::fs::remove_dir_all(&self.home); + } +} + +/// A pooled host waits without work while nothing changes: its record watch +/// never wakes on the reads of the record it makes itself (inotify reports +/// every open and read in a watched directory; the host spun at 100% CPU, +/// about 200,000 read calls a second, on the Linux build host). A record +/// rewritten by rename keeps it; the record removed ends it. +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pooled_host_is_idle_and_follows_its_record() { + let home = scratch("idle"); + let mut cleanup = Cleanup { daemon: start_daemon(&home), host: None, home: home.clone() }; + let mut rpc = Rpc::connect(&home).await; + rpc.pool_ready(&home, "fakeb").await; + let pool = home.join("hosts").join("pool"); + let session = pool_session(&home); + let path = pool.join(format!("{session}.json")); + let (host, _) = pids(&record(&pool, &session)); + cleanup.host = Some(host); + let (reads, ticks) = idle_usage(host, 3).await; + assert!(reads < 100 && ticks < 30, "idle pooled host {host}: {reads} reads, {ticks} ticks"); + // A record rewritten in place (write, then rename) is still this host's. + let bytes = std::fs::read(&path).unwrap(); + let staged = pool.join(format!("{session}.rewrite")); + std::fs::write(&staged, &bytes).unwrap(); + std::fs::rename(&staged, &path).unwrap(); + let (reads, ticks) = idle_usage(host, 2).await; + assert!(alive(host), "a rename rewrite of the record ended the host"); + assert!(reads < 100 && ticks < 30, "after the rewrite {host}: {reads} reads, {ticks} ticks"); + std::fs::remove_file(&path).unwrap(); + assert!(gone_within(host, Duration::from_secs(10)), "host {host} outlived its record"); + cleanup.host = None; + drop(rpc); +} diff --git a/cmux-tui/crates/acpmux/tests/hub_integration.rs b/cmux-tui/crates/acpmux/tests/hub_integration.rs index 2b0dba25d598..d994b5409078 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration.rs @@ -914,16 +914,7 @@ async fn catalog_reload_preserves_pending_turn_and_rejects_invalid_config() { ); next.presets.insert( "flash".into(), - acpmux::config::Preset { - harness: "deepseek".into(), - model: None, - effort: None, - policy: None, - env: BTreeMap::new(), - args: Vec::new(), - system_prompt_sha256: None, - description: None, - }, + serde_json::from_value::(json!({"harness": "deepseek"})).unwrap(), ); std::fs::write(&path, serde_json::to_vec(&next).unwrap()).unwrap(); let reload = c.request(method::MUX_RELOAD_CONFIG, json!({})).await.unwrap(); @@ -1333,6 +1324,8 @@ mod quit_spawn; mod fork_through; #[path = "hub_integration/last_message.rs"] mod last_message; +#[path = "hub_integration/session_git_state.rs"] +mod session_git_state; #[cfg(unix)] #[path = "hub_integration/session_list_order.rs"] mod session_list_order; diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/session_git_state.rs b/cmux-tui/crates/acpmux/tests/hub_integration/session_git_state.rs new file mode 100644 index 000000000000..f885563464a0 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/hub_integration/session_git_state.rs @@ -0,0 +1,146 @@ +//! A session records its folder's git branch and HEAD commit as they were +//! when it started (hq#1920), so a later report can join it to the pull +//! request it produced. A fork records its own folder's state at fork time. + +use super::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// A fresh folder for one test, canonical (macOS's temp dir is a symlink). +fn scratch(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("acpmux-session-git-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(dir).unwrap() +} + +/// Runs git in `dir` and returns its trimmed output. +fn git(dir: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(["-c", "user.name=acpmux", "-c", "user.email=acpmux@example.com"]) + .args(["-c", "commit.gpgsign=false"]) + .args(args) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .env_remove("GIT_INDEX_FILE") + .output() + .unwrap(); + assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_owned() +} + +/// A repository at `dir` on `branch` with one commit; returns that commit. +fn repo(dir: &Path, branch: &str) -> String { + git(dir, &["init", "-q"]); + git(dir, &["checkout", "-q", "-b", branch]); + git(dir, &["commit", "-q", "--allow-empty", "-m", "init"]); + git(dir, &["rev-parse", "HEAD"]) +} + +async fn new_session(c: &mut TestClient, cwd: &Path) -> String { + let s = c.request(method::SESSION_NEW, json!({"cwd": cwd, "mcpServers": []})).await.unwrap(); + s["sessionId"].as_str().unwrap().to_owned() +} + +/// The session's saved meta as session.json holds it. +fn saved(hub: &Hub, id: &str) -> Value { + serde_json::to_value(hub.resolve(id).unwrap().meta()).unwrap() +} + +#[tokio::test] +async fn git_state_records_the_branch_and_head_at_session_new() { + let dir = scratch("branch"); + let head = repo(&dir, "feature/git-state"); + let (hub, mut c) = setup(PermissionPolicy::ApproveAll).await; + let id = new_session(&mut c, &dir).await; + + let meta = saved(&hub, &id); + assert_eq!(meta["gitBranch"], "feature/git-state", "{meta}"); + assert_eq!(meta["gitHead"], head.as_str(), "{meta}"); + let info = c.request(method::MUX_INFO, json!({"sessionId": id})).await.unwrap(); + assert_eq!(info["gitBranch"], "feature/git-state", "{info}"); + assert_eq!(info["gitHead"], head.as_str(), "{info}"); + + // Started-at state: a later commit or checkout does not rewrite it. + git(&dir, &["checkout", "-q", "-b", "later"]); + git(&dir, &["commit", "-q", "--allow-empty", "-m", "later"]); + let meta = saved(&hub, &id); + assert_eq!(meta["gitBranch"], "feature/git-state", "{meta}"); + assert_eq!(meta["gitHead"], head.as_str(), "{meta}"); +} + +#[tokio::test] +async fn git_state_on_a_detached_head_records_only_the_head() { + let dir = scratch("detached"); + let head = repo(&dir, "main"); + git(&dir, &["checkout", "-q", "--detach"]); + let (hub, mut c) = setup(PermissionPolicy::ApproveAll).await; + let id = new_session(&mut c, &dir).await; + + let meta = saved(&hub, &id); + assert!(meta.get("gitBranch").is_none(), "{meta}"); + assert_eq!(meta["gitHead"], head.as_str(), "{meta}"); +} + +#[tokio::test] +async fn git_state_outside_a_repository_records_nothing() { + let dir = scratch("plain"); + let (hub, mut c) = setup(PermissionPolicy::ApproveAll).await; + let id = new_session(&mut c, &dir).await; + + let meta = saved(&hub, &id); + assert!(meta.get("gitBranch").is_none(), "{meta}"); + assert!(meta.get("gitHead").is_none(), "{meta}"); + let info = c.request(method::MUX_INFO, json!({"sessionId": id})).await.unwrap(); + assert!(info["gitBranch"].is_null() && info["gitHead"].is_null(), "{info}"); +} + +#[tokio::test] +async fn git_state_of_a_fork_is_its_folder_at_fork_time() { + let dir = scratch("fork"); + let first = repo(&dir, "first"); + let (hub, mut c) = setup(PermissionPolicy::ApproveAll).await; + let id = new_session(&mut c, &dir).await; + c.request( + method::SESSION_PROMPT, + json!({"sessionId": id, "prompt": [{"type": "text", "text": "hi"}]}), + ) + .await + .unwrap(); + + git(&dir, &["checkout", "-q", "-b", "second"]); + git(&dir, &["commit", "-q", "--allow-empty", "-m", "second"]); + let second = git(&dir, &["rev-parse", "HEAD"]); + let f = c.request(method::SESSION_FORK, json!({"sessionId": id, "cwd": dir})).await.unwrap(); + let fid = f["sessionId"].as_str().unwrap().to_owned(); + + let fork = saved(&hub, &fid); + assert_eq!(fork["gitBranch"], "second", "{fork}"); + assert_eq!(fork["gitHead"], second.as_str(), "{fork}"); + let parent = saved(&hub, &id); + assert_eq!(parent["gitBranch"], "first", "{parent}"); + assert_eq!(parent["gitHead"], first.as_str(), "{parent}"); +} + +#[test] +fn git_state_absent_from_an_old_session_json_still_loads() { + let old: acpmux::store::SessionMeta = serde_json::from_value(json!({ + "schema": "acpmux.session.v1", + "id": "old", + "name": "old", + "harness": "fake", + "cwd": "/tmp", + "status": "idle", + "createdAt": 1, + "updatedAt": 1, + })) + .unwrap(); + let back = serde_json::to_value(&old).unwrap(); + assert!(back.get("gitBranch").is_none() && back.get("gitHead").is_none(), "{back}"); +} diff --git a/cmux-tui/crates/acpmux/tests/local_app_origin.rs b/cmux-tui/crates/acpmux/tests/local_app_origin.rs index 78e1e88a0ff2..e1415038e1d4 100644 --- a/cmux-tui/crates/acpmux/tests/local_app_origin.rs +++ b/cmux-tui/crates/acpmux/tests/local_app_origin.rs @@ -5,11 +5,14 @@ //! Origin header, or another page) stays remote-origin even with the token. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::Duration; use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest}; @@ -54,13 +57,11 @@ impl Daemon { } fn launch(&mut self) { - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(&self.home, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "error"]) .env("ACPMUX_HOME", &self.home) .env("ACPMUX_SOCKET", self.home.join("s.sock")) - .env_remove("ACPMUX_AGENT_HOSTS") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .envs(self.env.iter().copied()) .stdin(Stdio::null()) .stdout(Stdio::piped()) diff --git a/cmux-tui/crates/acpmux/tests/peer_integration.rs b/cmux-tui/crates/acpmux/tests/peer_integration.rs index 21d3560c912e..b5a11ce35245 100644 --- a/cmux-tui/crates/acpmux/tests/peer_integration.rs +++ b/cmux-tui/crates/acpmux/tests/peer_integration.rs @@ -678,6 +678,87 @@ async fn every_answer_for_a_peers_prompt_goes_to_the_peer_within_a_hop_limit() { assert_eq!(decided["msg"]["outcome"]["optionId"], "no", "{decided}"); } +/// A pending prompt that only the person may allow reaches every app that +/// watches the session, also through a peer: a watcher on A that never +/// attached B's session gets `_acpmux/permission_pending` with B's decision +/// nonce and run, so A's app can show it and notify. An allow from a +/// connection that is not the person, without B's proof, is refused on B +/// and the prompt stays pending; B's proof counts. +#[tokio::test] +async fn peered_watcher_sees_a_pending_prompt_and_only_the_persons_proof_allows_it() { + const B_KEY: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + let b = hub(config(PermissionPolicy::Ask)).await; + b.person.install_spawn_key(B_KEY).unwrap(); + let (port, peer_token) = listen_with_peer_token(b.clone(), "watchpend").await; + let mut cb = plain(b.clone()).await; + let s = cb + .call( + method::SESSION_NEW, + json!({"cwd": std::env::temp_dir(), "mcpServers": [], "_meta": {"acpmux": {"name": "far"}}}), + ) + .await + .unwrap(); + let remote_id = s["sessionId"].as_str().unwrap().to_owned(); + cb.call(method::MUX_ATTACH, json!({"sessionId": remote_id, "limit": 0})).await.unwrap(); + let a = hub(config(PermissionPolicy::Ask)).await; + a.add_peer("b", &format!("ws://127.0.0.1:{port}"), Some("tok".into()), Some(peer_token), true) + .await + .unwrap(); + // A's watcher: watches everything, attaches nothing, is not the person. + let mut watcher = plain(a.clone()).await; + watcher.call(method::MUX_WATCH, json!({"enabled": true})).await.unwrap(); + + // The prompt is raised on B by B's own client. + cb.next += 1; + let prompt_id = cb.next; + cb.tx + .send( + Message::request( + prompt_id, + method::SESSION_PROMPT, + json!({"sessionId": remote_id, "prompt": [{"type": "text", "text": "ask: deploy"}]}), + ) + .to_line(), + ) + .await + .unwrap(); + let pending = + watcher.wait(method::MUX_PERMISSION_PENDING, |p| p["sessionId"] == remote_id).await; + let perm = pending["permissionId"].as_str().expect("the prompt's id").to_owned(); + let nonce = pending["decisionNonce"].as_str().expect("B's decision nonce").to_owned(); + let instance = pending["personInstance"].as_str().expect("B's daemon run").to_owned(); + assert_eq!(pending["peer"], "b", "{pending}"); + assert_eq!(pending["request"]["toolCall"]["title"], "deploy", "{pending}"); + + // Not the person, no proof: refused on B; still pending there. + let allow = |meta: Value| { + json!({"sessionId": remote_id, "permissionId": perm, "optionId": "yes", + "_meta": {"acpmux": meta}}) + }; + let e = watcher.call(method::MUX_PERMISSION_RESPOND, allow(json!({}))).await.unwrap_err(); + assert!(e.contains("approve this on the Mac app"), "{e}"); + let info = cb.call(method::MUX_INFO, json!({"sessionId": remote_id})).await.unwrap(); + assert_eq!(info["pending"][0]["permissionId"], perm.as_str(), "{info}"); + + // The person's proof (made by the app that holds B's key) counts. + let good = + acpmux::hub::person::decision_proof(B_KEY, &instance, &remote_id, &perm, "yes", &nonce) + .unwrap(); + watcher + .call( + method::MUX_PERMISSION_RESPOND, + allow(json!({"decisionNonce": nonce, "personDecision": good})), + ) + .await + .unwrap(); + let decided = cb + .wait(method::MUX_EVENT, |p| { + p["kind"] == "permission_decision" && p["sessionId"] == remote_id + }) + .await; + assert_eq!(decided["msg"]["outcome"]["optionId"], "yes", "{decided}"); +} + /// Prompt bookmarks of a peer's chat live on the daemon that owns it: A forwards both /// `_acpmux/bookmark_set` and `_acpmux/bookmark_get` to B, which checks the seq in its own log. #[tokio::test] @@ -918,3 +999,137 @@ async fn opening_a_long_peer_chat_stays_within_its_payload_budget_on_the_link() assert_eq!(older["hasMore"], true); let _ = std::fs::remove_dir_all(&dir); } + +/// A peer daemon that says what it likes (`hub/person.rs` (c)): a WebSocket +/// server that answers `initialize` with `instance` as its daemon run, +/// lists `sessions` on `_acpmux/watch`, then sends each of `pending` as +/// `_acpmux/permission_pending`. Returns its port. +async fn lying_peer(instance: String, sessions: Vec, pending: Vec) -> u16 { + use futures_util::{SinkExt, StreamExt}; + use tokio_tungstenite::tungstenite::Message as Ws; + let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((tcp, _)) = l.accept().await { + let Ok(mut ws) = tokio_tungstenite::accept_async(tcp).await else { continue }; + let (instance, sessions, pending) = + (instance.clone(), sessions.clone(), pending.clone()); + tokio::spawn(async move { + while let Some(Ok(Ws::Text(text))) = ws.next().await { + let Ok(Message::Request { id, method: m, .. }) = Message::parse(&text) else { + continue; + }; + let result = match m.as_str() { + method::INITIALIZE => json!({"protocolVersion": 1, "_meta": {"acpmux": { + "version": "0", "build": "lying", "origin": "peer", + "personInstance": instance}}}), + method::MUX_WATCH => json!({"sessions": sessions}), + _ => json!({}), + }; + let reply = Message::ok(id, result).to_line(); + if ws.send(Ws::Text(reply.trim_end().to_owned().into())).await.is_err() { + return; + } + if m == method::MUX_WATCH { + // After this daemon took the session list in. + tokio::time::sleep(Duration::from_millis(500)).await; + for p in &pending { + let note = + Message::notification(method::MUX_PERMISSION_PENDING, p.clone()); + let line = note.to_line().trim_end().to_owned(); + let _ = ws.send(Ws::Text(line.into())).await; + } + } + } + }); + } + }); + port +} + +/// A prompt as a peer's `_acpmux/permission_pending` carries it. +fn peer_prompt(session: &str, permission: &str, instance: &str) -> Value { + json!({"sessionId": session, "permissionId": permission, "decisionNonce": "n-1", + "personInstance": instance, "seq": 1, + "request": {"toolCall": {"title": format!("{permission} text")}, "options": [ + {"optionId": "yes", "kind": "allow_once"}, {"optionId": "no", "kind": "reject_once"}]}}) +} + +/// A's watcher with a session of its own and the lying peer `l` that asks +/// `forged` first, then one prompt that it may relay (`legit`, for its own +/// session `far-1` in its own run `l-run`). Returns every pending prompt the +/// watcher got up to the legit one. +async fn pendings_through_a_lying_peer( + forged: impl FnOnce(&str, &str) -> Vec, +) -> Vec { + let a = hub(config(PermissionPolicy::Ask)).await; + let mut ca = plain(a.clone()).await; + let own = ca + .call(method::SESSION_NEW, json!({"cwd": std::env::temp_dir(), "mcpServers": []})) + .await + .unwrap(); + let own = own["sessionId"].as_str().unwrap().to_owned(); + let mine = a.person.instance().to_owned(); + let mut pending = forged(&own, &mine); + pending.push(peer_prompt("far-1", "legit", "l-run")); + // It lists A's own session as its own too: a claim, never a proof. + let sessions = vec![ + json!({"sessionId": "far-1", "name": "far"}), + json!({"sessionId": own, "name": "mine"}), + ]; + let port = lying_peer("l-run".to_owned(), sessions, pending).await; + let mut watcher = plain(a.clone()).await; + watcher.call(method::MUX_WATCH, json!({"enabled": true})).await.unwrap(); + a.add_peer("l", &format!("ws://127.0.0.1:{port}"), Some("tok".into()), None, true) + .await + .unwrap(); + let mut got = Vec::new(); + loop { + let p = watcher.wait(method::MUX_PERMISSION_PENDING, |_| true).await; + let legit = p["permissionId"] == "legit"; + got.push(p); + if legit { + return got; + } + } +} + +/// A peer's pending prompt for a session of this daemon is never relayed: +/// with this daemon's own nonce, the person's allow of its text would be a +/// proof for the real local prompt. +#[tokio::test] +async fn peer_pending_for_a_local_session_is_dropped() { + let got = + pendings_through_a_lying_peer(|own, _| vec![peer_prompt(own, "forged", "l-run")]).await; + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0]["peer"], "l", "{got:?}"); +} + +/// A peer's pending prompt in this daemon's own run is never relayed: the +/// app on this host holds this run's key. +#[tokio::test] +async fn peer_pending_with_this_daemons_instance_is_dropped() { + let got = + pendings_through_a_lying_peer(|_, mine| vec![peer_prompt("far-1", "forged", mine)]).await; + assert_eq!(got.len(), 1, "{got:?}"); +} + +/// A peer's pending prompt for a session it never listed is never relayed. +#[tokio::test] +async fn peer_pending_for_an_unknown_session_is_dropped() { + let got = + pendings_through_a_lying_peer(|_, _| vec![peer_prompt("nobody", "forged", "l-run")]).await; + assert_eq!(got.len(), 1, "{got:?}"); +} + +/// A peer's prompt for its own session in its own run reaches the watcher, +/// with which peer asks. +#[tokio::test] +async fn peer_pending_legit_is_relayed() { + let got = pendings_through_a_lying_peer(|_, _| vec![]).await; + assert_eq!(got.len(), 1, "{got:?}"); + assert_eq!(got[0]["sessionId"], "far-1", "{got:?}"); + assert_eq!(got[0]["decisionNonce"], "n-1", "{got:?}"); + assert_eq!(got[0]["peer"], "l", "{got:?}"); + assert_eq!(got[0]["via"], "peer", "{got:?}"); +} diff --git a/cmux-tui/crates/acpmux/tests/person_allow.rs b/cmux-tui/crates/acpmux/tests/person_allow.rs index 283597902d59..62e2cedd495d 100644 --- a/cmux-tui/crates/acpmux/tests/person_allow.rs +++ b/cmux-tui/crates/acpmux/tests/person_allow.rs @@ -8,6 +8,9 @@ //! comes to an unsigned daemon only on `--person-key-fd` at spawn) may. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::os::unix::process::CommandExt; @@ -159,13 +162,11 @@ impl Daemon { ) .unwrap(); let socket = dir.join("s.sock"); - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + let mut cmd = daemon_env::command(&dir, daemon_env::Agents::StandIn, &[]); cmd.args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", &dir) .env("ACPMUX_SOCKET", &socket) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()); @@ -663,13 +664,12 @@ async fn a_person_proof_replayed_on_another_connection_is_refused() { /// Starts the real daemon with `args` in `dir` (with its config.json): its /// ready line, or None when it exited first, with its exit status and stderr. fn run_until_ready(dir: &Path, args: &[&str]) -> (Option, std::process::Child) { - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(dir) .args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "warn"]) .args(args) .env("ACPMUX_HOME", dir) .env("ACPMUX_SOCKET", dir.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -922,13 +922,12 @@ fn start_home(tag: &str) -> PathBuf { /// `acpmux ` with this home, `stdin` as its standard input. fn acpmux_cli(home: &Path, args: &[&str], stdin: Option<&str>) -> std::process::Output { - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + // `daemon start` passes this environment on to the daemon it starts. + let mut cmd = daemon_env::acpmux(home); cmd.args(args) .env("ACPMUX_HOME", home) .env("ACPMUX_SOCKET", home.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); @@ -997,7 +996,7 @@ fn the_key_reaches_the_daemon_only_after_it_is_not_dumpable() { let kind = libc::SOCK_STREAM | libc::SOCK_CLOEXEC; assert_eq!(unsafe { libc::socketpair(libc::AF_UNIX, kind, 0, fds.as_mut_ptr()) }, 0); let (ours, theirs) = (fds[0], fds[1]); - let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + let mut cmd = daemon_env::acpmux(&home); cmd.args([ "daemon", "run", @@ -1012,7 +1011,6 @@ fn the_key_reaches_the_daemon_only_after_it_is_not_dumpable() { .env("ACPMUX_HOME", &home) .env("ACPMUX_SOCKET", home.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()); diff --git a/cmux-tui/crates/acpmux/tests/person_auto_start.rs b/cmux-tui/crates/acpmux/tests/person_auto_start.rs new file mode 100644 index 000000000000..d9a887293d5c --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/person_auto_start.rs @@ -0,0 +1,678 @@ +//! A session that starts in a mode that does not ask, from the CLI, a script +//! or an agent (`hub/person.rs`, "Starting in a mode that does not ask"). +//! The real daemon binary over its real unix socket: +//! +//! - (a) inherit: a caller that is itself an acpmux session, proven by the +//! caller token acpmux put in that session's env (never by a claim in +//! params), may start a session of its own family in its own current mode, +//! never another mode that does not ask; +//! - (b) a preset the person approved once (`approveMode`) starts in its +//! mode from any client, until the preset changes; +//! - (d) every refusal names the host to approve on and the preset to use. +#![cfg(unix)] + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader}; +use std::os::unix::process::CommandExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + +const FAKE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); +const REASON: &str = "permission.person_required"; +const KEY: &str = "5e1f0c2a9b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f"; + +fn person_proof(key: &str, nonce: &str, connection: &str) -> Option { + use cmux_local_auth::frontend_proof::{hex, hmac_sha256, unhex}; + let key = unhex::<32>(key)?; + let mut message = b"acpmux-person-v1\0unix\0".to_vec(); + message.extend_from_slice(nonce.as_bytes()); + message.push(0); + message.extend_from_slice(connection.as_bytes()); + Some(hex(&hmac_sha256(&key, &message))) +} + +/// The host the refusal names: this machine's host name without `.local`. +fn host_label() -> String { + let mut buf = [0u8; 256]; + // SAFETY: a writable buffer of the given length. + let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) }; + assert_eq!(rc, 0, "gethostname"); + let end = buf.iter().position(|b| *b == 0).unwrap_or(buf.len()); + let name = String::from_utf8_lossy(&buf[..end]).trim().to_owned(); + name.strip_suffix(".local").map(str::to_owned).unwrap_or(name) +} + +struct Rpc { + lines: tokio::io::Lines>, + wr: tokio::net::unix::OwnedWriteHalf, + next: i64, + notes: Vec, +} + +impl Rpc { + async fn connect(path: &Path) -> Self { + let s = tokio::net::UnixStream::connect(path).await.expect("connect to daemon socket"); + let (rd, wr) = s.into_split(); + Self { lines: tokio::io::BufReader::new(rd).lines(), wr, next: 0, notes: Vec::new() } + } + + /// A client that only says `initialize` (the CLI outside any session). + async fn plain(path: &Path) -> Self { + Self::with_init(path, json!({"protocolVersion": 1})).await + } + + /// A client whose `initialize` carries `params` (the CLI inside a + /// session presents its caller token there). + async fn with_init(path: &Path, params: Value) -> Self { + let mut c = Self::connect(path).await; + let r = c.call("initialize", params).await; + assert!(r.get("error").is_none(), "initialize: {r}"); + c + } + + /// The CLI inside session `id`, as `Client::connect` presents it. + async fn in_session(path: &Path, id: &str, token: &str) -> Self { + Self::with_init( + path, + json!({"protocolVersion": 1, + "_meta": {"acpmux": {"callerSession": {"sessionId": id, "token": token}}}}), + ) + .await + } + + /// The app: `initialize`, then the person proof. + async fn person(path: &Path, key: &str) -> Self { + let mut c = Self::connect(path).await; + let r = c.call("initialize", json!({"protocolVersion": 1})).await; + let challenge = r["result"]["_meta"]["acpmux"]["personChallenge"].clone(); + let proof = person_proof( + key, + challenge["nonce"].as_str().unwrap(), + challenge["connection"].as_str().unwrap(), + ) + .unwrap(); + let r = c.call("_acpmux/person_prove", json!({"proof": proof})).await; + assert_eq!(r["result"]["person"], true, "{r}"); + c + } + + async fn line(&mut self) -> Value { + let line = tokio::time::timeout(Duration::from_secs(30), self.lines.next_line()) + .await + .expect("daemon answered in time") + .unwrap() + .expect("daemon closed the socket"); + serde_json::from_str(&line).unwrap() + } + + async fn send(&mut self, method: &str, params: Value) -> i64 { + self.next += 1; + let line = json!({"jsonrpc": "2.0", "id": self.next, "method": method, "params": params}); + self.wr.write_all(format!("{line}\n").as_bytes()).await.unwrap(); + self.next + } + + async fn call(&mut self, method: &str, params: Value) -> Value { + let id = self.send(method, params).await; + self.reply(id).await + } + + async fn reply(&mut self, id: i64) -> Value { + if let Some(i) = self.notes.iter().position(|n| n.get("id") == Some(&json!(id))) { + return self.notes.remove(i); + } + loop { + let v = self.line().await; + if v.get("id") == Some(&json!(id)) { + return v; + } + self.notes.push(v); + } + } + + async fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params).await; + assert!(r.get("error").is_none(), "{method} failed: {r}"); + r["result"].clone() + } + + async fn note(&mut self, method: &str, pick: impl Fn(&Value) -> bool) -> Value { + if let Some(i) = self.notes.iter().position(|n| n["method"] == method && pick(&n["params"])) + { + return self.notes.remove(i)["params"].clone(); + } + loop { + let v = self.line().await; + if v["method"] == method && pick(&v["params"]) { + return v["params"].clone(); + } + self.notes.push(v); + } + } +} + +struct Daemon { + child: std::process::Child, + socket: PathBuf, + dir: PathBuf, +} + +impl Daemon { + /// The real daemon with the app's key on `--person-key-fd 3`. The fake + /// harness asks in `normal` and `strict`; any other mode does not ask. + /// `wide` also offers `full-access`, the start mode of a new chat + /// (`hub/start_mode.rs`, `defaultPermissionMode` set for a hermetic run). + fn start(tag: &str) -> Self { + let dir = std::env::temp_dir().join(format!("aas-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("work")).unwrap(); + let dir = std::fs::canonicalize(&dir).unwrap(); + std::fs::write( + dir.join("config.json"), + json!({ + "harnesses": { + "fake": {"argv": ["python3", FAKE]}, + "mirror": {"argv": ["python3", FAKE], "family": "mirror"}, + "other": {"argv": ["python3", FAKE], "family": "fake", "env": {"FAKE_SESSION_PREFIX": "other-"}}, + "gated": {"argv": ["python3", FAKE], "family": "fake", "env": {"FAKE_START_GATE": dir.join("gate")}}, + "wide": {"argv": ["python3", FAKE], "family": "wide", "env": {"FAKE_FULL_ACCESS": "1"}}, + }, + "defaultHarness": "fake", + "permissionPolicy": "ask", + "defaultPermissionMode": "fullAccess", + "webAskingModes": {"fake": ["normal", "strict"], "mirror": ["normal", "strict"], "wide": ["normal", "strict"]}, + }) + .to_string(), + ) + .unwrap(); + let socket = dir.join("s.sock"); + let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")); + cmd.args(["daemon", "run", "--memory", "--ready-fd", "1", "--log", "warn"]) + .args(["--person-key-fd", "3"]) + .env("ACPMUX_HOME", &dir) + .env("ACPMUX_SOCKET", &socket) + .env("ACPMUX_LOGIN_ENV", "0") + .env_remove("ACPMUX_SESSION_ID") + .env_remove("ACPMUX_SESSION_TOKEN") + .env_remove("XPC_SERVICE_NAME") + .env_remove("CLAUDECODE") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + let mut fds = [-1i32; 2]; + // SAFETY: a fresh pipe; both ends are owned here. + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0); + let line = format!("{KEY}\n"); + // SAFETY: the write end is open; the line fits the pipe buffer. + let n = unsafe { libc::write(fds[1], line.as_ptr().cast(), line.len()) }; + assert_eq!(n as usize, line.len()); + // SAFETY: closing our own write end. + unsafe { libc::close(fds[1]) }; + let read_end = fds[0]; + // SAFETY: only dup2 between fork and exec (async-signal-safe). + unsafe { + cmd.pre_exec(move || { + if libc::dup2(read_end, 3) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = cmd.spawn().unwrap(); + // SAFETY: the child has its own copy on fd 3. + unsafe { libc::close(fds[0]) }; + let stdout = child.stdout.take().unwrap(); + let ready = BufReader::new(stdout) + .lines() + .map_while(Result::ok) + .any(|line| serde_json::from_str::(&line).is_ok_and(|v| v["ready"] == true)); + assert!(ready, "the daemon exited before its ready line"); + Daemon { child, socket, dir } + } + + fn work(&self) -> PathBuf { + self.dir.join("work") + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + // SAFETY: the pid of the child this test spawned and still owns. + unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn reason(v: &Value) -> &str { + v["error"]["data"]["reason"].as_str().unwrap_or_default() +} + +fn message(v: &Value) -> &str { + v["error"]["message"].as_str().unwrap_or_default() +} + +/// `session/new` params on `harness` with `_meta.acpmux` fields `meta`. +fn new_params(d: &Daemon, meta: Value) -> Value { + json!({"cwd": d.work(), "mcpServers": [], "_meta": {"acpmux": meta}}) +} + +fn current_mode(new_result: &Value) -> &str { + new_result["modes"]["currentModeId"].as_str().unwrap_or_default() +} + +async fn session_count(c: &mut Rpc) -> usize { + let v = c.ok("_acpmux/sessions", json!({})).await; + v["sessions"].as_array().map_or(0, Vec::len) +} + +/// The person's session on `harness` in `mode`, and the caller token its +/// agent sees in its env (`ACPMUX_SESSION_TOKEN`). +async fn parent_in(app: &mut Rpc, d: &Daemon, harness: &str, mode: &str) -> (String, String) { + let s = app.ok("session/new", new_params(d, json!({"harness": harness}))).await; + let id = s["sessionId"].as_str().unwrap().to_owned(); + app.ok("_acpmux/attach", json!({"sessionId": id})).await; + app.ok("session/set_mode", json!({"sessionId": id, "modeId": mode})).await; + let rid = app + .send( + "session/prompt", + json!({"sessionId": id, "prompt": [{"type": "text", "text": "env: ACPMUX_SESSION_TOKEN"}]}), + ) + .await; + let chunk = app + .note("session/update", |p| { + p["sessionId"] == id.as_str() && p["update"]["sessionUpdate"] == "agent_message_chunk" + }) + .await; + let text = chunk["update"]["content"]["text"].as_str().unwrap_or_default().to_owned(); + let done = app.reply(rid).await; + assert!(done.get("error").is_none(), "{done}"); + let token = text.strip_prefix("ACPMUX_SESSION_TOKEN=").unwrap_or_default().to_owned(); + assert_eq!(token.len(), 64, "the session's agent gets a caller token: {text:?}"); + // acpmux never shows the token to clients (only the agent's own reply, + // which printed it here, carries it). + let mut info = app.ok("_acpmux/info", json!({"sessionId": id})).await; + for echoed in ["lastMessage", "preview"] { + info.as_object_mut().unwrap().remove(echoed); + } + assert!(!info.to_string().contains(&token), "{info}"); + (id, token) +} + +#[tokio::test] +async fn auto_start_a_session_in_a_no_ask_mode_starts_a_session_in_its_own_mode() { + let d = Daemon::start("inherit"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (parent, token) = parent_in(&mut app, &d, "fake", "bypass").await; + let mut agent = Rpc::in_session(&d.socket, &parent, &token).await; + let child = + agent.ok("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))).await; + assert_eq!(current_mode(&child), "bypass", "{child}"); + let info = agent.ok("_acpmux/info", json!({"sessionId": child["sessionId"]})).await; + assert_eq!(info["modes"]["currentModeId"], "bypass", "{info}"); + // An asking mode needs no caller at all. + let mut cli = Rpc::plain(&d.socket).await; + let asking = + cli.ok("session/new", new_params(&d, json!({"harness": "fake", "mode": "strict"}))).await; + assert_eq!(current_mode(&asking), "strict", "{asking}"); +} + +#[tokio::test] +async fn auto_start_a_caller_that_is_no_session_needs_the_person_for_a_mode_that_does_not_ask() { + let d = Daemon::start("nosession"); + let mut app = Rpc::person(&d.socket, KEY).await; + let _ = parent_in(&mut app, &d, "fake", "bypass").await; + let mut cli = Rpc::plain(&d.socket).await; + let before = session_count(&mut cli).await; + let r = + cli.call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + // Refused before anything starts. + assert_eq!(session_count(&mut cli).await, before); + // The person may. + let s = + app.ok("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))).await; + assert_eq!(current_mode(&s), "bypass", "{s}"); +} + +#[tokio::test] +async fn auto_start_a_session_never_starts_one_in_another_mode_that_does_not_ask() { + let d = Daemon::start("stronger"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (bypass, bypass_token) = parent_in(&mut app, &d, "fake", "bypass").await; + let (asking, asking_token) = parent_in(&mut app, &d, "fake", "normal").await; + let mut agent = Rpc::in_session(&d.socket, &bypass, &bypass_token).await; + // Another mode that does not ask: refused. + let r = + agent.call("session/new", new_params(&d, json!({"harness": "fake", "mode": "yolo"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + // Its own mode on another family (mode ids are per family): refused. + let r = agent + .call("session/new", new_params(&d, json!({"harness": "mirror", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + // A session that asks starts nothing that does not ask. + let mut asker = Rpc::in_session(&d.socket, &asking, &asking_token).await; + let r = asker + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + // A caller that left its no-ask mode inherits nothing any more. + app.ok("session/set_mode", json!({"sessionId": bypass, "modeId": "normal"})).await; + let r = agent + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_caller_session_named_in_params_or_with_a_wrong_token_counts_for_nothing() { + let d = Daemon::start("spoof"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (parent, token) = parent_in(&mut app, &d, "fake", "bypass").await; + // A wrong token at initialize. + let mut wrong = Rpc::in_session(&d.socket, &parent, &"0".repeat(64)).await; + let r = wrong + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + // The parent named in the request's params, even with its real token: + // only the connection's own initialize binds a caller. + let mut cli = Rpc::plain(&d.socket).await; + let spoofed = new_params( + &d, + json!({"harness": "fake", "mode": "bypass", "parentSessionId": parent, + "callerSession": {"sessionId": parent, "token": token}}), + ); + let r = cli.call("session/new", spoofed).await; + assert_eq!(reason(&r), REASON, "{r}"); + // Another session's token under this session's id. + let (other, other_token) = parent_in(&mut app, &d, "fake", "normal").await; + assert_ne!(other, parent); + let mut swapped = Rpc::in_session(&d.socket, &parent, &other_token).await; + let r = swapped + .call("session/new", new_params(&d, json!({"harness": "fake", "mode": "bypass"}))) + .await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_preset_the_person_approved_starts_in_its_mode_from_the_cli() { + let d = Daemon::start("preset"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["mode"], "bypass", "{view}"); + assert_eq!(view["modeApproved"], true, "{view}"); + let s = cli.ok("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(current_mode(&s), "bypass", "{s}"); + // The approval covers the preset's own mode, never another one. + let r = + cli.call("session/new", new_params(&d, json!({"preset": "lane", "mode": "yolo"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_preset_changed_after_its_approval_loses_it() { + let d = Daemon::start("edited"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + // Any client may change the harness or model (they never set a mode + // that does not ask by themselves): the approval no longer covers it. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"harness": "mirror"}})).await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], false, "{view}"); + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + // The approval is bound to the preset's contents: the exact contents + // the person approved are approved again. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"harness": "fake"}})).await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], true, "{view}"); + // A model change drops it too, and so does the person's own edit. + app.ok("_acpmux/presets", json!({"name": "lane", "set": {"model": "m2"}})).await; + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], false, "{view}"); + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_only_the_person_approves_a_preset_mode_or_sets_one_that_does_not_ask() { + let d = Daemon::start("approve"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}}), + ) + .await; + for p in [ + json!({"name": "lane", "approveMode": true}), + json!({"name": "lane", "set": {"model": "m1"}, "approveMode": true}), + json!({"name": "other", "set": {"harness": "fake", "mode": "yolo"}}), + json!({"name": "lane", "set": {"mode": "yolo"}}), + ] { + let r = cli.call("_acpmux/presets", p.clone()).await; + assert_eq!(reason(&r), REASON, "{p}: {r}"); + } + let view = cli.ok("_acpmux/presets", json!({"name": "lane"})).await; + assert_eq!(view["modeApproved"], false, "{view}"); + assert_eq!(view["mode"], "bypass", "{view}"); + // A mode that asks, and withdrawing an approval, stay open. + cli.ok( + "_acpmux/presets", + json!({"name": "careful", "set": {"harness": "fake", "mode": "strict"}}), + ) + .await; + cli.ok("_acpmux/presets", json!({"name": "lane", "approveMode": false})).await; + // Unapproved, the preset's mode is refused from the CLI. + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); +} + +#[tokio::test] +async fn auto_start_a_refused_start_mode_names_the_host_and_the_preset_to_approve() { + let d = Daemon::start("where"); + let mut app = Rpc::person(&d.socket, KEY).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}}), + ) + .await; + let mut cli = Rpc::plain(&d.socket).await; + let host = host_label(); + // Through a preset: the message names it. + let r = cli.call("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(reason(&r), REASON, "{r}"); + let text = message(&r); + assert!(text.contains(&format!("cmux NEXT on {host}")), "{text}"); + assert!(text.contains("preset lane"), "{text}"); + assert_eq!(text.matches(". ").count(), 0, "one sentence: {text}"); + assert_eq!(r["error"]["data"]["host"], host.as_str(), "{r}"); + assert_eq!(r["error"]["data"]["preset"], "lane", "{r}"); + assert_eq!(r["error"]["data"]["mode"], "bypass", "{r}"); + // Without one: the message says to approve a preset once. + let r = + cli.call("session/new", new_params(&d, json!({"harness": "fake", "mode": "yolo"}))).await; + let text = message(&r); + assert!(text.contains(&format!("cmux NEXT on {host}")), "{text}"); + assert!(text.contains("preset"), "{text}"); + // A mode change on a running session names the host too. + let s = cli.ok("session/new", new_params(&d, json!({"harness": "fake"}))).await; + let r = cli + .call("session/set_mode", json!({"sessionId": s["sessionId"], "modeId": "bypass"})) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + assert!(message(&r).contains(&format!("cmux NEXT on {host}")), "{r}"); +} + +/// Session `id`'s mode now. +async fn mode(c: &mut Rpc, id: Value) -> String { + let info = c.ok("_acpmux/info", json!({"sessionId": id})).await; + info["modes"]["currentModeId"].as_str().unwrap_or_default().to_owned() +} + +/// The start mode of a new chat (`hub/start_mode.rs`: full access by +/// default) never widens a caller session: a session in a mode that asks +/// starts its chats in the harness's own mode; one in full access starts +/// them in full access, as the person's chats start. +#[tokio::test] +async fn auto_start_the_default_full_access_never_widens_a_caller_session_that_asks() { + let d = Daemon::start("default"); + let mut app = Rpc::person(&d.socket, KEY).await; + let own = app.ok("session/new", new_params(&d, json!({"harness": "wide"}))).await; + assert_eq!(mode(&mut app, own["sessionId"].clone()).await, "full-access", "{own}"); + let (asking, token) = parent_in(&mut app, &d, "wide", "strict").await; + let mut agent = Rpc::in_session(&d.socket, &asking, &token).await; + let child = agent.ok("session/new", new_params(&d, json!({"harness": "wide"}))).await; + assert_eq!(mode(&mut agent, child["sessionId"].clone()).await, "normal", "{child}"); + let (wide, token) = parent_in(&mut app, &d, "wide", "full-access").await; + let mut agent = Rpc::in_session(&d.socket, &wide, &token).await; + let child = agent.ok("session/new", new_params(&d, json!({"harness": "wide"}))).await; + assert_eq!(mode(&mut agent, child["sessionId"].clone()).await, "full-access", "{child}"); +} + +/// An adopt that finds a session already running never moves it to the +/// caller's mode: a caller in a no-ask mode that names another running +/// session's conversation with `ifLive: "open"` is refused, and that +/// session stays in the mode that asks (only the person widens a running +/// session, `session/set_mode`). +#[tokio::test] +async fn auto_start_an_adopt_never_moves_a_running_session_to_the_callers_mode() { + let d = Daemon::start("adopt"); + let mut app = Rpc::person(&d.socket, KEY).await; + let (parent, token) = parent_in(&mut app, &d, "fake", "bypass").await; + let (running, _) = parent_in(&mut app, &d, "other", "normal").await; + let info = app.ok("_acpmux/info", json!({"sessionId": running})).await; + let agent_session = info["agentSessionId"].as_str().expect("its conversation id").to_owned(); + let mut agent = Rpc::in_session(&d.socket, &parent, &token).await; + let r = agent + .call( + "session/new", + new_params( + &d, + json!({"harness": "fake", "mode": "bypass", + "adopt": {"agentSessionId": agent_session, "ifLive": "open"}}), + ), + ) + .await; + assert_eq!(reason(&r), REASON, "{r}"); + assert_eq!(mode(&mut app, json!(running)).await, "normal"); +} + +/// A preset's approval holds for the contents the session runs: a session +/// started from an approved preset whose preset changed afterwards comes back +/// from a respawn in the harness's own mode, not the approved one. +#[tokio::test] +async fn auto_start_an_approved_preset_edited_then_respawned_leaves_its_no_ask_mode() { + let d = Daemon::start("respawn"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + let s = cli.ok("session/new", new_params(&d, json!({"preset": "lane"}))).await; + let id = s["sessionId"].as_str().unwrap().to_owned(); + assert_eq!(mode(&mut cli, json!(id)).await, "bypass"); + // Any client may change the preset; the approval no longer covers it. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"model": "m2"}})).await; + cli.ok("_acpmux/attach", json!({"sessionId": id})).await; + let prompt = |text: &str| json!({"sessionId": id, "prompt": [{"type": "text", "text": text}]}); + cli.ok("session/prompt", prompt("answer-then-exit")).await; + tokio::time::sleep(Duration::from_millis(1500)).await; + // The next prompt respawns the harness from the changed preset. + cli.ok("session/prompt", prompt("hello")).await; + // The harness's own mode, not the approved one replayed (the fake's + // session/load reports none, so acpmux knows no mode). + assert_ne!(mode(&mut cli, json!(id)).await, "bypass"); +} + +/// An approved preset starts exactly what the person approved: a request +/// that adds its own model, effort, policy or env to it needs the person. +#[tokio::test] +async fn auto_start_an_approved_preset_with_request_overrides_needs_the_person() { + let d = Daemon::start("override"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "fake", "mode": "bypass"}, "approveMode": true}), + ) + .await; + for extra in [ + json!({"model": "m2"}), + json!({"effort": "high"}), + json!({"policy": "approve-all"}), + json!({"env": {"FOO": "1"}}), + ] { + let mut meta = json!({"preset": "lane"}); + for (k, v) in extra.as_object().unwrap() { + meta[k] = v.clone(); + } + let r = cli.call("session/new", new_params(&d, meta)).await; + assert_eq!(reason(&r), REASON, "{extra}: {r}"); + } + // Its own folder is fine. + let s = cli.ok("session/new", new_params(&d, json!({"preset": "lane"}))).await; + assert_eq!(current_mode(&s), "bypass", "{s}"); +} + +/// An approved preset's start counts only the contents its own harness was +/// spawned with. Another request on the session while that harness starts +/// (after any client edited the preset) spawns nothing and records nothing, +/// so the start, which spawned the approved contents, keeps its mode. +#[tokio::test] +async fn auto_start_a_request_during_the_start_never_rebinds_the_approval() { + let d = Daemon::start("race"); + let mut app = Rpc::person(&d.socket, KEY).await; + let mut cli = Rpc::plain(&d.socket).await; + app.ok( + "_acpmux/presets", + json!({"name": "lane", "set": {"harness": "gated", "mode": "bypass"}, "approveMode": true}), + ) + .await; + // The harness waits at its gate, so the start holds the session's spawn. + let mut starter = Rpc::plain(&d.socket).await; + let rid = starter + .send("session/new", new_params(&d, json!({"preset": "lane", "name": "racing"}))) + .await; + let id = loop { + let list = cli.ok("_acpmux/sessions", json!({})).await; + let found = list["sessions"] + .as_array() + .and_then(|a| a.iter().find(|s| s["name"] == "racing").map(|s| s["sessionId"].clone())); + if let Some(id) = found { + break id; + } + tokio::time::sleep(Duration::from_millis(50)).await; + }; + tokio::time::sleep(Duration::from_millis(500)).await; + // Meanwhile a client edits the preset and asks the starting session for something. + cli.ok("_acpmux/presets", json!({"name": "lane", "set": {"model": "m2"}})).await; + let mut other = Rpc::plain(&d.socket).await; + other + .send( + "session/set_config_option", + json!({"sessionId": id, "configId": "effort", "value": "high"}), + ) + .await; + tokio::time::sleep(Duration::from_millis(500)).await; + std::fs::write(d.dir.join("gate"), "").unwrap(); + let s = starter.reply(rid).await; + assert!(s.get("error").is_none(), "{s}"); + assert_eq!(current_mode(&s["result"]), "bypass", "{s}"); +} diff --git a/cmux-tui/crates/acpmux/tests/router_free_tier.rs b/cmux-tui/crates/acpmux/tests/router_free_tier.rs index 371b15ab03f4..e9142402c47c 100644 --- a/cmux-tui/crates/acpmux/tests/router_free_tier.rs +++ b/cmux-tui/crates/acpmux/tests/router_free_tier.rs @@ -5,6 +5,9 @@ //! stand-in cmux API on loopback (a debug-build-only origin). #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use base64::Engine as _; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; @@ -12,7 +15,7 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -119,11 +122,10 @@ impl Drop for Relay { /// `acpmux router serve` in `dir`, ready once its admin socket answers. fn start_relay(dir: &Path, test_origin: &str) -> Relay { - let child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let child = daemon_env::acpmux(dir) .args(["router", "serve"]) .env("ACPMUX_HOME", dir) .env("CMUX_ROUTER_FREE_TEST_ORIGIN", test_origin) - .env_remove("CMUX_ROUTER_OWNER_FIFO") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) diff --git a/cmux-tui/crates/acpmux/tests/router_install_secret.rs b/cmux-tui/crates/acpmux/tests/router_install_secret.rs new file mode 100644 index 000000000000..028703c41fd9 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/router_install_secret.rs @@ -0,0 +1,161 @@ +//! The local router with no default keychain (cx-f4thx): it runs on a random +//! in-memory install secret, says so in `status` (`install_secret: +//! ephemeral`), and the keys it mints work for that run only. Driven through +//! `acpmux router serve`, its admin socket and its loopback data plane. On +//! Linux the router always runs this way; on macOS the debug-only +//! `CMUX_CODEROUTER_TEST_NO_KEYCHAIN` takes the same path as a Mac without a +//! login keychain. +#![cfg(unix)] + +#[path = "support/daemon_env.rs"] +mod daemon_env; + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::process::{Child, Stdio}; +use std::time::{Duration, Instant}; + +fn scratch() -> PathBuf { + use std::os::unix::fs::DirBuilderExt; + // Short: the socket path must stay under the macOS limit. A new, owner-only + // folder: an existing path (a stale run, or another user's link) fails. + let dir = std::env::temp_dir().join(format!("amx-rsec-{}", std::process::id())); + std::fs::DirBuilder::new().mode(0o700).create(&dir).unwrap(); + dir +} + +/// Ends the router if the test fails before `stop`. +struct Router(Option); +impl Drop for Router { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +/// `acpmux router serve` on `home` (no owner FIFO: it runs until `shutdown`). +fn start_router(home: &Path, stderr: &Path) -> Router { + let _ = std::fs::remove_file(home.join("router").join("router.sock")); + let child = daemon_env::acpmux(home) + .args(["router", "serve"]) + .env("ACPMUX_HOME", home) + .env("CMUX_CODEROUTER_TEST_NO_KEYCHAIN", "1") + .env("RUST_LOG", "warn") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(std::fs::OpenOptions::new().create(true).append(true).open(stderr).unwrap()) + .spawn() + .unwrap(); + let router = Router(Some(child)); + let deadline = Instant::now() + Duration::from_secs(20); + while UnixStream::connect(home.join("router").join("router.sock")).is_err() { + assert!(Instant::now() < deadline, "the router never opened its admin socket"); + std::thread::sleep(Duration::from_millis(50)); + } + router +} + +/// One admin request; the reply line as JSON. +fn ask(home: &Path, request: Value) -> Value { + let mut stream = UnixStream::connect(home.join("router").join("router.sock")).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + stream.write_all(format!("{request}\n").as_bytes()).unwrap(); + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line).unwrap(); + serde_json::from_str(&line).unwrap_or_else(|e| panic!("{request}: no reply ({e}): {line:?}")) +} + +/// The HTTP status of one chat completion through the data plane with `key`. +fn relay_status(port: u64, key: &str) -> u16 { + let mut stream = std::net::TcpStream::connect(("127.0.0.1", port as u16)).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let body = r#"{"model":"m","messages":[]}"#; + let request = format!( + "POST /v1/chat/completions HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {key}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(request.as_bytes()).unwrap(); + let mut reply = String::new(); + let _ = stream.read_to_string(&mut reply); + reply.split_whitespace().nth(1).and_then(|code| code.parse().ok()).unwrap_or(0) +} + +fn stop(home: &Path, mut router: Router) { + assert_eq!(ask(home, json!({"op": "shutdown"}))["stopping"], json!(true)); + let mut child = router.0.take().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while child.try_wait().unwrap().is_none() { + assert!(Instant::now() < deadline, "the router did not stop"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// Every file under `dir` (the router's home), for a secret scan. +fn files(dir: &Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + let kind = entry.file_type().unwrap(); + if kind.is_dir() { + files(&path, out); + } else if kind.is_file() { + out.push(path); + } + } +} + +#[test] +fn with_no_keychain_keys_work_for_one_router_run_and_the_status_says_ephemeral() { + let home = scratch(); + let log = home.join("router-stderr.log"); + let router = start_router(&home, &log); + let status = ask(&home, json!({"op": "status"})); + assert_eq!(status["install_secret"], "ephemeral", "{status}"); + let port = status["port"].as_u64().unwrap(); + let minted = ask( + &home, + json!({"op": "mint_key", "scope": {"harness": "fake", "session": "s1", "surfaces": [], "families": ["open_ai_chat"], "expires_at": 0}}), + ); + let key = + minted["key"].as_str().unwrap_or_else(|| panic!("no key minted: {minted}")).to_owned(); + // Within the run the key is accepted: the relay answers that no hosted + // upstream is signed in (503), never that the key is unknown (401). + assert_eq!(relay_status(port, &key), 503); + assert_eq!(relay_status(port, &format!("{key}x")), 401); + stop(&home, router); + + // Keys never outlive a router run (with any store): the old key is + // unknown to the next run. + let router = start_router(&home, &log); + let status = ask(&home, json!({"op": "status"})); + assert_eq!(status["install_secret"], "ephemeral", "{status}"); + let port = status["port"].as_u64().unwrap(); + assert_eq!(relay_status(port, &key), 401, "a key outlived its router run"); + stop(&home, router); + + // The fallback is visible: one warning line on stderr per run (macOS; on + // Linux the random secret is the only kind, with no fallback to report). + let warnings = std::fs::read_to_string(&log) + .unwrap() + .lines() + .filter(|l| l.contains("this router run uses a random in-memory secret")) + .count(); + assert_eq!(warnings, if cfg!(target_os = "macos") { 2 } else { 0 }, "router stderr: {log:?}"); + + // The secret part of the key reached no file under the home and no log. + let secret = key.rsplit('_').next().unwrap().to_owned(); + let mut all = Vec::new(); + files(&home, &mut all); + for path in all { + let bytes = std::fs::read(&path).unwrap(); + assert!( + !String::from_utf8_lossy(&bytes).contains(&secret), + "the key's secret is in {}", + path.display() + ); + } + let _ = std::fs::remove_dir_all(&home); +} diff --git a/cmux-tui/crates/acpmux/tests/router_lifetime.rs b/cmux-tui/crates/acpmux/tests/router_lifetime.rs index 15903133500f..ba992d60c8b7 100644 --- a/cmux-tui/crates/acpmux/tests/router_lifetime.rs +++ b/cmux-tui/crates/acpmux/tests/router_lifetime.rs @@ -6,11 +6,14 @@ //! still go through. Real binaries, real sockets, a temporary home. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; @@ -31,15 +34,12 @@ fn scratch(tag: &str) -> PathBuf { /// A daemon in `dir` with the local router on; returns after its readiness line. fn start_daemon(dir: &Path) -> Child { - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(dir, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", dir) .env("ACPMUX_SOCKET", dir.join("s.sock")) .env("ACPMUX_LOCAL_ROUTER", "1") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("ACPMUX_AGENT_HOSTS") - .env_remove("XPC_SERVICE_NAME") - .env_remove("CLAUDECODE") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -59,7 +59,7 @@ fn start_daemon(dir: &Path) -> Child { /// `acpmux daemon shutdown` as the CLI runs it, against the daemon in `dir`. fn cli_shutdown(dir: &Path, extra: &[&str]) { - let out = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let out = daemon_env::acpmux(dir) .args(["daemon", "shutdown"]) .args(extra) .env("ACPMUX_HOME", dir) diff --git a/cmux-tui/crates/acpmux/tests/session_pool.rs b/cmux-tui/crates/acpmux/tests/session_pool.rs index a9c293711e35..646d603ba09b 100644 --- a/cmux-tui/crates/acpmux/tests/session_pool.rs +++ b/cmux-tui/crates/acpmux/tests/session_pool.rs @@ -4,10 +4,13 @@ //! counted and leave nothing behind, and they end with the daemon. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; @@ -72,17 +75,14 @@ impl Daemon { fn start(&mut self) { assert!(self.child.is_none()); - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(&self.home, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "warn"]) .env("ACPMUX_HOME", &self.home) .env("ACPMUX_SOCKET", &self.socket) // No catalog fetch: a test never reaches the network, and the fetch's // warning (a catalog server 404) came on stdout before the ready line. .env("ACPMUX_CATALOG_FETCH", "0") - .env_remove("ACPMUX_AGENT_HOSTS") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") - .env_remove("ACPMUX_IDLE_CHILD_SECS") // Each marker file here stalls one agent host's hello (debug // builds' seam): `stall_hellos`. .env("ACPMUX_TEST_HOST_HELLO_STALL_DIR", self.stall_dir()) @@ -624,7 +624,7 @@ async fn the_shutdown_command_ends_every_agent_host_and_reports_the_counts() { .flatten() .collect(); - let out = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let out = daemon_env::acpmux(&home) .args(["--json", "daemon", "shutdown"]) .env("ACPMUX_HOME", &home) .env("ACPMUX_SOCKET", &daemon.socket) diff --git a/cmux-tui/crates/acpmux/tests/ssh_peer_injection.rs b/cmux-tui/crates/acpmux/tests/ssh_peer_injection.rs index 086d21aaa578..b2def71e4a1f 100644 --- a/cmux-tui/crates/acpmux/tests/ssh_peer_injection.rs +++ b/cmux-tui/crates/acpmux/tests/ssh_peer_injection.rs @@ -6,10 +6,13 @@ //! only). #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::{Duration, Instant}; struct Daemon { @@ -34,7 +37,7 @@ fn start(tag: &str, peers: Value) -> Daemon { let _ = std::fs::remove_dir_all(&home); std::fs::create_dir_all(&home).unwrap(); std::fs::write(home.join("config.json"), json!({"peers": peers}).to_string()).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(&home) .args([ "daemon", "run", @@ -49,7 +52,6 @@ fn start(tag: &str, peers: Value) -> Daemon { .env("ACPMUX_HOME", &home) .env("ACPMUX_SOCKET", home.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/cmux-tui/crates/acpmux/tests/support/daemon_env.rs b/cmux-tui/crates/acpmux/tests/support/daemon_env.rs new file mode 100644 index 000000000000..118a9993d8a2 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/support/daemon_env.rs @@ -0,0 +1,180 @@ +//! The one way a test starts the real `acpmux` binary (cx-s8wux). +//! +//! A test process has the host's whole environment. On a fleet host its PATH +//! holds real agent CLIs (codex, claude, gemini, opencode) with real +//! accounts: a daemon started with that environment discovers them and runs +//! live model probes, prewarms, sign-in checks and CLI auto-updates +//! (cx-w014j: a host codex ran a test's fake login shell). Every `acpmux` +//! process a test starts gets this environment instead: +//! +//! - nothing from the host (`env_clear`); +//! - `PATH`: a private directory with links to the named host tools +//! (`python3` always, for the stand-in agents and fake shells), then the +//! system directories. No real agent CLI can be found on it; +//! - `HOME`: `/home` (not made here), under the scratch directory +//! the test passes. Never the scratch directory itself: tests use that as +//! the daemon's home or a chat's folder, and the daemon refuses to start an +//! agent in the user's home folder unless a person picks it; +//! - `TMPDIR`: the test process's temp directory (macOS: the per-user one); +//! - `ACPMUX_AGENT_LAUNCH=deny`: the daemon starts no agent at all +//! (cx-9ettq). A test that drives a stand-in agent (fake_agent.py, +//! fake_claude.py) opts out with [`Agents::StandIn`]; it keeps the bare +//! PATH, so only its stand-in can start. +//! +//! A test sets anything else it needs with `.env(...)` after this. +//! `tests/hermetic_daemon_guard.rs` refuses a test file that names the +//! binary (`CARGO_BIN_EXE_acpmux`) anywhere but here. +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The binary under test. Only for values (a path a daemon passes on, an +/// agent host launcher); start a process with [`acpmux`] or [`command`]. +pub fn bin() -> &'static Path { + Path::new(env!("CARGO_BIN_EXE_acpmux")) +} + +/// Whether the daemon may start agent processes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Agents { + /// `ACPMUX_AGENT_LAUNCH=deny`: no agent, probe, prewarm or sign-in check. + Deny, + /// Launches allowed, for a test's own stand-in agent. PATH stays bare. + StandIn, +} + +/// `acpmux` with the hermetic environment, agent launches denied. +pub fn acpmux(scratch: &Path) -> Command { + command(scratch, Agents::Deny, &[]) +} + +/// `acpmux` with the hermetic environment; `tools` are host tools (by name, +/// found on the test's PATH) to put on the daemon's PATH besides `python3`. +pub fn command(scratch: &Path, agents: Agents, tools: &[&str]) -> Command { + let mut cmd = Command::new(bin()); + apply(&mut cmd, scratch, agents, tools); + cmd +} + +/// The same environment for an async command. +pub fn tokio_command(scratch: &Path, agents: Agents) -> tokio::process::Command { + let mut cmd = tokio::process::Command::new(bin()); + cmd.env_clear().envs(vars(scratch, agents, &[])); + cmd +} + +/// Clears `cmd`'s environment and sets the hermetic one. +pub fn apply<'c>( + cmd: &'c mut Command, + scratch: &Path, + agents: Agents, + tools: &[&str], +) -> &'c mut Command { + cmd.env_clear().envs(vars(scratch, agents, tools)) +} + +/// The hermetic environment as pairs (for an agent host's `SpawnSpec.env`). +pub fn vars(scratch: &Path, agents: Agents, tools: &[&str]) -> Vec<(String, String)> { + let mut env = platform_vars(&home(scratch), tools); + if agents == Agents::Deny { + env.push(("ACPMUX_AGENT_LAUNCH".into(), "deny".into())); + } + env +} + +/// The `HOME` the hermetic environment gives: `/home`. +pub fn home(scratch: &Path) -> PathBuf { + scratch.join("home") +} + +#[cfg(unix)] +fn platform_vars(home: &Path, tools: &[&str]) -> Vec<(String, String)> { + vec![ + ("PATH".into(), bare_path(tools)), + ("HOME".into(), home.display().to_string()), + ("TMPDIR".into(), std::env::temp_dir().display().to_string()), + ] +} + +/// Windows: only what a process needs to start (the system directories and +/// the temp directory), the scratch profile as home. No tool links: Windows +/// tests start no stand-in agent. +#[cfg(windows)] +fn platform_vars(home: &Path, tools: &[&str]) -> Vec<(String, String)> { + assert!(tools.is_empty(), "no host tools on Windows"); + let mut env = Vec::new(); + for key in ["SystemRoot", "SystemDrive", "windir", "TEMP", "TMP"] { + if let Ok(value) = std::env::var(key) { + env.push((key.to_owned(), value)); + } + } + let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".into()); + env.push(("PATH".into(), format!(r"{root}\System32;{root}"))); + env.push(("USERPROFILE".into(), home.display().to_string())); + env.push(("HOME".into(), home.display().to_string())); + env +} + +/// `:/usr/bin:/bin:/usr/sbin:/sbin`, where `` (one per process and +/// tool set) links `python3` and `tools` from the test's PATH. +#[cfg(unix)] +pub fn bare_path(tools: &[&str]) -> String { + format!("{}:/usr/bin:/bin:/usr/sbin:/sbin", tool_dir(tools).display()) +} + +/// The directory of tool links that [`bare_path`] puts first. A link left by +/// an earlier run (same pid) that points elsewhere is replaced; tests of one +/// process make the links one at a time. +#[cfg(unix)] +pub fn tool_dir(tools: &[&str]) -> PathBuf { + static LINKS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _held = LINKS.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let mut names = vec!["python3"]; + names.extend(tools.iter().copied().filter(|t| *t != "python3")); + let dir = + std::env::temp_dir().join(format!("amx-bin-{}-{}", std::process::id(), names.join("-"))); + std::fs::create_dir_all(&dir).unwrap(); + for name in &names { + let link = dir.join(name); + let target = host_tool(name); + if std::fs::read_link(&link).is_ok_and(|t| t == target) { + continue; + } + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(&target, &link) + .unwrap_or_else(|e| panic!("link {} to {}: {e}", target.display(), link.display())); + } + dir +} + +/// `name` on the test process's own PATH (absolute entries only). For +/// `python3`, the interpreter itself, not a version manager's shim: a shim +/// picks its version from files under HOME, and the daemon's HOME is a +/// scratch folder. +#[cfg(unix)] +fn host_tool(name: &str) -> PathBuf { + let found = std::env::var_os("PATH") + .iter() + .flat_map(std::env::split_paths) + .filter(|d| d.is_absolute()) + .map(|d| d.join(name)) + .find(|p| p.is_file()) + .unwrap_or_else(|| panic!("{name} on the test's PATH")); + if name != "python3" { + return found; + } + static PYTHON: std::sync::OnceLock = std::sync::OnceLock::new(); + PYTHON + .get_or_init(|| { + let out = Command::new(&found) + .args(["-c", "import sys; print(sys.executable)"]) + .output() + .unwrap_or_else(|e| panic!("run {}: {e}", found.display())); + let exe = String::from_utf8(out.stdout).unwrap(); + let exe = PathBuf::from(exe.trim()); + assert!(out.status.success() && exe.is_absolute(), "python3 names no interpreter"); + exe + }) + .clone() +} diff --git a/cmux-tui/crates/acpmux/tests/transfer_cli.rs b/cmux-tui/crates/acpmux/tests/transfer_cli.rs index d6ffb5984a5a..3f64af4dd5c6 100644 --- a/cmux-tui/crates/acpmux/tests/transfer_cli.rs +++ b/cmux-tui/crates/acpmux/tests/transfer_cli.rs @@ -2,6 +2,9 @@ //! with a stable `detail` and its data, not only acpmux's text. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -60,16 +63,13 @@ impl Daemon { let config = json!({"harnesses": {"fake": {"argv": ["python3", fake]}}, "defaultHarness": "fake", "permissionPolicy": "approve-all"}); std::fs::write(home.join("config.json"), config.to_string()).expect("config"); let socket = home.join("s.sock"); - let child = std::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let child = daemon_env::command(&home, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--log", "warn"]) .env("ACPMUX_HOME", &home) .env("ACPMUX_SOCKET", &socket) // No agent hosts: a host outlives its daemon, and this test kills only the daemon. .env("ACPMUX_AGENT_HOSTS", "0") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") - .env_remove("ACPMUX_LOCAL_ROUTER") - .env_remove("ACPMUX_IDLE_CHILD_SECS") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -103,7 +103,7 @@ async fn a_chat_whose_folder_is_missing_on_the_target_is_refused_with_a_reason_t // The chat's folder is not on the target (here: gone from this machine). std::fs::remove_dir_all(&folder).expect("remove folder"); - let out = tokio::process::Command::new(env!("CARGO_BIN_EXE_acpmux")) + let out = daemon_env::tokio_command(&a.home, daemon_env::Agents::Deny) .args(["--json", "transfer", &id, "--to"]) .arg(&b.socket) .env("ACPMUX_HOME", &a.home) diff --git a/cmux-tui/crates/acpmux/tests/web_mode_drift.rs b/cmux-tui/crates/acpmux/tests/web_mode_drift.rs index c96f25b54ef5..94c56e116aa6 100644 --- a/cmux-tui/crates/acpmux/tests/web_mode_drift.rs +++ b/cmux-tui/crates/acpmux/tests/web_mode_drift.rs @@ -2,6 +2,9 @@ //! is ignored with a warning, and Web control of a session ends when its //! mode leaves the asking table (default deny on drift). +#[path = "support/daemon_env.rs"] +mod daemon_env; + use acpmux::config::{Config, StoreMode}; use acpmux::hub::Hub; use acpmux::rpc::Message; @@ -9,7 +12,7 @@ use acpmux::server::{Origin, serve_connection_with}; use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; @@ -110,7 +113,7 @@ async fn a_config_entry_for_a_non_asking_mode_is_ignored() { fn the_daemon_logs_the_merged_table_once_and_warns_about_the_ignored_entry() { let d = dir("log"); std::fs::write(d.join("config.json"), config(&d).to_string()).unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(&d) .args([ "daemon", "run", @@ -125,7 +128,6 @@ fn the_daemon_logs_the_merged_table_once_and_warns_about_the_ignored_entry() { .env("ACPMUX_HOME", &d) .env("ACPMUX_SOCKET", d.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .env("NO_COLOR", "1") .stdin(Stdio::null()) .stdout(Stdio::piped()) diff --git a/cmux-tui/crates/acpmux/tests/web_token_exposure.rs b/cmux-tui/crates/acpmux/tests/web_token_exposure.rs index 7ebc100d9863..060c8d2c3393 100644 --- a/cmux-tui/crates/acpmux/tests/web_token_exposure.rs +++ b/cmux-tui/crates/acpmux/tests/web_token_exposure.rs @@ -2,13 +2,16 @@ //! saved token from a build that sent it there is rotated once. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use acpmux::config::{Config, PeerConfig, StoreMode, WebSocketConfig}; use acpmux::hub::Hub; use acpmux::rpc::{Message, method}; use acpmux::server::{Origin, serve_connection_with}; use serde_json::{Value, json}; use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::time::Duration; use tokio::sync::mpsc; @@ -114,7 +117,7 @@ impl Drop for Daemon { } fn start(home: &std::path::Path, extra: &[&str]) -> (Daemon, Value) { - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(home) .args([ "daemon", "run", @@ -130,7 +133,6 @@ fn start(home: &std::path::Path, extra: &[&str]) -> (Daemon, Value) { .env("ACPMUX_HOME", home) .env("ACPMUX_SOCKET", home.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/cmux-tui/crates/acpmux/tests/web_token_rotation.rs b/cmux-tui/crates/acpmux/tests/web_token_rotation.rs index 55572793713c..759cda62d686 100644 --- a/cmux-tui/crates/acpmux/tests/web_token_rotation.rs +++ b/cmux-tui/crates/acpmux/tests/web_token_rotation.rs @@ -6,11 +6,14 @@ //! authenticated with it as its only credential (Web, Peer) is closed. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use futures::StreamExt; use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Read, Write}; use std::os::unix::fs::PermissionsExt; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::time::Duration; const OLD: &str = "old-dashboard-token-0123456789abcdef"; @@ -44,14 +47,13 @@ fn home(tag: &str) -> std::path::PathBuf { } fn start(home: &std::path::Path, extra: &[&str]) -> (Daemon, Value) { - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::acpmux(home) .args(["daemon", "run", "--memory", "--listen", "127.0.0.1:0", "--ready-fd", "1"]) .args(["--log", "error"]) .args(extra) .env("ACPMUX_HOME", home) .env("ACPMUX_SOCKET", home.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) @@ -103,12 +105,11 @@ fn page_status(port: u16, token: &str) -> u16 { /// `acpmux [--json] web …` against this home's daemon. fn cli(home: &std::path::Path, args: &[&str]) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_acpmux")) + daemon_env::acpmux(home) .args(args) .env("ACPMUX_HOME", home) .env("ACPMUX_SOCKET", home.join("s.sock")) .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .output() .unwrap() diff --git a/cmux-tui/crates/acpmux/tests/web_token_rotation_connections.rs b/cmux-tui/crates/acpmux/tests/web_token_rotation_connections.rs index c3c157914b14..3293cb44ab67 100644 --- a/cmux-tui/crates/acpmux/tests/web_token_rotation_connections.rs +++ b/cmux-tui/crates/acpmux/tests/web_token_rotation_connections.rs @@ -5,11 +5,14 @@ //! this launch's LocalApp token, stays open and keeps its attachment. #![cfg(unix)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; use std::io::{BufRead, BufReader, Write}; use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; +use std::process::{Child, Stdio}; use std::time::Duration; use tokio_tungstenite::tungstenite::{Message, client::IntoClientRequest}; @@ -35,14 +38,13 @@ impl Daemon { .to_string(), ) .unwrap(); - let mut child = Command::new(env!("CARGO_BIN_EXE_acpmux")) + let mut child = daemon_env::command(&home, daemon_env::Agents::StandIn, &[]) .args(["daemon", "run", "--listen", "127.0.0.1:0", "--ready-fd", "1", "--log", "error"]) .env("ACPMUX_HOME", &home) .env("ACPMUX_SOCKET", home.join("s.sock")) .env("ACPMUX_AGENT_HOSTS", "0") .env("ACPMUX_CATALOG_FETCH", "0") .env("ACPMUX_LOGIN_ENV", "0") - .env_remove("XPC_SERVICE_NAME") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) diff --git a/cmux-tui/crates/acpmux/tests/windows_daemon.rs b/cmux-tui/crates/acpmux/tests/windows_daemon.rs index 9225b85a4324..a8a770f72279 100644 --- a/cmux-tui/crates/acpmux/tests/windows_daemon.rs +++ b/cmux-tui/crates/acpmux/tests/windows_daemon.rs @@ -5,6 +5,9 @@ //! and a peer of another user is refused by the daemon and by the CLI. #![cfg(windows)] +#[path = "support/daemon_env.rs"] +mod daemon_env; + use serde_json::Value; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; @@ -19,6 +22,7 @@ fn scratch(tag: &str) -> PathBuf { fn acpmux(exe: &Path, home: &Path) -> Command { let mut cmd = Command::new(exe); + daemon_env::apply(&mut cmd, home, daemon_env::Agents::Deny, &[]); cmd.env("ACPMUX_HOME", home) .env("ACPMUX_CATALOG_FETCH", "0") .env("ACPMUX_REGISTRY_FETCH", "0") @@ -38,7 +42,7 @@ fn spawns() -> std::sync::MutexGuard<'static, ()> { } fn exe() -> PathBuf { - PathBuf::from(env!("CARGO_BIN_EXE_acpmux")) + daemon_env::bin().to_path_buf() } /// A daemon this test started; killed if the test ends early. @@ -550,3 +554,188 @@ fn two_starts_at_once_share_one_daemon() { assert!(!pids[0].is_empty() && pids[0] == pids[1], "two daemons answered: {pids:?}"); let _ = std::fs::remove_dir_all(&home); } + +/// How many ping.exe processes have `marker` in their command line. +fn pings_with(marker: &str) -> usize { + let script = format!( + "@(Get-CimInstance Win32_Process -Filter \"Name='PING.EXE'\" | Where-Object {{ $_.CommandLine -like '*{marker}*' }}).Count" + ); + let out = Command::new("powershell") + .args(["-NoProfile", "-NonInteractive", "-Command", &script]) + .output() + .expect("run powershell"); + String::from_utf8_lossy(&out.stdout) + .trim() + .parse() + .unwrap_or_else(|_| panic!("count: {}", text(&out))) +} + +/// Until `pings_with(marker)` is `want` (at least `want` with `at_least`), +/// or `limit`. +fn wait_pings(marker: &str, want: usize, limit: Duration) -> usize { + wait_pings_by(marker, |n| n == want, limit) +} + +fn wait_pings_by(marker: &str, done: impl Fn(usize) -> bool, limit: Duration) -> usize { + let deadline = Instant::now() + limit; + loop { + let n = pings_with(marker); + if done(n) || Instant::now() >= deadline { + return n; + } + std::thread::sleep(Duration::from_millis(300)); + } +} + +/// A loopback address only this test pings, so its processes are found by +/// command line. +fn ping_marker(salt: u32) -> String { + let n = std::process::id().wrapping_add(salt); + format!("127.{}.{}.{}", 1 + n % 250, 1 + (n / 250) % 250, 1 + salt % 250) +} + +/// A config with one harness `grand`: cmd.exe running `script`. +fn grand_config(home: &Path, script: &str) { + std::fs::create_dir_all(home).unwrap(); + let config = serde_json::json!({ + "harnesses": {"grand": {"argv": ["cmd.exe", "/d", "/c", script]}}, + "defaultHarness": "grand", + "permissionPolicy": "approve-all", + }); + std::fs::write(home.join("config.json"), config.to_string()).unwrap(); +} + +/// An agent that starts a background process and exits at once: the +/// process it left (in the agent's job) is ended, as killpg ends the +/// agent's group on Unix. +#[test] +fn an_agent_leaves_no_process_behind_when_it_exits() { + let _spawns = spawns(); + let exe = exe(); + let home = scratch("jobexit"); + let marker = ping_marker(1); + grand_config(&home, &format!("start /b ping -n 300 {marker}")); + let out = acpmux(&exe, &home).args(["daemon", "start"]).output().expect("daemon start"); + assert!(out.status.success(), "acpmux daemon start failed: {}", text(&out)); + let new = acpmux(&exe, &home) + .args(["new", "--detach", "--cwd"]) + .arg(&home) + .output() + .expect("run acpmux new"); + let left = wait_pings(&marker, 0, Duration::from_secs(20)); + let out = acpmux(&exe, &home).arg("shutdown").output().expect("run acpmux shutdown"); + assert!(out.status.success(), "acpmux shutdown failed: {}", text(&out)); + assert_eq!(left, 0, "the agent's background ping outlived it (acpmux new: {})", text(&new)); + let _ = std::fs::remove_dir_all(&home); +} + +/// A running agent and what it started end with the daemon even when the +/// daemon is killed outright: the agent's job is kill-on-close. +#[test] +fn a_killed_daemon_takes_its_agents_with_it() { + let _spawns = spawns(); + let exe = exe(); + let home = scratch("jobkill"); + let marker = ping_marker(2); + grand_config(&home, &format!("start /b ping -n 300 {marker} & ping -n 300 {marker}")); + let log = std::env::temp_dir().join(format!("amxw-jobkill-{}.log", std::process::id())); + let mut daemon = Daemon::start(&exe, &home, log); + wait_ready(&exe, &home, &mut daemon); + // The agent never answers `initialize`: `new` waits; the agent runs. + let mut new = acpmux(&exe, &home) + .args(["new", "--detach", "--cwd"]) + .arg(&home) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn acpmux new"); + // The session's agent and the harness's model probe each run both. + let running = wait_pings_by(&marker, |n| n >= 2, Duration::from_secs(30)); + daemon.child.kill().unwrap(); + let _ = daemon.child.wait(); + let left = wait_pings(&marker, 0, Duration::from_secs(20)); + let _ = new.kill(); + let _ = new.wait(); + assert!(running >= 2, "the agent and its background ping did not start:\n{}", daemon.log()); + assert_eq!(left, 0, "processes of a killed daemon's agent survived:\n{}", daemon.log()); + let _ = std::fs::remove_dir_all(&home); +} + +/// Stopping a running agent asks first: its process group gets Ctrl+Break +/// (SIGTERM on Unix), and only after the grace does the job end it. The +/// agent is this test binary's `ctrl_break_agent`, which records the event. +#[test] +fn a_stopped_agent_gets_ctrl_break_before_its_job_ends() { + let _spawns = spawns(); + let exe = exe(); + let home = scratch("jobbreak"); + std::fs::create_dir_all(&home).unwrap(); + // The agent runs a copy: the build folder may be locked by other runs. + let agent = home.join("agent.exe"); + std::fs::copy(std::env::current_exe().unwrap(), &agent).unwrap(); + let config = serde_json::json!({ + "harnesses": {"breaker": {"argv": [agent, "--ignored", "--exact", "ctrl_break_agent", "--test-threads", "1"]}}, + "defaultHarness": "breaker", + "permissionPolicy": "approve-all", + }); + std::fs::write(home.join("config.json"), config.to_string()).unwrap(); + let log = std::env::temp_dir().join(format!("amxw-jobbreak-{}.log", std::process::id())); + let mut daemon = Daemon::start(&exe, &home, log); + wait_ready(&exe, &home, &mut daemon); + let mut new = acpmux(&exe, &home) + .args(["new", "--detach", "--cwd"]) + .arg(&home) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn acpmux new"); + let deadline = Instant::now() + Duration::from_secs(30); + while !home.join("agent-ready").exists() { + assert!(Instant::now() < deadline, "the agent did not start:\n{}", daemon.log()); + std::thread::sleep(Duration::from_millis(100)); + } + let out = acpmux(&exe, &home).arg("shutdown").output().expect("run acpmux shutdown"); + let _ = new.kill(); + let _ = new.wait(); + let _ = daemon.child.wait(); + assert!(out.status.success(), "acpmux shutdown failed: {}", text(&out)); + assert!( + home.join("agent-ctrl-break").exists(), + "the agent got no Ctrl+Break before its job ended:\n{}", + daemon.log() + ); + let _ = std::fs::remove_dir_all(&home); +} + +/// The agent of `a_stopped_agent_gets_ctrl_break_before_its_job_ends`: +/// records a Ctrl+Break in its session folder, handles it, and keeps +/// running until its job ends. +#[test] +#[ignore = "run as an agent by a_stopped_agent_gets_ctrl_break_before_its_job_ends"] +fn ctrl_break_agent() { + #[link(name = "kernel32")] + unsafe extern "system" { + fn SetConsoleCtrlHandler( + handler: Option i32>, + add: i32, + ) -> i32; + } + static HOME: std::sync::OnceLock = std::sync::OnceLock::new(); + unsafe extern "system" fn on_ctrl(event: u32) -> i32 { + // CTRL_BREAK_EVENT + if event == 1 { + if let Some(home) = HOME.get() { + let _ = std::fs::write(home.join("agent-ctrl-break"), b"1"); + } + return 1; + } + 0 + } + // The session folder (`acpmux new --cwd`) is the agent's current folder. + let home = std::env::current_dir().unwrap(); + HOME.set(home.clone()).unwrap(); + // SAFETY: a static handler for this process. + assert_ne!(unsafe { SetConsoleCtrlHandler(Some(on_ctrl), 1) }, 0); + std::fs::write(home.join("agent-ready"), b"1").unwrap(); + std::thread::sleep(Duration::from_secs(300)); +} diff --git a/cmux-tui/crates/cmux-agent-pane-policy/policy.json b/cmux-tui/crates/cmux-agent-pane-policy/policy.json index fb1285c07ffa..5b2995cd5d61 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/policy.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/policy.json @@ -4,7 +4,7 @@ "initialize": "initialize", "requests": [ "session/new", "session/prompt", "session/set_model", "session/set_mode", "session/set_config_option", - "_acpmux/watch", "_acpmux/events", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/warm", + "_acpmux/watch", "_acpmux/sessions", "_acpmux/events", "_acpmux/attach", "_acpmux/detach", "_acpmux/view", "_acpmux/warm", "_acpmux/kill", "_acpmux/prewarm", "_acpmux/harnesses", "_acpmux/models", "_acpmux/permission_respond", "_acpmux/status", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/ask", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/handoff_prepare", "_acpmux/handoff_get", "_acpmux/handoff_draft", "_acpmux/handoff_start", @@ -12,7 +12,7 @@ "_acpmux/permission_groups", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke", "acp.session.fork", "acp.trust.get", "acp.trust.set", "_acpmux/harness_enable", "_acpmux/secret_submit", "_acpmux/archive", "_acpmux/markUnread", - "_acpmux/harness/health", "_acpmux/side", "_acpmux/offer_respond" + "_acpmux/harness/health", "_acpmux/side", "_acpmux/offer_respond", "_acpmux/subagent_stop" ], "notifications": ["session/cancel"], "maximum_frame_bytes": 33554432, @@ -22,7 +22,7 @@ "session/prompt", "session/set_mode", "session/set_config_option", "session/set_model", "session/cancel", "_acpmux/kill", "_acpmux/draft_get", "_acpmux/draft_set", "_acpmux/bookmark_get", "_acpmux/bookmark_set", "_acpmux/ask", "_acpmux/queue_remove", "_acpmux/queue_edit", "_acpmux/permission_respond", "_acpmux/permission_group_respond", "_acpmux/permission_chat_revoke", "_acpmux/secret_submit", "_acpmux/archive", "_acpmux/markUnread", "_acpmux/side", - "_acpmux/offer_respond" + "_acpmux/offer_respond", "_acpmux/subagent_stop" ], "optionally_session_scoped": ["acp.trust.get", "acp.trust.set"], "scoped_session_params": {"_acpmux/side": ["chat"]}, @@ -50,7 +50,8 @@ "session/prompt": {"params": ["sessionId", "prompt", "_meta"], "acpmux": ["promptId", "steer", "interrupt"]}, "session/set_model": {"params": ["sessionId", "modelId"], "acpmux": []}, "session/cancel": {"params": ["sessionId"], "acpmux": []}, - "_acpmux/watch": {"params": ["enabled"], "acpmux": []}, + "_acpmux/watch": {"params": ["enabled", "limit"], "acpmux": []}, + "_acpmux/sessions": {"params": ["limit", "cursor"], "acpmux": []}, "_acpmux/events": {"params": ["sessionId", "afterSeq", "beforeSeq", "limit", "maxBytes", "kinds"], "acpmux": []}, "_acpmux/attach": {"params": ["sessionId", "limit", "maxBytes", "kinds", "eventStream", "visible"], "acpmux": []}, "_acpmux/detach": {"params": ["sessionId"], "acpmux": []}, @@ -83,6 +84,7 @@ "_acpmux/harness/health": {"params": ["refresh", "rescan"], "acpmux": []}, "_acpmux/side": {"params": ["sessionId", "chat"], "acpmux": []}, "_acpmux/offer_respond": {"params": ["sessionId", "offerId", "outcome", "childSessionId"], "acpmux": []}, + "_acpmux/subagent_stop": {"params": ["sessionId", "subagentSessionId"], "acpmux": []}, "acp.session.fork": {"params": ["sessionId", "throughSeq"], "acpmux": []}, "acp.trust.get": {"params": ["cwd", "sessionId"], "acpmux": []}, "acp.trust.set": {"params": ["cwd", "level", "sessionId"], "acpmux": []}, diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json index 3ceea0ba9509..f0960cffdf0d 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/frames.json @@ -464,6 +464,15 @@ "send": "unchanged" } }, + { + "name": "request _acpmux/sessions passes", + "text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"_acpmux/sessions\",\"params\":{}}", + "first": false, + "token": "abc", + "expect": { + "send": "unchanged" + } + }, { "name": "request acp.session.fork passes", "text": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"acp.session.fork\",\"params\":{}}", @@ -766,24 +775,24 @@ } }, { - "name": "escaped '_acpmux/sessions' refused", - "text": "{\"jsonrpc\":\"2.0\",\"id\":511,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0073\\u0065\\u0073\\u0073\\u0069\\u006f\\u006e\\u0073\",\"params\":{}}", + "name": "escaped '_acpmux/web_token_rotate' refused", + "text": "{\"jsonrpc\":\"2.0\",\"id\":511,\"method\":\"\\u005f\\u0061\\u0063\\u0070\\u006d\\u0075\\u0078\\u002f\\u0077\\u0065\\u0062\\u005f\\u0074\\u006f\\u006b\\u0065\\u006e\\u005f\\u0072\\u006f\\u0074\\u0061\\u0074\\u0065\",\"params\":{}}", "first": false, "token": null, "expect": { "refuse": "transport.method_refused", - "method": "_acpmux/sessions", + "method": "_acpmux/web_token_rotate", "id": "511" } }, { - "name": "notification '_acpmux/sessions' refused", - "text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/sessions\",\"params\":{}}", + "name": "notification '_acpmux/web_token_rotate' refused", + "text": "{\"jsonrpc\":\"2.0\",\"method\":\"_acpmux/web_token_rotate\",\"params\":{}}", "first": false, "token": null, "expect": { "refuse": "transport.method_refused", - "method": "_acpmux/sessions", + "method": "_acpmux/web_token_rotate", "id": null } }, diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json index d70e4e44f995..65680d27f551 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/params.json @@ -482,12 +482,42 @@ "id": 1, "method": "_acpmux/watch", "params": { - "enabled": 1 + "enabled": 1, + "limit": 1 + } + }, + "mode_fields": null, + "breaks": false + }, + { + "name": "_acpmux/sessions every known param", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/sessions", + "params": { + "limit": 1, + "cursor": 1 } }, "mode_fields": null, "breaks": false }, + { + "name": "_acpmux/sessions unknown param", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/sessions", + "params": { + "limit": 1, + "cursor": "k1.2", + "query": "x" + } + }, + "mode_fields": null, + "breaks": true + }, { "name": "_acpmux/events every known param", "frame": { @@ -1017,5 +1047,34 @@ }, "mode_fields": null, "breaks": true + }, + { + "name": "subagent_stop known params pass", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/subagent_stop", + "params": { + "sessionId": "a", + "subagentSessionId": "t" + } + }, + "mode_fields": null, + "breaks": false + }, + { + "name": "subagent_stop unknown param", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "_acpmux/subagent_stop", + "params": { + "sessionId": "a", + "subagentSessionId": "t", + "command": "ls" + } + }, + "mode_fields": null, + "breaks": true } ] diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/sessions.json b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/sessions.json index 5b3b3184c7ea..414d796ff76a 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/sessions.json +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/cases/sessions.json @@ -387,5 +387,37 @@ "a" ], "refused": true + }, + { + "name": "subagent_stop for the chat in scope passes", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "method": "_acpmux/subagent_stop", + "params": { + "sessionId": "a", + "subagentSessionId": "t" + } + }, + "sessions": [ + "a" + ], + "refused": false + }, + { + "name": "subagent_stop for another chat is refused", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "method": "_acpmux/subagent_stop", + "params": { + "sessionId": "b", + "subagentSessionId": "t" + } + }, + "sessions": [ + "a" + ], + "refused": true } ] diff --git a/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs b/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs index 77d90ee85f1f..87f88158bad7 100644 --- a/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs +++ b/cmux-tui/crates/cmux-agent-pane-policy/tests/typescript.rs @@ -1,6 +1,6 @@ //! The lists against what the page sends (webviews/src/agent-session/acpmux), //! as AcpmuxPaneMethods.swift says they were made: every -//! `this.request("...")` in direct.ts, `HANDOFF_OPS` (handoff/protocol.ts), +//! `this.request("...")` in direct.ts and sessionPages.ts, `HANDOFF_OPS` (handoff/protocol.ts), //! `PERMISSION_GROUP_OPS` (permissions/protocol.ts), `FORK_OP` //! (operations.ts), `PREWARM_METHOD` (direct.ts), and the raw //! `session/cancel` notification. `file.search` (and the git reads, sent by a @@ -63,6 +63,7 @@ fn the_allowlist_is_what_the_page_sends() { let dir = pane(); let direct = read(&dir, "direct.ts"); let mut sent: BTreeSet = quoted_after(&direct, "this.request(").into_iter().collect(); + sent.extend(quoted_after(&read(&dir, "sessionPages.ts"), "this.request(")); sent.extend(object_values(&read(&dir, "handoff/protocol.ts"), "HANDOFF_OPS")); sent.extend(object_values(&read(&dir, "permissions/protocol.ts"), "PERMISSION_GROUP_OPS")); sent.insert(constant(&read(&dir, "operations.ts"), "FORK_OP")); diff --git a/cmux-tui/crates/cmux-app-ffi/Cargo.lock b/cmux-tui/crates/cmux-app-ffi/Cargo.lock index 0c9486f46cb2..94d223ecff31 100644 --- a/cmux-tui/crates/cmux-app-ffi/Cargo.lock +++ b/cmux-tui/crates/cmux-app-ffi/Cargo.lock @@ -22,6 +22,7 @@ name = "cmux-app-ffi" version = "0.1.0" dependencies = [ "cmux-layout-reducer-ffi", + "cmux-motion", "cmux-palette-ranker", "cmux-rd-ffi", ] @@ -54,6 +55,10 @@ dependencies = [ "serde_json", ] +[[package]] +name = "cmux-motion" +version = "0.1.0" + [[package]] name = "cmux-palette-ranker" version = "0.1.0" diff --git a/cmux-tui/crates/cmux-app-ffi/Cargo.toml b/cmux-tui/crates/cmux-app-ffi/Cargo.toml index d9c5ea4cda93..724a3ad07ae9 100644 --- a/cmux-tui/crates/cmux-app-ffi/Cargo.toml +++ b/cmux-tui/crates/cmux-app-ffi/Cargo.toml @@ -5,7 +5,7 @@ edition = "2024" rust-version = "1.91" license = "GPL-3.0-or-later" publish = false -description = "The one Rust static library the cmux macOS app links: the C ABIs of the remote desktop core, the sidebar layout reducer and the palette ranker" +description = "The one Rust static library the cmux macOS app links: the C ABIs of the remote desktop core, the sidebar layout reducer, the palette ranker and cmux-motion" # Own workspace, like its members: built only for the app xcframework # (scripts/cmux-next/build-app-ffi.sh), so cmux-tui workspace builds and @@ -19,6 +19,9 @@ crate-type = ["staticlib"] cmux-rd-ffi = { path = "../cmux-rd-ffi" } cmux-layout-reducer-ffi = { path = "../cmux-layout-reducer-ffi" } cmux-palette-ranker = { path = "../cmux-palette-ranker" } +# The spring step and motion tokens (src/motion.rs). No `settings`: the app +# passes its own settings per call, so no AppKit or env reading here. +cmux-motion = { path = "../cmux-motion", default-features = false } [profile.release] opt-level = 3 diff --git a/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h new file mode 100644 index 000000000000..534ed4e73a68 --- /dev/null +++ b/cmux-tui/crates/cmux-app-ffi/include/cmux_motion_ffi.h @@ -0,0 +1,85 @@ +#ifndef CMUX_MOTION_FFI_H +#define CMUX_MOTION_FFI_H + +/* cmux-motion's C ABI (cmux-tui/crates/cmux-app-ffi/src/motion.rs): the + * closed-form spring, the spec's motion tokens, the structural bounce rule + * and the ui.animations.* switches, and the tab strip's tweens and geometry + * (Chrome's strip motion). Pure functions; no globals. ABI version 2. */ + +#include +#include + +typedef struct { + double response; + double damping_fraction; +} CmuxMotionSpring; + +typedef struct { + double value; + double velocity; + double target; +} CmuxMotionState; + +/* speed: 0 fast, 1 normal, 2 off. bounce: 0 none, 1 subtle. */ +typedef struct { + uint8_t speed; + bool reduce_motion; + uint8_t bounce; + bool pane_animations; + bool new_tab; + bool new_workspace; +} CmuxMotionPolicy; + +uint32_t cmux_motion_ffi_abi_version(void); +/* token: 0 move, 1 appear, 2 disappear, 3 settle, 4 scroll, 5 screen, + * 6 track, 7 selection, 8 panel, 9 pinDrag, 10 zoom, 11 exit, 12 tabOpen + * (the macOS MotionSpring declares its cases in this order). */ +bool cmux_motion_spring_base(uint8_t token, CmuxMotionSpring *out); +bool cmux_motion_spring_is_structural(uint8_t token); +CmuxMotionSpring cmux_motion_resolve(CmuxMotionSpring base, bool structural, CmuxMotionPolicy policy); +bool cmux_motion_step(CmuxMotionState *state, double dt, CmuxMotionSpring spring, double epsilon); +/* kind: 0 paneAnimations, 1 newTab, 2 newWorkspace (newTab on by default, the others off). */ +bool cmux_motion_animates(uint8_t kind, CmuxMotionPolicy policy); +double cmux_motion_fade_seconds(uint8_t fade, CmuxMotionPolicy policy); + +/* A tween: seconds and curve (0 easeOutQuad = 1-(1-t)^2). */ +typedef struct { + double duration; + uint8_t curve; +} CmuxMotionTween; + +/* One scalar on a tween: a new target restarts the duration from value. */ +typedef struct { + double value; + double from; + double target; + double elapsed; + double duration; +} CmuxMotionTweenState; + +/* design-tokens.json motion.tabStrip (pt; closing_mode_exit_delay in s). */ +typedef struct { + double max_width; + double open_start_width; + double close_end_width; + double min_active_width; + double min_inactive_width; + double pinned_width; + double reorder_threshold; + double closing_mode_below_pad; + double closing_mode_trailing_pad; + double closing_mode_exit_delay; +} CmuxMotionTabStripGeometry; + +/* token: 0 tabOpen, 1 tabClose, 2 tabMove. */ +bool cmux_motion_tween_base(uint8_t token, CmuxMotionTween *out); +/* 0 under speed off, Reduce Motion, or tabOpen/tabClose with newTab off. */ +double cmux_motion_tween_seconds(uint8_t token, CmuxMotionPolicy policy); +/* Same target: no change, false. New target: restart duration from value. */ +bool cmux_motion_tween_retarget(CmuxMotionTweenState *state, double target, double duration); +bool cmux_motion_tween_step(CmuxMotionTweenState *state, double dt, uint8_t curve); +double cmux_motion_ease(uint8_t curve, double t); +/* density: 0 compact, 1 comfortable. */ +bool cmux_motion_tab_strip_geometry(uint8_t density, CmuxMotionTabStripGeometry *out); + +#endif diff --git a/cmux-tui/crates/cmux-app-ffi/src/lib.rs b/cmux-tui/crates/cmux-app-ffi/src/lib.rs index f9ead1f17480..c555a3e7dac2 100644 --- a/cmux-tui/crates/cmux-app-ffi/src/lib.rs +++ b/cmux-tui/crates/cmux-app-ffi/src/lib.rs @@ -8,6 +8,9 @@ //! archive: this crate links their crates, whose `#[unsafe(no_mangle)]` //! functions are the archive's exported symbols, over one Rust runtime. //! Add a new in-tree C ABI here, never as another static library. +//! `motion` is cmux-motion's C ABI (the spring step and motion tokens). + +pub mod motion; pub use cmux_layout_reducer_ffi as layout_reducer; pub use cmux_palette_ranker::ffi as palette_ranker; diff --git a/cmux-tui/crates/cmux-app-ffi/src/motion.rs b/cmux-tui/crates/cmux-app-ffi/src/motion.rs new file mode 100644 index 000000000000..8309316ef6e1 --- /dev/null +++ b/cmux-tui/crates/cmux-app-ffi/src/motion.rs @@ -0,0 +1,341 @@ +//! The C ABI of cmux-motion for the macOS app (`include/cmux_motion_ffi.h`, +//! Swift module `CCmuxMotionFFI`): the closed-form spring step, the token +//! table, the structural bounce rule and the per-kind switches, and the +//! tab strip's tweens and geometry (Chrome's strip motion). The app's +//! `MotionClock` keeps only the display link and the layer updates; the +//! physics and the rules live once, in cmux-motion. Every function is pure +//! (no globals, no allocation) and never panics on any input. + +use cmux_motion::{ + AnimationSwitches, MotionBounce, MotionFade, MotionKind, MotionPolicy, MotionSpeed, + MotionSpring, MotionTween, SpringParameters, TabStripGeometry, TweenCurve, TweenValue, + advance_spring, +}; + +/// Bumped on any change to a type or function in the header. +const ABI_VERSION: u32 = 2; + +/// `SpringParameters`. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionSpring { + pub response: f64, + pub damping_fraction: f64, +} + +/// One animated scalar: value, velocity (units per second), target. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionState { + pub value: f64, + pub velocity: f64, + pub target: f64, +} + +/// The host's settings for one resolve: `speed` 0 fast, 1 normal, 2 off; +/// `bounce` 0 none, 1 subtle; the three `ui.animations.*` switches. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionPolicy { + pub speed: u8, + pub reduce_motion: bool, + pub bounce: u8, + pub pane_animations: bool, + pub new_tab: bool, + pub new_workspace: bool, +} + +impl From for SpringParameters { + fn from(s: CmuxMotionSpring) -> Self { + SpringParameters::new(s.response, s.damping_fraction) + } +} + +impl From for CmuxMotionSpring { + fn from(p: SpringParameters) -> Self { + Self { response: p.response, damping_fraction: p.damping_fraction } + } +} + +fn policy(p: &CmuxMotionPolicy) -> MotionPolicy { + let speed = match p.speed { + 1 => MotionSpeed::Normal, + 2 => MotionSpeed::Off, + _ => MotionSpeed::Fast, + }; + let bounce = if p.bounce == 1 { MotionBounce::Subtle } else { MotionBounce::None }; + MotionPolicy::new(speed, p.reduce_motion).with_animations(AnimationSwitches { + pane_animations: p.pane_animations, + new_tab: p.new_tab, + new_workspace: p.new_workspace, + bounce, + }) +} + +/// The ABI version this library implements. +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_ffi_abi_version() -> u32 { + ABI_VERSION +} + +/// The spring token `token` (`MotionSpring` order: 0 move, 1 appear, 2 +/// disappear, 3 settle, 4 scroll, 5 screen, 6 track, 7 selection, 8 panel, +/// 9 pinDrag, 10 zoom, 11 exit, 12 tabOpen; the macOS `MotionSpring` declares its +/// cases in this order) at speed `fast` from the spec, before the policy. Returns +/// false for an unknown token. +/// +/// # Safety +/// `out` is null or points to a writable `CmuxMotionSpring`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_spring_base(token: u8, out: *mut CmuxMotionSpring) -> bool { + let Some(token) = MotionSpring::ALL.get(usize::from(token)) else { return false }; + if out.is_null() { + return false; + } + // SAFETY: `out` is non-null and writable per the contract above. + unsafe { out.write(token.base().into()) }; + true +} + +/// True when spring token `token` is structural (follows the bounce). +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_spring_is_structural(token: u8) -> bool { + MotionSpring::ALL.get(usize::from(token)).is_some_and(|t| t.is_structural()) +} + +/// Resolves `base` (a token's speed-`fast` tuning, possibly a host +/// override) under `policy`: a structural spring is critically damped with +/// bounce none and takes the subtle bounce with bounce subtle; then the +/// speed scales the response. The same rule as `MotionPolicy::spring`. +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_resolve( + base: CmuxMotionSpring, + structural: bool, + policy: CmuxMotionPolicy, +) -> CmuxMotionSpring { + let p = self::policy(&policy); + let mut s = SpringParameters::from(base); + if structural { + s = match p.animations.bounce { + MotionBounce::None => SpringParameters::new(s.response, s.damping_fraction.max(1.)), + MotionBounce::Subtle => s.with_bounce(MotionBounce::Subtle.amount()), + }; + } + if p.speed == MotionSpeed::Off { s.into() } else { s.scaled(p.speed.time_scale()).into() } +} + +/// Advances `state` by `dt` seconds on `spring` in closed form (exact at +/// any frame rate, keeps velocity across retargets, dt capped at 0.1 s) and +/// snaps it to its target once within `epsilon` and slow. True while still +/// moving; false (and no change) for a null `state`. +/// +/// # Safety +/// `state` is null or points to a valid, writable `CmuxMotionState`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_step( + state: *mut CmuxMotionState, + dt: f64, + spring: CmuxMotionSpring, + epsilon: f64, +) -> bool { + if state.is_null() { + return false; + } + // SAFETY: `state` is non-null, valid and writable per the contract above. + let s = unsafe { &mut *state }; + advance_spring(&mut s.value, &mut s.velocity, s.target, dt, spring.into(), epsilon) +} + +/// True when kind `kind` (0 paneAnimations, 1 newTab, 2 newWorkspace) animates +/// under `policy`: movement animates and its switch is on. +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_animates(kind: u8, policy: CmuxMotionPolicy) -> bool { + MotionKind::ALL.get(usize::from(kind)).is_some_and(|k| self::policy(&policy).animates(*k)) +} + +/// Seconds of fade token `fade` (`MotionFade` order) under `policy`; 0 +/// applies at once (also for an unknown token). +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_fade_seconds(fade: u8, policy: CmuxMotionPolicy) -> f64 { + MotionFade::ALL.get(usize::from(fade)).map_or(0., |f| self::policy(&policy).fade(*f)) +} + +/// `TweenParameters`: seconds and curve (0 easeOutQuad). +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionTween { + pub duration: f64, + pub curve: u8, +} + +/// One scalar on a tween (`TweenValue`). +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionTweenState { + pub value: f64, + pub from: f64, + pub target: f64, + pub elapsed: f64, + pub duration: f64, +} + +/// `TabStripGeometry`, the same fields in the same order. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CmuxMotionTabStripGeometry { + pub max_width: f64, + pub open_start_width: f64, + pub close_end_width: f64, + pub min_active_width: f64, + pub min_inactive_width: f64, + pub pinned_width: f64, + pub reorder_threshold: f64, + pub closing_mode_below_pad: f64, + pub closing_mode_trailing_pad: f64, + pub closing_mode_exit_delay: f64, +} + +/// The curve for a C ABI curve number: easeOutQuad is the only one (0); +/// any other number takes it too. +fn curve(_c: u8) -> TweenCurve { + TweenCurve::EaseOutQuad +} + +impl From for TweenValue { + fn from(s: CmuxMotionTweenState) -> Self { + TweenValue { + value: s.value, + from: s.from, + target: s.target, + elapsed: s.elapsed, + duration: s.duration, + } + } +} + +impl From for CmuxMotionTweenState { + fn from(s: TweenValue) -> Self { + Self { + value: s.value, + from: s.from, + target: s.target, + elapsed: s.elapsed, + duration: s.duration, + } + } +} + +/// The tween token `token` (`MotionTween` order: 0 tabOpen, 1 tabClose, 2 +/// tabMove) at speed `fast` from the spec. Returns false for an unknown +/// token or a null `out`. +/// +/// # Safety +/// `out` is null or points to a writable `CmuxMotionTween`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_tween_base(token: u8, out: *mut CmuxMotionTween) -> bool { + let Some(token) = MotionTween::ALL.get(usize::from(token)) else { return false }; + if out.is_null() { + return false; + } + let p = token.base(); + // SAFETY: `out` is non-null and writable per the contract above. + unsafe { out.write(CmuxMotionTween { duration: p.duration, curve: p.curve as u8 }) }; + true +} + +/// Seconds of tween token `token` under `policy`: 0 under speed off, +/// Reduce Motion, or with the token's kind switched off (tabOpen and +/// tabClose follow newTab); 0 for an unknown token. +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_tween_seconds(token: u8, policy: CmuxMotionPolicy) -> f64 { + MotionTween::ALL.get(usize::from(token)).map_or(0., |t| self::policy(&policy).tween(*t)) +} + +/// Points `state` at `target`: the same target changes nothing (returns +/// false); a new one restarts `duration` seconds from the current value (no +/// velocity); a duration that is not positive applies it at once. False +/// for a null `state`. +/// +/// # Safety +/// `state` is null or points to a valid, writable `CmuxMotionTweenState`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_tween_retarget( + state: *mut CmuxMotionTweenState, + target: f64, + duration: f64, +) -> bool { + if state.is_null() { + return false; + } + // SAFETY: `state` is non-null, valid and writable per the contract above. + let s = unsafe { &mut *state }; + let mut t = TweenValue::from(*s); + let changed = t.retarget(target, duration); + *s = t.into(); + changed +} + +/// Advances `state` by `dt` seconds (capped at 0.1 s) on `curve` (0 +/// easeOutQuad). Ends exactly on the target. True while still moving; +/// false (and no change) for a null `state`. +/// +/// # Safety +/// `state` is null or points to a valid, writable `CmuxMotionTweenState`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_tween_step( + state: *mut CmuxMotionTweenState, + dt: f64, + curve: u8, +) -> bool { + if state.is_null() { + return false; + } + // SAFETY: `state` is non-null, valid and writable per the contract above. + let s = unsafe { &mut *state }; + let mut t = TweenValue::from(*s); + let moving = t.step(dt, self::curve(curve)); + *s = t.into(); + moving +} + +/// Progress of `curve` (0 easeOutQuad) at time fraction `t` (clamped to 0..=1). +#[unsafe(no_mangle)] +pub extern "C" fn cmux_motion_ease(curve: u8, t: f64) -> f64 { + self::curve(curve).progress_f64(t) +} + +/// The tab strip geometry for `density` (0 compact, 1 comfortable) from the +/// spec (design-tokens.json `motion.tabStrip`). False for an unknown +/// density or a null `out`. +/// +/// # Safety +/// `out` is null or points to a writable `CmuxMotionTabStripGeometry`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn cmux_motion_tab_strip_geometry( + density: u8, + out: *mut CmuxMotionTabStripGeometry, +) -> bool { + let g = match density { + 0 => TabStripGeometry::COMPACT, + 1 => TabStripGeometry::COMFORTABLE, + _ => return false, + }; + if out.is_null() { + return false; + } + let c = CmuxMotionTabStripGeometry { + max_width: g.max_width, + open_start_width: g.open_start_width, + close_end_width: g.close_end_width, + min_active_width: g.min_active_width, + min_inactive_width: g.min_inactive_width, + pinned_width: g.pinned_width, + reorder_threshold: g.reorder_threshold, + closing_mode_below_pad: g.closing_mode_below_pad, + closing_mode_trailing_pad: g.closing_mode_trailing_pad, + closing_mode_exit_delay: g.closing_mode_exit_delay, + }; + // SAFETY: `out` is non-null and writable per the contract above. + unsafe { out.write(c) }; + true +} diff --git a/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts b/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts index 5b0a0e1f8259..a676e4786e16 100644 --- a/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts +++ b/cmux-tui/crates/cmux-app-host/generated/cmux-app.d.ts @@ -588,6 +588,8 @@ interface CmuxGlobal { /** `chief.list` (read, scope `chief:read`): The user's chiefs (active first, the default marked), archived ones on request, and tombstones. */ list: CmuxOp<{ include_archived?: boolean }, { chiefs: Array; tombstones: Array<{ id: Cmux.ChiefId; owner_user: string; archived_at: Cmux.Timestamp }> }> profile: { + /** `chief.profile.adopt` (mutation, scope `chief:write`) */ + adopt: CmuxOp<{ machine?: string; session?: string; participant_id: string; display_name?: string; role?: string; personality?: string; instructions?: string; tools?: Array; harnesses?: Array; self_edit?: Array; harness?: string; model?: string; effort?: string }, Cmux.MutationResult> avatar: { /** `chief.profile.avatar.get` (read, scope `chief:read`) */ get: CmuxOp<{ machine?: string; session?: string; chief: string }, Cmux.ChiefAvatarPage> diff --git a/cmux-tui/crates/cmux-app-host/generated/ops.json b/cmux-tui/crates/cmux-app-host/generated/ops.json index 74505989015b..2379d643133b 100644 --- a/cmux-tui/crates/cmux-app-host/generated/ops.json +++ b/cmux-tui/crates/cmux-app-host/generated/ops.json @@ -53,6 +53,7 @@ "chief.engine.get", "chief.engine.set", "chief.list", + "chief.profile.adopt", "chief.profile.avatar.get", "chief.profile.avatar.set", "chief.profile.create", diff --git a/cmux-tui/crates/cmux-app-host/generated/scopes.json b/cmux-tui/crates/cmux-app-host/generated/scopes.json index 52ec70c96812..272f7a71cc5c 100644 --- a/cmux-tui/crates/cmux-app-host/generated/scopes.json +++ b/cmux-tui/crates/cmux-app-host/generated/scopes.json @@ -209,6 +209,10 @@ "scope": "chief:read", "class": "read" }, + "chief.profile.adopt": { + "scope": "chief:write", + "class": "mutation" + }, "chief.profile.avatar.get": { "scope": "chief:read", "class": "read" diff --git a/cmux-tui/crates/cmux-build-identity/Cargo.toml b/cmux-tui/crates/cmux-build-identity/Cargo.toml new file mode 100644 index 000000000000..6c00252ec5cf --- /dev/null +++ b/cmux-tui/crates/cmux-build-identity/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "cmux-build-identity" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +description = "The cmux-tui build identity (HEAD, plus a hash of the dirty source tree), in a leaf crate so a source edit does not recompile cmux-remote" + +[lints] +workspace = true + +[build-dependencies] +# This root package uses Cargo's Git-aware default fingerprint to notice new +# workspace-root files without recursively walking target/. +cmux-tui-source-watch = { path = "../.." } diff --git a/cmux-tui/crates/cmux-remote/build.rs b/cmux-tui/crates/cmux-build-identity/build.rs similarity index 99% rename from cmux-tui/crates/cmux-remote/build.rs rename to cmux-tui/crates/cmux-build-identity/build.rs index bc916058f5ee..927f125a995b 100644 --- a/cmux-tui/crates/cmux-remote/build.rs +++ b/cmux-tui/crates/cmux-build-identity/build.rs @@ -44,7 +44,7 @@ fn source_revision(manifest_dir: &Path) -> String { let source_root = manifest_dir .parent() .and_then(Path::parent) - .unwrap_or_else(|| panic!("cmux-remote is not inside the cmux-tui workspace")); + .unwrap_or_else(|| panic!("cmux-build-identity is not inside the cmux-tui workspace")); let source_root = source_root.canonicalize().unwrap_or_else(|error| { panic!("could not resolve cmux-tui source root {}: {error}", source_root.display()) }); diff --git a/cmux-tui/crates/cmux-build-identity/src/lib.rs b/cmux-tui/crates/cmux-build-identity/src/lib.rs new file mode 100644 index 000000000000..2c5c6f803ebf --- /dev/null +++ b/cmux-tui/crates/cmux-build-identity/src/lib.rs @@ -0,0 +1,17 @@ +//! The identity of this cmux-tui build: `CMUX_TUI_BUILD_COMMIT` when the +//! build sets it (release and CI builds), otherwise the HEAD commit, plus +//! `-dirty.` of the uncommitted cmux-tui sources (build.rs). +//! +//! Daemon skew detection and SSH host re-bootstrap compare this value, so a +//! dev build must change it on every source edit. It lives in this leaf crate, +//! not in cmux-remote, because the build script reruns on every edit in the +//! workspace: only this crate and the crates that read it (cmux-tui) recompile, +//! not cmux-remote and everything between it and the binary. Library crates +//! take the value as an argument (`SshBootstrapConfig::defaults`, +//! `pinned_linux_payload`); do not depend on this crate from a library that +//! the binary does not otherwise rebuild on every edit. + +#![forbid(unsafe_code)] + +/// This build's identity. See the crate documentation. +pub const BUILD_IDENTITY: &str = env!("CMUX_TUI_BUILD_IDENTITY"); diff --git a/cmux-tui/crates/cmux-remote/tests/build_identity.rs b/cmux-tui/crates/cmux-build-identity/tests/build_identity.rs similarity index 98% rename from cmux-tui/crates/cmux-remote/tests/build_identity.rs rename to cmux-tui/crates/cmux-build-identity/tests/build_identity.rs index c6ee7df9f34e..1a8f34a835e9 100644 --- a/cmux-tui/crates/cmux-remote/tests/build_identity.rs +++ b/cmux-tui/crates/cmux-build-identity/tests/build_identity.rs @@ -26,7 +26,7 @@ impl BuildFixture { .as_nanos(); let root = env::temp_dir() .join(format!("cmux-build-identity-{}-{nanos}-{unique}", std::process::id())); - let manifest_dir = root.join("cmux-tui/crates/cmux-remote"); + let manifest_dir = root.join("cmux-tui/crates/cmux-build-identity"); fs::create_dir_all(&manifest_dir).unwrap(); fs::write(root.join(".gitignore"), "cmux-tui/target/\n").unwrap(); fs::write(root.join("cmux-tui/source.txt"), "clean\n").unwrap(); @@ -37,7 +37,7 @@ impl BuildFixture { .unwrap(); fs::write( root.join("cmux-tui/Cargo.toml"), - "[package]\nname = \"cmux-tui-source-watch\"\nversion = \"0.0.0\"\nedition = \"2024\"\nbuild = \"source-watch-build.rs\"\n\n[lib]\npath = \"source-watch.rs\"\n\n[workspace]\nmembers = [\"crates/cmux-remote\"]\nresolver = \"2\"\n", + "[package]\nname = \"cmux-tui-source-watch\"\nversion = \"0.0.0\"\nedition = \"2024\"\nbuild = \"source-watch-build.rs\"\n\n[lib]\npath = \"source-watch.rs\"\n\n[workspace]\nmembers = [\"crates/cmux-build-identity\"]\nresolver = \"2\"\n", ) .unwrap(); fs::write(root.join("cmux-tui/source-watch.rs"), "pub const ACTIVE: () = ();\n").unwrap(); diff --git a/cmux-tui/crates/cmux-coderouter/src/lib.rs b/cmux-tui/crates/cmux-coderouter/src/lib.rs index 0bd5f5470f63..a7fe67fcec3a 100644 --- a/cmux-tui/crates/cmux-coderouter/src/lib.rs +++ b/cmux-tui/crates/cmux-coderouter/src/lib.rs @@ -86,14 +86,16 @@ pub struct StaticInstallSecretStore { /// creation. Each instance creates one OS-CSPRNG secret and reuses it. pub struct RandomInstallSecretStore { install_id: String, - secret: Vec, + secret: Secret>, } impl RandomInstallSecretStore { /// Create one install identity with a fresh 32-byte secret. pub fn new(install_id: impl Into) -> anyhow::Result { let mut secret = [0u8; 32]; getrandom::fill(&mut secret)?; - Ok(Self { install_id: install_id.into(), secret: secret.to_vec() }) + let kept = Secret::new(secret.to_vec()); + secret.zeroize(); + Ok(Self { install_id: install_id.into(), secret: kept }) } } impl InstallSecretStore for RandomInstallSecretStore { @@ -101,7 +103,7 @@ impl InstallSecretStore for RandomInstallSecretStore { &self.install_id } fn load(&self) -> anyhow::Result>> { - Ok(Secret::new(self.secret.clone())) + Ok(Secret::new(self.secret.expose().clone())) } } impl StaticInstallSecretStore { @@ -122,6 +124,10 @@ impl InstallSecretStore for StaticInstallSecretStore { } } +/// `errSecItemNotFound` (Security framework): the Keychain item does not exist yet. +#[cfg(target_os = "macos")] +const ERR_SEC_ITEM_NOT_FOUND: i32 = -25300; + /// Production Keychain store. The item is addressed by service and install id; /// the router never persists the returned bytes. pub struct KeychainInstallSecretStore { @@ -142,16 +148,22 @@ impl InstallSecretStore for KeychainInstallSecretStore { let keychain = security_framework::os::macos::keychain::SecKeychain::default()?; match keychain.find_generic_password("com.cmuxterm.coderouter", &self.install_id) { Ok((password, _item)) => Ok(Secret::new(password.to_owned())), - Err(_) => { + // Only a missing item is created: any other error (a locked or + // unreadable keychain) must not rotate the install secret. + Err(e) if e.code() == ERR_SEC_ITEM_NOT_FOUND => { let mut secret = [0u8; 32]; getrandom::fill(&mut secret)?; - keychain.set_generic_password( + let set = keychain.set_generic_password( "com.cmuxterm.coderouter", &self.install_id, &secret, - )?; - Ok(Secret::new(secret.to_vec())) + ); + let kept = Secret::new(secret.to_vec()); + secret.zeroize(); + set?; + Ok(kept) } + Err(e) => Err(e.into()), } } #[cfg(not(target_os = "macos"))] @@ -385,6 +397,14 @@ pub async fn serve(home: impl AsRef) -> anyhow::Result<()> { #[cfg(unix)] static OWNERS_GONE: std::sync::Mutex = std::sync::Mutex::new(false); +/// Where this run's install secret lives, for `status` (`install_secret`): +/// `keychain` (macOS Keychain) or `ephemeral` (random, in memory, this run +/// only: Linux, or a Mac with no default keychain). +static INSTALL_SECRET_KIND: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); +#[cfg(target_os = "macos")] +const INSTALL_SECRET_KEYCHAIN: &str = "keychain"; +const INSTALL_SECRET_EPHEMERAL: &str = "ephemeral"; + /// Reads the owner FIFO on its own thread (a blocking read: no polling, no /// timer). A read returns 0 when no process holds a write end. Under /// `OWNERS_GONE`'s lock the watch then checks again with a non-blocking read: @@ -490,6 +510,16 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { let lock = tokio::time::timeout(std::time::Duration::from_secs(30), lock_rx) .await .map_err(|_| anyhow::anyhow!("another local router holds router.lock"))???; + // Keys live in memory for one router run. macOS keeps the install secret in + // the Keychain; elsewhere a fresh random secret per run is equivalent. + // Chosen before the admin socket exists: a client that reaches the socket + // gets an answer. + #[cfg(target_os = "macos")] + let (store, kind) = macos_install_secret_store()?; + #[cfg(not(target_os = "macos"))] + let (store, kind): (Arc, &'static str) = + (Arc::new(RandomInstallSecretStore::new("default")?), INSTALL_SECRET_EPHEMERAL); + let _ = INSTALL_SECRET_KIND.set(kind); let socket_path = router_dir.join("router.sock"); let _ = tokio::fs::remove_file(&socket_path).await; let admin = UnixListener::bind(&socket_path)?; @@ -500,12 +530,6 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { } let bind_address: SocketAddr = "127.0.0.1:0".parse()?; let listener = TcpListener::bind(LoopbackAddr::try_from(bind_address)?.address()).await?; - // Keys live in memory for one router run. macOS keeps the install secret in - // the Keychain; elsewhere a fresh random secret per run is equivalent. - #[cfg(target_os = "macos")] - let store: Arc = Arc::new(KeychainInstallSecretStore::new("default")); - #[cfg(not(target_os = "macos"))] - let store: Arc = Arc::new(RandomInstallSecretStore::new("default")?); let keys = Arc::new(tokio::sync::RwLock::new(KeyRing::new(store)?)); let port = listener.local_addr()?.port(); let upstream: SharedUpstream = Arc::new(tokio::sync::RwLock::new(None)); @@ -521,6 +545,55 @@ async fn serve_owned(home: &Path) -> anyhow::Result<()> { Ok(()) } +/// The Keychain store when the Keychain answers at start, else a random +/// in-memory secret for this run (a Mac with no default keychain: a fleet +/// worker, a session without a login keychain). Keys never outlive one router +/// run, so the random secret is as strong; without it every mint failed and +/// the admin socket closed with no reply. Decided once: the key digest reads +/// the store on every check, so it must never switch during a run. +#[cfg(target_os = "macos")] +fn macos_install_secret_store() -> anyhow::Result<(Arc, &'static str)> { + let keychain = KeychainInstallSecretStore::new("default"); + // Debug builds only: tests take the no-keychain path on any Mac. + #[cfg(debug_assertions)] + let loaded = if std::env::var_os("CMUX_CODEROUTER_TEST_NO_KEYCHAIN").is_some() { + Err(anyhow::anyhow!("no default keychain (CMUX_CODEROUTER_TEST_NO_KEYCHAIN)")) + } else { + probe_keychain() + }; + #[cfg(not(debug_assertions))] + let loaded = probe_keychain(); + match loaded { + Ok(()) => Ok((Arc::new(keychain), INSTALL_SECRET_KEYCHAIN)), + Err(e) => { + // stderr (the router installs no log subscriber); the error text + // only, the secret itself never reaches a log. + eprintln!( + "cmux-coderouter: warning: the Keychain install secret is unavailable ({e:#}); this router run uses a random in-memory secret" + ); + Ok((Arc::new(RandomInstallSecretStore::new("default")?), INSTALL_SECRET_EPHEMERAL)) + } + } +} + +/// Ask the Keychain once, on its own thread and for at most +/// `KEYCHAIN_PROBE_LIMIT`: a lookup can block (an access prompt with no one to +/// answer it), and the router must still start. +#[cfg(target_os = "macos")] +fn probe_keychain() -> anyhow::Result<()> { + let (sent, answer) = std::sync::mpsc::channel(); + std::thread::Builder::new().name("keychain-probe".into()).spawn(move || { + let loaded = KeychainInstallSecretStore::new("default").load().map(drop); + let _ = sent.send(loaded.map_err(|e| format!("{e:#}"))); + })?; + match answer.recv_timeout(KEYCHAIN_PROBE_LIMIT) { + Ok(result) => result.map_err(|e| anyhow::anyhow!(e)), + Err(_) => anyhow::bail!("the Keychain did not answer within {KEYCHAIN_PROBE_LIMIT:?}"), + } +} +#[cfg(target_os = "macos")] +const KEYCHAIN_PROBE_LIMIT: std::time::Duration = std::time::Duration::from_secs(3); + /// Start the real loopback data plane for integration tests and composition. pub async fn spawn_data_plane( keys: Arc>, @@ -661,18 +734,18 @@ async fn admin_connection( } }; let response = match request { - AdminRequest::Mint { key_id, scope } => { - let mut guard = keys.write().await; - let key = guard.mint(key_id, scope)?; - serde_json::json!({"key": key.expose()}) - } + // A mint error (no install secret) is answered, not a closed socket. + AdminRequest::Mint { key_id, scope } => match keys.write().await.mint(key_id, scope) { + Ok(key) => serde_json::json!({"key": key.expose()}), + Err(error) => serde_json::json!({"error": error.to_string()}), + }, AdminRequest::List => serde_json::json!({"keys": keys.read().await.list()}), AdminRequest::Revoke { key_id } => { serde_json::json!({"revoked": keys.write().await.revoke(&key_id)}) } AdminRequest::Status => { let up = upstream.read().await; - serde_json::json!({"port": port, "build": router_build(), "pid": std::process::id(), "upstream": up.as_ref().map(Upstream::view), "free": free.view()}) + serde_json::json!({"port": port, "build": router_build(), "pid": std::process::id(), "upstream": up.as_ref().map(Upstream::view), "free": free.view(), "install_secret": INSTALL_SECRET_KIND.get().copied()}) } AdminRequest::Adopt => { // The daemon opened the owner FIFO before it asked; a router @@ -688,8 +761,10 @@ async fn admin_connection( } AdminRequest::MintKey { scope } => { let id = format!("s{}", uuid::Uuid::new_v4().simple()); - let key = keys.write().await.mint(id.clone(), scope)?; - serde_json::json!({"id": id, "key": key.expose()}) + match keys.write().await.mint(id.clone(), scope) { + Ok(key) => serde_json::json!({"id": id, "key": key.expose()}), + Err(error) => serde_json::json!({"error": error.to_string()}), + } } AdminRequest::RevokeKey { id } => { serde_json::json!({"revoked": keys.write().await.revoke(&id)}) diff --git a/cmux-tui/crates/cmux-motion/build-support/json.rs b/cmux-tui/crates/cmux-motion/build-support/json.rs new file mode 100644 index 000000000000..4690ba0f2757 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/build-support/json.rs @@ -0,0 +1,210 @@ +//! A small JSON reader for `build.rs` (no dependencies, so the crate's +//! dependency graph and every Cargo.lock stay unchanged). It reads exactly +//! what `design-tokens.json` uses: objects, arrays, strings, numbers, +//! booleans and null. Errors name the byte offset. + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + Null, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + Object(Vec<(String, Value)>), +} + +impl Value { + pub fn get(&self, key: &str) -> Option<&Value> { + match self { + Value::Object(entries) => entries.iter().find(|(k, _)| k == key).map(|(_, v)| v), + _ => None, + } + } + + pub fn as_f64(&self) -> Option { + match self { + Value::Number(n) => Some(*n), + _ => None, + } + } + + pub fn as_str(&self) -> Option<&str> { + match self { + Value::String(s) => Some(s), + _ => None, + } + } + + pub fn as_bool(&self) -> Option { + match self { + Value::Bool(b) => Some(*b), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Value::Array(items) => Some(items), + _ => None, + } + } +} + +pub fn parse(text: &str) -> Result { + let mut p = Parser { s: text.as_bytes(), i: 0 }; + let v = p.value()?; + p.ws(); + if p.i != p.s.len() { + return Err(p.err("trailing text")); + } + Ok(v) +} + +struct Parser<'a> { + s: &'a [u8], + i: usize, +} + +impl Parser<'_> { + fn err(&self, what: &str) -> String { + format!("{what} at byte {}", self.i) + } + + fn ws(&mut self) { + while self.i < self.s.len() && self.s[self.i].is_ascii_whitespace() { + self.i += 1; + } + } + + fn eat(&mut self, lit: &str) -> bool { + if self.s[self.i..].starts_with(lit.as_bytes()) { + self.i += lit.len(); + true + } else { + false + } + } + + fn value(&mut self) -> Result { + self.ws(); + match self.s.get(self.i) { + Some(b'{') => self.object(), + Some(b'[') => self.array(), + Some(b'"') => self.string().map(Value::String), + Some(b't') if self.eat("true") => Ok(Value::Bool(true)), + Some(b'f') if self.eat("false") => Ok(Value::Bool(false)), + Some(b'n') if self.eat("null") => Ok(Value::Null), + Some(_) => self.number(), + None => Err(self.err("unexpected end")), + } + } + + fn object(&mut self) -> Result { + self.i += 1; + let mut entries = Vec::new(); + self.ws(); + if self.eat("}") { + return Ok(Value::Object(entries)); + } + loop { + self.ws(); + let key = self.string()?; + self.ws(); + if !self.eat(":") { + return Err(self.err("expected ':'")); + } + entries.push((key, self.value()?)); + self.ws(); + if self.eat(",") { + continue; + } + if self.eat("}") { + return Ok(Value::Object(entries)); + } + return Err(self.err("expected ',' or '}'")); + } + } + + fn array(&mut self) -> Result { + self.i += 1; + let mut items = Vec::new(); + self.ws(); + if self.eat("]") { + return Ok(Value::Array(items)); + } + loop { + items.push(self.value()?); + self.ws(); + if self.eat(",") { + continue; + } + if self.eat("]") { + return Ok(Value::Array(items)); + } + return Err(self.err("expected ',' or ']'")); + } + } + + fn string(&mut self) -> Result { + if !self.eat("\"") { + return Err(self.err("expected a string")); + } + let mut out = String::new(); + loop { + let start = self.i; + while self.i < self.s.len() && self.s[self.i] != b'"' && self.s[self.i] != b'\\' { + self.i += 1; + } + out.push_str( + std::str::from_utf8(&self.s[start..self.i]).map_err(|_| self.err("bad UTF-8"))?, + ); + match self.s.get(self.i) { + Some(b'"') => { + self.i += 1; + return Ok(out); + } + Some(b'\\') => { + self.i += 1; + let c = *self.s.get(self.i).ok_or_else(|| self.err("unexpected end"))?; + self.i += 1; + match c { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let hex = self + .s + .get(self.i..self.i + 4) + .ok_or_else(|| self.err("short \\u"))?; + let code = + u32::from_str_radix(std::str::from_utf8(hex).unwrap_or("x"), 16) + .map_err(|_| self.err("bad \\u"))?; + self.i += 4; + // Surrogate pairs never carry a token value; keep a replacement. + out.push(char::from_u32(code).unwrap_or('\u{fffd}')); + } + _ => return Err(self.err("bad escape")), + } + } + _ => return Err(self.err("unterminated string")), + } + } + } + + fn number(&mut self) -> Result { + let start = self.i; + while self.i < self.s.len() + && matches!(self.s[self.i], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9') + { + self.i += 1; + } + let text = + std::str::from_utf8(&self.s[start..self.i]).map_err(|_| self.err("bad number"))?; + text.parse::().map(Value::Number).map_err(|_| self.err("expected a value")) + } +} diff --git a/cmux-tui/crates/cmux-motion/build.rs b/cmux-tui/crates/cmux-motion/build.rs new file mode 100644 index 000000000000..91d0e81d5411 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/build.rs @@ -0,0 +1,205 @@ +//! Reads the motion tokens from the spec's `design-tokens.json` (`motion`) +//! at build time and writes them as constants (`$OUT_DIR/tokens.rs`), so +//! the numbers live in one file that every port reads (cmux-next macOS, +//! this crate, the GPUI app) and the token functions stay `const`. A +//! missing key, a wrong type or a value out of range fails the build with +//! the json path; nothing falls back to a copy in code. + +#[path = "build-support/json.rs"] +mod json; + +use std::fmt::Write as _; +use std::path::PathBuf; + +/// Repo root `plans/cmux-next/spec-proposals/visuals/design-tokens.json`, +/// the same file cmux-theme-tokens compiles in. +const TOKENS: &str = "../../../plans/cmux-next/spec-proposals/visuals/design-tokens.json"; + +/// `motion.springs` keys in `MotionSpring` declaration order. +const SPRINGS: [&str; 13] = [ + "move", + "appear", + "disappear", + "settle", + "scroll", + "screen", + "track", + "selection", + "panel", + "pinDrag", + "zoom", + "exit", + "tabOpen", +]; +/// `motion.fades` keys in `MotionFade` declaration order. +const FADES: [&str; 12] = [ + "hover", + "focus", + "fadeIn", + "fadeOut", + "crossfade", + "lift", + "theme", + "highlight", + "launch", + "pinLift", + "pinSettle", + "pinShrink", +]; +/// `motion.tweens` keys in `MotionTween` declaration order. +const TWEENS: [&str; 3] = ["tabOpen", "tabClose", "tabMove"]; +/// `motion.loops` keys in `MotionLoop` declaration order. +const LOOPS: [&str; 3] = ["spinner", "pulse", "flash"]; + +fn main() { + let path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")) + .join(TOKENS); + println!("cargo:rerun-if-changed={}", path.display()); + println!("cargo:rerun-if-changed=build-support/json.rs"); + let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let doc = json::parse(&text).unwrap_or_else(|e| panic!("design-tokens.json: {e}")); + let motion = doc.get("motion").unwrap_or_else(|| panic!("design-tokens.json: no motion")); + let code = generate(motion).unwrap_or_else(|e| panic!("design-tokens.json motion.{e}")); + let out = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")).join("tokens.rs"); + std::fs::write(&out, code).unwrap_or_else(|e| panic!("{}: {e}", out.display())); +} + +fn at<'a>(v: &'a json::Value, path: &[&str]) -> Result<&'a json::Value, String> { + path.iter() + .try_fold(v, |v, key| v.get(key)) + .ok_or_else(|| format!("{}: missing", path.join("."))) +} + +fn number( + v: &json::Value, + path: &[&str], + range: std::ops::RangeInclusive, +) -> Result { + let n = at(v, path)?.as_f64().ok_or_else(|| format!("{}: not a number", path.join(".")))?; + if !range.contains(&n) { + return Err(format!("{}: {n} is outside {range:?}", path.join("."))); + } + Ok(n) +} + +fn flag(v: &json::Value, path: &[&str]) -> Result { + at(v, path)?.as_bool().ok_or_else(|| format!("{}: not a boolean", path.join("."))) +} + +fn generate(m: &json::Value) -> Result { + let mut code = + String::from("// Generated by cmux-motion/build.rs from design-tokens.json `motion`.\n"); + let speed = |key| number(m, &["speed", "value", key], 0.0..=10.0); + let (fast, normal, off) = (speed("fast")?, speed("normal")?, speed("off")?); + if fast != 1.0 || off != 0.0 { + return Err(format!("speed.value: fast must be 1 and off 0 (got {fast}, {off})")); + } + writeln!(code, "pub(crate) const SPEED_NORMAL: f64 = {normal:?};").ok(); + + writeln!(code, "pub(crate) const SPRINGS: [SpringParameters; {}] = [", SPRINGS.len()).ok(); + for key in SPRINGS { + let response = number(m, &["springs", key, "value", "response"], 0.01..=2.0)?; + let damping = number(m, &["springs", key, "value", "dampingFraction"], 0.05..=4.0)?; + writeln!(code, " SpringParameters::new({response:?}, {damping:?}), // {key}").ok(); + } + code.push_str("];\n"); + + let structural = at(m, &["structural", "value"])? + .as_array() + .ok_or("structural.value: not an array")? + .iter() + .map(|v| v.as_str().ok_or("structural.value: not a string").map(str::to_owned)) + .collect::, _>>()?; + if let Some(unknown) = structural.iter().find(|k| !SPRINGS.contains(&k.as_str())) { + return Err(format!("structural.value: unknown spring {unknown}")); + } + let flags: Vec = + SPRINGS.iter().map(|k| structural.iter().any(|s| s == k).to_string()).collect(); + writeln!( + code, + "pub(crate) const STRUCTURAL: [bool; {}] = [{}];", + SPRINGS.len(), + flags.join(", ") + ) + .ok(); + + let subtle = number(m, &["bounce", "value", "subtle"], 0.0..=0.9)?; + if number(m, &["bounce", "value", "none"], 0.0..=0.0).is_err() { + return Err("bounce.value.none: must be 0 (critically damped)".into()); + } + let default_bounce = match at(m, &["bounce", "value", "default"])?.as_str() { + Some("none") => "None", + Some("subtle") => "Subtle", + _ => return Err("bounce.value.default: not none or subtle".into()), + }; + writeln!(code, "pub(crate) const SUBTLE_BOUNCE: f64 = {subtle:?};").ok(); + writeln!( + code, + "pub(crate) const DEFAULT_BOUNCE: MotionBounce = MotionBounce::{default_bounce};" + ) + .ok(); + + let kinds = + ["paneAnimations", "newTab", "newWorkspace"].map(|k| flag(m, &["kinds", "value", k])); + let [pane_animations, new_tab, new_workspace] = kinds; + writeln!( + code, + "pub(crate) const DEFAULT_KINDS: [bool; 3] = [{}, {}, {}];", + pane_animations?, new_tab?, new_workspace? + ) + .ok(); + + let geometry = number(m, &["settle", "value", "geometry"], 0.001..=4.0)?; + let unit = number(m, &["settle", "value", "unit"], 0.000_001..=0.1)?; + let factor = number(m, &["settle", "value", "velocityFactor"], 1.0..=100.0)?; + writeln!(code, "pub(crate) const SETTLE_GEOMETRY: f32 = {:?};", geometry as f32).ok(); + writeln!(code, "pub(crate) const SETTLE_UNIT: f32 = {:?};", unit as f32).ok(); + writeln!(code, "pub(crate) const SETTLE_VELOCITY_FACTOR: f32 = {:?};", factor as f32).ok(); + + code.push_str("pub(crate) const FADES: [f64; 12] = [\n"); + for key in FADES { + let seconds = number(m, &["fades", key, "value"], 0.0..=5.0)?; + writeln!(code, " {seconds:?}, // {key}").ok(); + } + code.push_str("];\n"); + writeln!(code, "pub(crate) const TWEENS: [TweenParameters; {}] = [", TWEENS.len()).ok(); + for key in TWEENS { + let duration = number(m, &["tweens", key, "value", "duration"], 0.0..=2.0)?; + let curve = match at(m, &["tweens", key, "value", "curve"])?.as_str() { + Some("easeOutQuad") => "EaseOutQuad", + _ => return Err(format!("tweens.{key}.value.curve: not easeOutQuad")), + }; + writeln!(code, " TweenParameters::new({duration:?}, TweenCurve::{curve}), // {key}") + .ok(); + } + code.push_str("];\n"); + for density in ["compact", "comfortable"] { + let g = |key: &str| number(m, &["tabStrip", "value", density, key], 0.0..=1000.0); + let c = |key: &str| { + number(m, &["tabStrip", "value", density, "closingMode", key], 0.0..=1000.0) + }; + writeln!( + code, + "pub(crate) const TAB_STRIP_{}: TabStripGeometry = TabStripGeometry {{ max_width: {:?}, open_start_width: {:?}, close_end_width: {:?}, min_active_width: {:?}, min_inactive_width: {:?}, pinned_width: {:?}, reorder_threshold: {:?}, closing_mode_below_pad: {:?}, closing_mode_trailing_pad: {:?}, closing_mode_exit_delay: {:?} }};", + density.to_ascii_uppercase(), + g("maxWidth")?, + g("openStartWidth")?, + g("closeEndWidth")?, + g("minActiveWidth")?, + g("minInactiveWidth")?, + g("pinnedWidth")?, + g("reorderThreshold")?, + c("belowPad")?, + c("trailingPad")?, + c("exitDelay")?, + ) + .ok(); + } + code.push_str("pub(crate) const LOOPS: [f64; 3] = [\n"); + for key in LOOPS { + let seconds = number(m, &["loops", key, "value"], 0.05..=10.0)?; + writeln!(code, " {seconds:?}, // {key}").ok(); + } + code.push_str("];\n"); + Ok(code) +} diff --git a/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs b/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs new file mode 100644 index 000000000000..25adfed7fcc1 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/examples/motion_trajectories.rs @@ -0,0 +1,133 @@ +//! Prints the motion parity corpus as JSON: every chrome spring token +//! resolved under bounce `none` and `subtle`, and the frame-by-frame path +//! of a 200 pt move (and a mid-flight retarget to 50 pt) at 60 and 120 Hz +//! until the spring rests. cmux-next macOS prints the same corpus from its +//! own spring (`debug.motion {action: "trajectory"}`); +//! `scripts/cmux-next/motion-parity.py RUST.json SWIFT.json` compares them. +//! Tween tokens (`tweens`) add the path of a 200 pt tween, of a retarget to +//! 50 pt after 5 frames (restarts from the value on screen) and of the same +//! target set again after 5 frames (no restart), at 60 and 120 Hz. +//! +//! `cargo run -p cmux-motion --example motion_trajectories` + +use cmux_motion::{ + AnimationSwitches, GEOMETRY_EPSILON, MotionBounce, MotionPolicy, MotionSpeed, MotionSpring, + MotionTween, SpringParameters, SpringValue, TweenParameters, TweenValue, +}; + +/// Tween tokens both ports have, by their spec name. +const TWEENS: [(&str, MotionTween); 3] = [ + ("tabOpen", MotionTween::TabOpen), + ("tabClose", MotionTween::TabClose), + ("tabMove", MotionTween::TabMove), +]; + +/// Frames of a tween from 0 toward 200 until it ends; after `at` frames +/// the target is set to `to` (50 restarts, 200 again does not). +fn tween_path(p: TweenParameters, hz: f64, retarget: Option<(usize, f64)>) -> Vec { + let mut s = TweenValue::new(0.); + s.retarget(200., p.duration); + let mut out = Vec::new(); + for frame in 0..(3. * hz) as usize { + if let Some((_, to)) = retarget.filter(|(at, _)| *at == frame) { + s.retarget(to, p.duration); + } + let moving = s.step(1. / hz, p.curve); + out.push(s.value as f32); + if !moving { + break; + } + } + out +} + +/// Tokens both ports have, by their spec name. +const TOKENS: [(&str, MotionSpring); 13] = [ + ("move", MotionSpring::Move), + ("appear", MotionSpring::Appear), + ("disappear", MotionSpring::Disappear), + ("settle", MotionSpring::Settle), + ("scroll", MotionSpring::Scroll), + ("screen", MotionSpring::Screen), + ("track", MotionSpring::Track), + ("selection", MotionSpring::Selection), + ("panel", MotionSpring::Panel), + ("pinDrag", MotionSpring::PinDrag), + ("zoom", MotionSpring::Zoom), + ("exit", MotionSpring::Exit), + ("tabOpen", MotionSpring::TabOpen), +]; + +/// Frames until rest (at most 3 s) from 0 toward 200, retargeting to +/// `retarget` after `at` frames when given. +fn path(p: SpringParameters, hz: f64, retarget: Option<(usize, f32)>) -> Vec { + let mut s = SpringValue::new(0.); + s.target = 200.; + let dt = 1. / hz; + let mut out = Vec::new(); + for frame in 0..(3. * hz) as usize { + if let Some((_, to)) = retarget.filter(|(at, _)| *at == frame) { + s.target = to; + } + let moving = s.advance(dt, p, GEOMETRY_EPSILON); + out.push(s.value); + if !moving { + break; + } + } + out +} + +fn list(values: &[f32]) -> String { + values.iter().map(|v| format!("{v:.4}")).collect::>().join(",") +} + +fn main() { + let mut tokens = Vec::new(); + let mut paths = Vec::new(); + for bounce in [MotionBounce::None, MotionBounce::Subtle] { + let policy = MotionPolicy::new(MotionSpeed::Fast, false) + .with_animations(AnimationSwitches { bounce, ..AnimationSwitches::DEFAULT }); + for (name, token) in TOKENS { + let p = policy.spring(token); + tokens.push(format!( + "{{\"token\":\"{name}\",\"bounce\":\"{}\",\"response\":{:.9},\"damping_fraction\":{:.9},\"visible_end\":{:.6}}}", + bounce.as_str(), + p.response, + p.damping_fraction, + p.visible_end() + )); + for hz in [60., 120.] { + for (case, retarget) in [("move", None), ("retarget", Some((5, 50.)))] { + paths.push(format!( + "{{\"token\":\"{name}\",\"bounce\":\"{}\",\"hz\":{hz},\"case\":\"{case}\",\"values\":[{}]}}", + bounce.as_str(), + list(&path(p, hz, retarget)) + )); + } + } + } + } + let mut tweens = Vec::new(); + let policy = MotionPolicy::new(MotionSpeed::Fast, false); + for (name, token) in TWEENS { + let p = token.base(); + let seconds = policy.tween(token); + for hz in [60., 120.] { + for (case, retarget) in + [("move", None), ("retarget", Some((5, 50.))), ("same", Some((5, 200.)))] + { + tweens.push(format!( + "{{\"token\":\"{name}\",\"duration\":{seconds:.9},\"hz\":{hz},\"case\":\"{case}\",\"values\":[{}]}}", + list(&tween_path(p, hz, retarget)) + )); + } + } + } + println!( + "{{\"port\":\"rust\",\"tokens\":[{}],\"paths\":[{}],\"tweens\":[{}]}}", + tokens.join(","), + paths.join(","), + tweens.join(",") + ); +} diff --git a/cmux-tui/crates/cmux-motion/src/fade.rs b/cmux-tui/crates/cmux-motion/src/fade.rs new file mode 100644 index 000000000000..3d1eb9d18e3c --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/fade.rs @@ -0,0 +1,161 @@ +//! Timed fades: Core Animation's ease curves and `Fade`, a timed opacity +//! or color change that retargets from the value on screen. + +use crate::spring::MAX_FRAME; +use crate::{MotionFade, MotionPolicy}; + +// --------------------------------------------------------------------------- +// Timed fades + +/// Control points of Core Animation's `easeOut` +/// (`CAMediaTimingFunction(name: .easeOut)`, the curve of cmux-next's +/// `Motion.fadeCurve`): a cubic Bezier from (0, 0) to (1, 1) through +/// (0, 0) and (0.58, 1). +pub const EASE_OUT_CONTROL_POINTS: [f64; 4] = [0., 0., 0.58, 1.]; + +/// Core Animation's `easeOut`, used by every timed fade: the progress at +/// time fraction `t` (0..=1). Solves the curve's x for `t` (Newton, then +/// bisection), then evaluates its y; within 1e-5 of `CAMediaTimingFunction` +/// (whose own solver stops at a coarser tolerance: 7e-6 at most here). +pub fn ease_out(t: f32) -> f32 { + let [x1, y1, x2, y2] = EASE_OUT_CONTROL_POINTS; + cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 +} + +/// Control points of Core Animation's `easeInEaseOut` +/// (`CAMediaTimingFunction(name: .easeInEaseOut)`): a cubic Bezier from +/// (0, 0) to (1, 1) through (0.42, 0) and (0.58, 1). cmux-next uses it for +/// the hover marquee's scroll (`Motion.marqueeAnimation`). +pub const EASE_IN_EASE_OUT_CONTROL_POINTS: [f64; 4] = [0.42, 0., 0.58, 1.]; + +/// Core Animation's `easeInEaseOut` at time fraction `t` (0..=1), solved +/// like `ease_out`; within 1e-5 of `CAMediaTimingFunction`. +pub fn ease_in_ease_out(t: f32) -> f32 { + let [x1, y1, x2, y2] = EASE_IN_EASE_OUT_CONTROL_POINTS; + cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 +} + +/// A CSS / Core Animation timing curve through (0, 0), (x1, y1), (x2, y2), +/// (1, 1) at time fraction `t` (0..=1; x1 and x2 within 0..=1). +fn cubic_bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { + if t <= 0. { + return 0.; + } + if t >= 1. { + return 1.; + } + // B(s) = 3(1-s)^2 s p1 + 3(1-s) s^2 p2 + s^3, as a polynomial in s. + let coefficients = |p1: f64, p2: f64| { + let c = 3. * p1; + let b = 3. * (p2 - p1) - c; + (1. - c - b, b, c) + }; + let (ax, bx, cx) = coefficients(x1, x2); + let (ay, by, cy) = coefficients(y1, y2); + let x = |s: f64| ((ax * s + bx) * s + cx) * s; + let dx = |s: f64| (3. * ax * s + 2. * bx) * s + cx; + let y = |s: f64| ((ay * s + by) * s + cy) * s; + const EPSILON: f64 = 1e-9; + let mut s = t; + for _ in 0..8 { + let error = x(s) - t; + if error.abs() < EPSILON { + return y(s); + } + let slope = dx(s); + if slope.abs() < 1e-12 { + break; + } + s -= error / slope; + } + // x is monotonic in s on 0..=1: bisection always converges. + let (mut lo, mut hi) = (0., 1.); + s = t; + for _ in 0..64 { + let v = x(s); + if (v - t).abs() < EPSILON { + break; + } + if v < t { + lo = s; + } else { + hi = s; + } + s = (lo + hi) / 2.; + } + y(s) +} + +/// A timed opacity or color change with ease-out. Retargeting restarts the +/// curve from the value on screen (as AppKit does for timed animators), so +/// a fade never jumps and never queues. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct Fade { + pub token: MotionFade, + from: f32, + target: f32, + value: f32, + elapsed: f64, + duration: f64, +} + +impl Fade { + pub fn new(value: f32, token: MotionFade) -> Self { + Self { token, from: value, target: value, value, elapsed: 0., duration: 0. } + } + + pub fn value(&self) -> f32 { + self.value + } + + pub fn target(&self) -> f32 { + self.target + } + + pub fn is_moving(&self) -> bool { + self.value != self.target + } + + /// Starts toward `target` from the current value with this token's + /// duration under `policy` (0 applies at once). + pub fn set_target(&mut self, target: f32, policy: &MotionPolicy) { + self.set_target_with(target, self.token, policy); + } + + /// As `set_target`, with another token (e.g. `fadeIn` vs `fadeOut`). + pub fn set_target_with(&mut self, target: f32, token: MotionFade, policy: &MotionPolicy) { + if target == self.target && self.is_moving() { + return; + } + self.token = token; + self.from = self.value; + self.target = target; + self.elapsed = 0.; + self.duration = policy.fade(token); + if self.duration <= 0. { + self.value = target; + } + } + + pub fn snap_to(&mut self, target: f32) { + self.from = target; + self.target = target; + self.value = target; + } + + /// Advances `dt` seconds. True while still moving. + pub fn step(&mut self, dt: f64) -> bool { + if !self.is_moving() { + return false; + } + self.elapsed += dt.clamp(0., MAX_FRAME); + if self.duration <= 0. || self.elapsed >= self.duration { + self.value = self.target; + return false; + } + let t = self.token.curve().progress((self.elapsed / self.duration) as f32); + self.value = self.from + (self.target - self.from) * t; + true + } +} diff --git a/cmux-tui/crates/cmux-motion/src/lib.rs b/cmux-tui/crates/cmux-motion/src/lib.rs index 56c1c81a1c26..9554bde5dbb5 100644 --- a/cmux-tui/crates/cmux-motion/src/lib.rs +++ b/cmux-tui/crates/cmux-motion/src/lib.rs @@ -18,21 +18,45 @@ //! read on macOS, Linux and Windows (`system_reduce_motion`; on Linux and //! Windows a watcher thread keeps it current). A `Spring` is a `SpringKind::Size` //! (a move to 0 uses `disappear`) unless built with `Spring::position` -//! (positions and offsets never switch token). The numbers in `spring.rs` -//! are the only animation timing constants; change them together with -//! `docs/motion.md`. +//! (positions and offsets never switch token). +//! +//! The token numbers (springs, fades, loops, speed, the bounce, the settle +//! thresholds, the structural set and the per-kind switch defaults) are read +//! at build time from the spec's `design-tokens.json` (`motion`; build.rs), +//! the same file cmux-next macOS and cmux-theme-tokens read. Springs step in +//! closed form (`SpringValue::solve`), so a path is exact at any frame rate. +//! Structural springs (`MotionSpring::is_structural`) follow +//! `ui.animations.bounce`; `ui.paneAnimations`, `ui.animations.newTab` and +//! `ui.animations.newWorkspace` turn one kind on (`MotionPolicy::animates`; +//! newTab on by default, the others off). The host passes these settings +//! in (`set_animation_switches` or `MotionPolicy::with_animations`). +//! +//! The tab strip moves like Chrome's: `Tween`s (`MotionTween`, 200 ms +//! `ease_out_quad`) that restart from the value on screen on a new target, +//! with the strip geometry in `TabStripGeometry` (design-tokens.json +//! `motion.tweens`, `motion.tabStrip`). mod clock; +mod fade; mod marquee; +mod policy; #[cfg(feature = "settings")] mod settings; mod spring; +mod tab_strip; +mod tokens; +mod tween; // The Reduce Motion reading off macOS (macOS asks NSWorkspace in settings.rs). #[cfg(all(feature = "settings", not(target_os = "macos")))] mod system; pub use clock::*; +pub use fade::*; pub use marquee::*; +pub use policy::*; #[cfg(feature = "settings")] pub use settings::*; pub use spring::*; +pub use tab_strip::*; +pub use tokens::*; +pub use tween::*; diff --git a/cmux-tui/crates/cmux-motion/src/policy.rs b/cmux-tui/crates/cmux-motion/src/policy.rs new file mode 100644 index 000000000000..699d3858a141 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/policy.rs @@ -0,0 +1,120 @@ +//! `MotionPolicy`: tokens resolved for one speed, Reduce Motion state, +//! bounce setting and set of per-kind switches. + +use crate::{ + AnimationSwitches, MotionBounce, MotionFade, MotionKind, MotionLoop, MotionSpeed, MotionSpring, + MotionTween, SpringParameters, +}; + +/// Resolves tokens for one speed setting, Reduce Motion state and set of +/// `ui.animations.*` switches. +/// +/// - `off`: nothing animates. Wins over Reduce Motion. +/// - Reduce Motion: movement (position, size, scale, scroll) is instant; +/// fades stay, capped at one crossfade (0.1 s); loops stop. +/// - `normal`: every time constant is 1.5x `fast`; damping is unchanged. +/// - Structural springs (`MotionSpring::is_structural`) are critically +/// damped with bounce `none` and take SwiftUI's subtle bounce with +/// `subtle`; a kind switched off (`animates(kind)` false) applies in one +/// frame, like Reduce Motion. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct MotionPolicy { + pub speed: MotionSpeed, + pub reduce_motion: bool, + /// Extra time multiplier for screen recordings (`CMUX2_ANIM_SCALE`); 1 + /// normally. Not part of the cmux-next contract. + pub debug_scale: f64, + /// `ui.animations.*`: per-kind switches and the bounce. + pub animations: AnimationSwitches, +} + +impl Default for MotionPolicy { + fn default() -> Self { + Self::new(MotionSpeed::Fast, false) + } +} + +impl MotionPolicy { + pub fn new(speed: MotionSpeed, reduce_motion: bool) -> Self { + Self { speed, reduce_motion, debug_scale: 1., animations: AnimationSwitches::DEFAULT } + } + + /// The same policy with these `ui.animations.*` settings. + pub fn with_animations(self, animations: AnimationSwitches) -> Self { + Self { animations, ..self } + } + + /// `kind` moves: movement animates and its `ui.animations.*` switch is + /// on. When false the change applies in one frame (no crossfade). + pub fn animates(&self, kind: MotionKind) -> bool { + self.animates_movement() && self.animations.enabled(kind) + } + + pub(crate) fn scale(&self) -> f64 { + self.speed.time_scale() * self.debug_scale.max(1.) + } + + /// Position, size, scale and scroll animate. When false, snap. + pub fn animates_movement(&self) -> bool { + self.speed != MotionSpeed::Off && !self.reduce_motion + } + + /// Opacity and color changes animate. When false, apply at once. + pub fn animates_fades(&self) -> bool { + self.speed != MotionSpeed::Off + } + + /// Spinners and pulses run. + pub fn animates_loops(&self) -> bool { + self.speed != MotionSpeed::Off && !self.reduce_motion + } + + /// The spring for `token`. `off` returns the `fast` spring so a caller + /// that steps anyway never divides by zero; callers snap when + /// `animates_movement` is false (`Spring::step` does). + pub fn spring(&self, token: MotionSpring) -> SpringParameters { + let mut base = token.base(); + if token.is_structural() { + // Bounce none: critically damped (never past the target); subtle: SwiftUI bounce. + base = match self.animations.bounce { + MotionBounce::None => { + SpringParameters::new(base.response, base.damping_fraction.max(1.)) + } + MotionBounce::Subtle => base.with_bounce(MotionBounce::Subtle.amount()), + }; + } + if self.speed == MotionSpeed::Off { base } else { base.scaled(self.scale()) } + } + + /// Seconds for a fade; 0 applies at once. Reduce Motion caps it at one + /// crossfade. + pub fn fade(&self, token: MotionFade) -> f64 { + if !self.animates_fades() { + return 0.; + } + let scaled = token.base() * self.scale(); + if self.reduce_motion { scaled.min(MotionFade::Crossfade.base()) } else { scaled } + } + + /// Seconds of a tween; 0 applies at once: speed `off`, Reduce Motion + /// (Chrome's strip animations are 0 ms under it) or the token's kind + /// switched off. `normal` is 1.5x. + pub fn tween(&self, token: MotionTween) -> f64 { + if !self.animates_movement() || token.kind().is_some_and(|k| !self.animations.enabled(k)) { + return 0.; + } + token.base().duration * self.scale() + } + + /// Seconds that stand in for a spring when only a timed API exists: + /// the spring's visible end. 0 when movement does not animate. + pub fn spring_duration(&self, token: MotionSpring) -> f64 { + if self.animates_movement() { self.spring(token).visible_end() } else { 0. } + } + + /// Period of a loop, or `None` when loops are stopped. + pub fn period(&self, token: MotionLoop) -> Option { + self.animates_loops().then(|| token.period()) + } +} diff --git a/cmux-tui/crates/cmux-motion/src/settings.rs b/cmux-tui/crates/cmux-motion/src/settings.rs index 91dab074d04d..75f8f2f055a3 100644 --- a/cmux-tui/crates/cmux-motion/src/settings.rs +++ b/cmux-tui/crates/cmux-motion/src/settings.rs @@ -1,7 +1,10 @@ -//! Process-wide motion settings: animation speed, Reduce Motion and the -//! screen-recording time scale. These are the crate's only global state: -//! two atomics behind documented setters (`set_speed`, -//! `set_reduce_motion_override`), plus environment values read once. +//! Process-wide motion settings: animation speed, Reduce Motion, the +//! `ui.animations.*` switches and the screen-recording time scale. These +//! are the crate's only global state: three atomics behind documented +//! setters (`set_speed`, `set_reduce_motion_override`, +//! `set_animation_switches`), plus environment values read once. The host +//! reads its own settings file and calls the setters; this crate never +//! reads a settings file. //! Callers that want no globals build a `MotionPolicy` themselves and pass //! it to `Spring::step` / `Animated::advance`; `policy()` is a convenience. @@ -10,11 +13,14 @@ use std::sync::{ atomic::{AtomicU8, Ordering}, }; -use crate::{MotionPolicy, MotionSpeed}; +use crate::{AnimationSwitches, MotionBounce, MotionPolicy, MotionSpeed}; const UNSET: u8 = u8::MAX; static SPEED: AtomicU8 = AtomicU8::new(UNSET); static REDUCE_OVERRIDE: AtomicU8 = AtomicU8::new(UNSET); +/// `AnimationSwitches` as bits (1 paneAnimations, 2 newTab, 4 newWorkspace, 8 +/// bounce subtle); `UNSET` until the host sets them (the spec defaults). +static SWITCHES: AtomicU8 = AtomicU8::new(UNSET); fn speed_code(s: MotionSpeed) -> u8 { match s { @@ -47,6 +53,31 @@ pub fn set_speed(s: MotionSpeed) { SPEED.store(speed_code(s), Ordering::Relaxed); } +/// Changes the `ui.animations.*` settings (per-kind switches and bounce) +/// for the whole process. Running animations pick them up on their next +/// frame. +pub fn set_animation_switches(switches: AnimationSwitches) { + let bits = u8::from(switches.pane_animations) + | (u8::from(switches.new_tab) << 1) + | (u8::from(switches.new_workspace) << 2) + | (u8::from(switches.bounce == MotionBounce::Subtle) << 3); + SWITCHES.store(bits, Ordering::Relaxed); +} + +/// The `ui.animations.*` settings: the last `set_animation_switches`, else +/// the spec defaults. +pub fn animation_switches() -> AnimationSwitches { + match SWITCHES.load(Ordering::Relaxed) { + UNSET => AnimationSwitches::DEFAULT, + bits => AnimationSwitches { + pane_animations: bits & 1 != 0, + new_tab: bits & 2 != 0, + new_workspace: bits & 4 != 0, + bounce: if bits & 8 != 0 { MotionBounce::Subtle } else { MotionBounce::None }, + }, + } +} + /// Pins Reduce Motion (tests, a future setting); `None` follows the system. pub fn set_reduce_motion_override(value: Option) { REDUCE_OVERRIDE.store(value.map_or(UNSET, u8::from), Ordering::Relaxed); @@ -100,7 +131,12 @@ fn debug_scale() -> f64 { }) } -/// The live policy: speed, Reduce Motion and the recording scale. +/// The live policy: speed, Reduce Motion, the `ui.animations.*` switches +/// and the recording scale. pub fn policy() -> MotionPolicy { - MotionPolicy { debug_scale: debug_scale(), ..MotionPolicy::new(speed(), reduce_motion()) } + MotionPolicy { + debug_scale: debug_scale(), + animations: animation_switches(), + ..MotionPolicy::new(speed(), reduce_motion()) + } } diff --git a/cmux-tui/crates/cmux-motion/src/spring.rs b/cmux-tui/crates/cmux-motion/src/spring.rs index 2ffb37ff3b54..e23261723531 100644 --- a/cmux-tui/crates/cmux-motion/src/spring.rs +++ b/cmux-tui/crates/cmux-motion/src/spring.rs @@ -1,14 +1,18 @@ -//! Tokens, the spring integrator, timed fades and the speed / Reduce Motion -//! policy. Pure and synchronous: the caller passes time, nothing here reads -//! a clock or a global. Every type is a plain `Copy` value with a C layout -//! (`repr(C)` structs, `repr(u8)` enums) so a C ABI can wrap it later. +//! Springs: tuning (`SpringParameters`), the closed-form damped harmonic +//! oscillator (`SpringValue`) and the token-driven `Spring` with direct +//! manipulation. Pure and synchronous: the caller passes time, nothing here +//! reads a clock or a global. Every type is a plain `Copy` value with a C +//! layout (`repr(C)` structs, `repr(u8)` enums) so a C ABI can wrap it. use std::f64::consts::PI; -/// Fixed substep of the spring integrator (s). Same as cmux-next. +use crate::tokens::{SETTLE_GEOMETRY, SETTLE_UNIT, SETTLE_VELOCITY_FACTOR}; +use crate::{MotionPolicy, MotionSpring}; + +/// Sampling step of the duration probes (`visible_end`) (s). const SUBSTEP: f64 = 1. / 480.; -/// A stalled frame never moves a spring more than this much time (s). -const MAX_FRAME: f64 = 0.1; +/// A stalled frame never moves a spring or a fade more than this much time (s). +pub(crate) const MAX_FRAME: f64 = 0.1; // --------------------------------------------------------------------------- // Springs @@ -44,11 +48,51 @@ impl SpringParameters { 2. * self.damping_fraction * self.omega() } + /// This tuning with a non-finite or non-positive response, or a + /// non-finite or non-positive damping fraction, replaced by + /// `fallback`'s. A damping of 0 or less (or NaN) is an undamped spring + /// that never settles, so the clock would never idle. + pub fn finite_or(self, fallback: SpringParameters) -> Self { + let response = if self.response.is_finite() && self.response > 0. { + self.response + } else { + fallback.response + }; + let damping_fraction = if self.damping_fraction.is_finite() && self.damping_fraction > 0. { + self.damping_fraction + } else { + fallback.damping_fraction + }; + Self::new(response, damping_fraction) + } + /// The same spring with every time constant multiplied by `scale`. pub fn scaled(&self, scale: f64) -> Self { Self::new(self.response * scale, self.damping_fraction) } + /// SwiftUI `Spring(duration:bounce:)`: `duration` is the perceptual + /// duration (the response); `bounce` 0 is critically damped, above 0 + /// overshoots (0.15 is a subtle bounce), below 0 is over-damped. + pub fn from_duration_bounce(duration: f64, bounce: f64) -> Self { + let damping_fraction = if bounce >= 0. { 1. - bounce } else { 1. / (1. + bounce) }; + Self::new(duration, damping_fraction) + } + + /// SwiftUI's bounce for this damping fraction (0 critically damped). + pub fn bounce(&self) -> f64 { + if self.damping_fraction <= 1. { + 1. - self.damping_fraction + } else { + 1. / self.damping_fraction - 1. + } + } + + /// The same response with `bounce` (SwiftUI semantics). + pub fn with_bounce(&self, bounce: f64) -> Self { + Self::from_duration_bounce(self.response, bounce) + } + /// Closed-form position of a unit step (0 -> 1, from rest) after `t` /// seconds. Used as an easing curve for GPUI's timed `Animation`. pub fn step_response(&self, t: f64) -> f64 { @@ -131,9 +175,10 @@ impl SpringParameters { /// Settle distance for geometry (pt): 0.25 pt is invisible after pixel /// snapping at 2x. Velocity must also be under 8x this (2 pt/s). -pub const GEOMETRY_EPSILON: f32 = 0.25; +/// (design-tokens.json `motion.settle`.) +pub const GEOMETRY_EPSILON: f32 = SETTLE_GEOMETRY; /// Settle distance for opacity and other 0..1 values. -pub const UNIT_EPSILON: f32 = 0.001; +pub const UNIT_EPSILON: f32 = SETTLE_UNIT; /// One scalar driven by a damped spring toward `target`. Retargeting keeps /// `value` and `velocity` (units per second), so an interrupted animation @@ -151,25 +196,58 @@ impl SpringValue { Self { value, velocity: 0., target: value } } - /// Advances `dt` seconds with fixed 1/480 s substeps (semi-implicit - /// Euler): stable at any frame rate. `dt` is capped at 0.1 s so a - /// stalled frame never teleports. + /// Advances `dt` seconds with the closed-form solution of the damped + /// harmonic oscillator (under-, critically or over-damped) from the + /// current displacement and velocity, so the path is exact and the same + /// at every frame rate: two 60 Hz frames land where four 120 Hz frames + /// do. `dt` is capped at 0.1 s so a stalled frame never teleports. pub fn step(&mut self, dt: f64, p: SpringParameters) { - let (k, c) = (p.stiffness(), p.damping()); - let mut remaining = dt.clamp(0., MAX_FRAME); - let (mut x, mut v, t) = (self.value as f64, self.velocity as f64, self.target as f64); - while remaining > 0. { - let h = remaining.min(SUBSTEP); - v += (-k * (x - t) - c * v) * h; - x += v * h; - remaining -= h; + let t = dt.clamp(0., MAX_FRAME); + // A NaN dt stays NaN through clamp: refuse it too. + if t.is_nan() || t <= 0. { + return; } - self.value = x as f32; + let target = f64::from(self.target); + let (x, v) = Self::solve(f64::from(self.value) - target, f64::from(self.velocity), t, p); + self.value = (target + x) as f32; self.velocity = v as f32; } + /// Displacement from the target and velocity `time` seconds after a + /// state with displacement `x0` and velocity `v0`, for `p` (mass 1). + /// The one spring solution every port uses (macOS calls it through + /// cmux-app-ffi). + pub fn solve(x0: f64, v0: f64, time: f64, p: SpringParameters) -> (f64, f64) { + let p = p.finite_or(MotionSpring::Move.base()); + let w0 = p.omega(); + let zeta = p.damping_fraction.max(0.); + let t = time; + if (zeta - 1.).abs() < 1e-4 { + let decay = (-w0 * t).exp(); + let b = v0 + w0 * x0; + return ((x0 + b * t) * decay, (v0 - w0 * b * t) * decay); + } + if zeta < 1. { + let wd = w0 * (1. - zeta * zeta).sqrt(); + let decay = (-zeta * w0 * t).exp(); + let (s, c) = (wd * t).sin_cos(); + let b = (v0 + zeta * w0 * x0) / wd; + return ( + decay * (x0 * c + b * s), + decay * (v0 * c - (w0 * w0 * x0 + zeta * w0 * v0) / wd * s), + ); + } + let root = (zeta * zeta - 1.).sqrt(); + let (r1, r2) = (-w0 * (zeta - root), -w0 * (zeta + root)); + let c2 = (v0 - r1 * x0) / (r2 - r1); + let c1 = x0 - c2; + let (e1, e2) = ((r1 * t).exp(), (r2 * t).exp()); + (c1 * e1 + c2 * e2, c1 * r1 * e1 + c2 * r2 * e2) + } + pub fn is_settled(&self, epsilon: f32) -> bool { - (self.value - self.target).abs() < epsilon && self.velocity.abs() < epsilon * 8. + (self.value - self.target).abs() < epsilon + && self.velocity.abs() < epsilon * SETTLE_VELOCITY_FACTOR } pub fn is_moving(&self) -> bool { @@ -201,6 +279,35 @@ impl SpringValue { } } +/// `SpringValue::advance` on f64 state, for hosts whose geometry is f64 +/// (macOS `CGFloat`, through cmux-app-ffi): steps `dt` seconds in closed +/// form and snaps to `target` once within `epsilon` and moving slower than +/// `epsilon` times the settle velocity factor. True while still moving. +pub fn advance_spring( + value: &mut f64, + velocity: &mut f64, + target: f64, + dt: f64, + p: SpringParameters, + epsilon: f64, +) -> bool { + if *value == target && *velocity == 0. { + return false; + } + let t = dt.clamp(0., MAX_FRAME); + if t > 0. { + let (x, v) = SpringValue::solve(*value - target, *velocity, t, p); + (*value, *velocity) = (target + x, v); + } + if (*value - target).abs() < epsilon + && velocity.abs() < epsilon * f64::from(SETTLE_VELOCITY_FACTOR) + { + (*value, *velocity) = (target, 0.); + return false; + } + true +} + /// What a `Spring` animates. It decides whether a move toward 0 is a /// collapse (faster `disappear`) or an ordinary move. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] @@ -365,477 +472,3 @@ impl Spring { } } } - -// --------------------------------------------------------------------------- -// Timed fades - -/// Control points of Core Animation's `easeOut` -/// (`CAMediaTimingFunction(name: .easeOut)`, the curve of cmux-next's -/// `Motion.fadeCurve`): a cubic Bezier from (0, 0) to (1, 1) through -/// (0, 0) and (0.58, 1). -pub const EASE_OUT_CONTROL_POINTS: [f64; 4] = [0., 0., 0.58, 1.]; - -/// Core Animation's `easeOut`, used by every timed fade: the progress at -/// time fraction `t` (0..=1). Solves the curve's x for `t` (Newton, then -/// bisection), then evaluates its y; within 1e-5 of `CAMediaTimingFunction` -/// (whose own solver stops at a coarser tolerance: 7e-6 at most here). -pub fn ease_out(t: f32) -> f32 { - let [x1, y1, x2, y2] = EASE_OUT_CONTROL_POINTS; - cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 -} - -/// Control points of Core Animation's `easeInEaseOut` -/// (`CAMediaTimingFunction(name: .easeInEaseOut)`): a cubic Bezier from -/// (0, 0) to (1, 1) through (0.42, 0) and (0.58, 1). cmux-next uses it for -/// the hover marquee's scroll (`Motion.marqueeAnimation`). -pub const EASE_IN_EASE_OUT_CONTROL_POINTS: [f64; 4] = [0.42, 0., 0.58, 1.]; - -/// Core Animation's `easeInEaseOut` at time fraction `t` (0..=1), solved -/// like `ease_out`; within 1e-5 of `CAMediaTimingFunction`. -pub fn ease_in_ease_out(t: f32) -> f32 { - let [x1, y1, x2, y2] = EASE_IN_EASE_OUT_CONTROL_POINTS; - cubic_bezier(f64::from(t.clamp(0., 1.)), x1, y1, x2, y2) as f32 -} - -/// A CSS / Core Animation timing curve through (0, 0), (x1, y1), (x2, y2), -/// (1, 1) at time fraction `t` (0..=1; x1 and x2 within 0..=1). -fn cubic_bezier(t: f64, x1: f64, y1: f64, x2: f64, y2: f64) -> f64 { - if t <= 0. { - return 0.; - } - if t >= 1. { - return 1.; - } - // B(s) = 3(1-s)^2 s p1 + 3(1-s) s^2 p2 + s^3, as a polynomial in s. - let coefficients = |p1: f64, p2: f64| { - let c = 3. * p1; - let b = 3. * (p2 - p1) - c; - (1. - c - b, b, c) - }; - let (ax, bx, cx) = coefficients(x1, x2); - let (ay, by, cy) = coefficients(y1, y2); - let x = |s: f64| ((ax * s + bx) * s + cx) * s; - let dx = |s: f64| (3. * ax * s + 2. * bx) * s + cx; - let y = |s: f64| ((ay * s + by) * s + cy) * s; - const EPSILON: f64 = 1e-9; - let mut s = t; - for _ in 0..8 { - let error = x(s) - t; - if error.abs() < EPSILON { - return y(s); - } - let slope = dx(s); - if slope.abs() < 1e-12 { - break; - } - s -= error / slope; - } - // x is monotonic in s on 0..=1: bisection always converges. - let (mut lo, mut hi) = (0., 1.); - s = t; - for _ in 0..64 { - let v = x(s); - if (v - t).abs() < EPSILON { - break; - } - if v < t { - lo = s; - } else { - hi = s; - } - s = (lo + hi) / 2.; - } - y(s) -} - -/// A timed opacity or color change with ease-out. Retargeting restarts the -/// curve from the value on screen (as AppKit does for timed animators), so -/// a fade never jumps and never queues. -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(C)] -pub struct Fade { - pub token: MotionFade, - from: f32, - target: f32, - value: f32, - elapsed: f64, - duration: f64, -} - -impl Fade { - pub fn new(value: f32, token: MotionFade) -> Self { - Self { token, from: value, target: value, value, elapsed: 0., duration: 0. } - } - - pub fn value(&self) -> f32 { - self.value - } - - pub fn target(&self) -> f32 { - self.target - } - - pub fn is_moving(&self) -> bool { - self.value != self.target - } - - /// Starts toward `target` from the current value with this token's - /// duration under `policy` (0 applies at once). - pub fn set_target(&mut self, target: f32, policy: &MotionPolicy) { - self.set_target_with(target, self.token, policy); - } - - /// As `set_target`, with another token (e.g. `fadeIn` vs `fadeOut`). - pub fn set_target_with(&mut self, target: f32, token: MotionFade, policy: &MotionPolicy) { - if target == self.target && self.is_moving() { - return; - } - self.token = token; - self.from = self.value; - self.target = target; - self.elapsed = 0.; - self.duration = policy.fade(token); - if self.duration <= 0. { - self.value = target; - } - } - - pub fn snap_to(&mut self, target: f32) { - self.from = target; - self.target = target; - self.value = target; - } - - /// Advances `dt` seconds. True while still moving. - pub fn step(&mut self, dt: f64) -> bool { - if !self.is_moving() { - return false; - } - self.elapsed += dt.clamp(0., MAX_FRAME); - if self.duration <= 0. || self.elapsed >= self.duration { - self.value = self.target; - return false; - } - let t = self.token.curve().progress((self.elapsed / self.duration) as f32); - self.value = self.from + (self.target - self.from) * t; - true - } -} - -// --------------------------------------------------------------------------- -// Tokens - -/// `ui.animationSpeed`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -#[repr(u8)] -pub enum MotionSpeed { - /// Default: short springs tuned for a pro tool. - #[default] - Fast, - /// About Apple's system pacing: every time constant x1.5. - Normal, - /// No animation: every change applies in one frame, no crossfade. - Off, -} - -impl MotionSpeed { - /// Multiplier on every spring response and fade duration. - pub fn time_scale(self) -> f64 { - match self { - Self::Fast => 1., - Self::Normal => 1.5, - Self::Off => 0., - } - } - - pub fn parse(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "fast" => Some(Self::Fast), - "normal" => Some(Self::Normal), - "off" | "none" | "0" => Some(Self::Off), - _ => None, - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Fast => "fast", - Self::Normal => "normal", - Self::Off => "off", - } - } -} - -/// Spring tokens (values at speed `fast`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionSpring { - /// An existing item moves or resizes: tab reflow and reorder, sidebar - /// rows, pane and divider frames, pane zoom, sidebar drag gap. - Move, - /// Something appears or expands: tab grow-in, group expand, row insert, - /// palette scale-in. - Appear, - /// Something disappears or collapses. Faster than `Appear`. - Disappear, - /// Release after direct manipulation; carries pointer velocity and - /// overshoots slightly (0.8 pt on 200 pt). - Settle, - /// Programmatic scroll: tab strip reveal, column reveal, wheel, fling. - Scroll, - /// Screen switch slide. - Screen, - /// An overlay tracking the pointer between targets: drop zones, ghost. - Track, - /// Selection indicator glide (sidebar pill). - Selection, - /// Floating panel slide (hover card). - Panel, - /// Home list pin drag: the tiles making room, the rows below the grid - /// and a dropped tile landing (MessagesLab `SidebarPinDragging.spring`: - /// `CASpringAnimation` mass 1, stiffness 320, damping 30). Overshoots - /// about 1.6 pt on 200 pt. - PinDrag, -} - -impl MotionSpring { - pub const ALL: [Self; 10] = [ - Self::Move, - Self::Appear, - Self::Disappear, - Self::Settle, - Self::Scroll, - Self::Screen, - Self::Track, - Self::Selection, - Self::Panel, - Self::PinDrag, - ]; - - pub const fn base(self) -> SpringParameters { - match self { - Self::Move => SpringParameters::new(0.20, 0.90), - Self::Appear => SpringParameters::new(0.18, 0.90), - Self::Disappear => SpringParameters::new(0.15, 0.90), - Self::Settle => SpringParameters::new(0.22, 0.85), - Self::Scroll => SpringParameters::new(0.22, 0.90), - Self::Screen => SpringParameters::new(0.22, 0.90), - Self::Track => SpringParameters::new(0.12, 0.90), - Self::Selection => SpringParameters::new(0.15, 0.90), - Self::Panel => SpringParameters::new(0.18, 0.85), - // SidebarPinDragging.swift `spring`: mass 1, stiffness 320, - // damping 30, so response 2 pi / sqrt(320) and damping fraction - // 30 / (2 sqrt(320)). - Self::PinDrag => { - SpringParameters::new(0.351_240_736_552_036_3, 0.838_525_491_562_421_1) - } - } - } -} - -/// Timed opacity and color changes (ease-out), seconds at speed `fast`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionFade { - /// Hover fills and hover-revealed buttons. - Hover, - /// Focus ring and inactive-pane dim. - Focus, - /// A view or panel fades in. - FadeIn, - /// A view or panel fades out. Faster than `FadeIn`. - FadeOut, - /// Content swap in place; also the Reduce Motion ceiling. - Crossfade, - /// Drag lift shadow. - Lift, - /// Theme switch recoloring in place. - Theme, - /// A highlighted row fading out after a jump to it (cmux-next: the - /// Settings row after a search jump or deep link). - Highlight, - /// The launch mark resolving on a window still connecting (cmux-next - /// `LaunchMarkView`, `Motion.revealLaunchMark`). - Launch, - /// Home list pin drag: the tile lifting to `SidebarPinDragState.lift` - /// (MessagesLab `beginPinDrag`, `transform.scale` over 0.15 s). - PinLift, - /// Home list pin drag: a dropped tile scaling back from the lift - /// (`landPinDrag`, `transform.scale` over 0.25 s). - PinSettle, - /// Home list pin drag: a ghost that is not a tile now shrinking onto - /// its row's avatar and fading out (`landPinDrag`, a 0.22 s transaction). - PinShrink, -} - -impl MotionFade { - pub const ALL: [Self; 12] = [ - Self::Hover, - Self::Focus, - Self::FadeIn, - Self::FadeOut, - Self::Crossfade, - Self::Lift, - Self::Theme, - Self::Highlight, - Self::Launch, - Self::PinLift, - Self::PinSettle, - Self::PinShrink, - ]; - - pub const fn base(self) -> f64 { - match self { - Self::Hover => 0.08, - Self::Focus => 0.10, - Self::FadeIn => 0.12, - Self::FadeOut => 0.08, - Self::Crossfade => 0.10, - Self::Lift => 0.12, - Self::Theme => 0.16, - // MotionTunables.swift:38-39 (fadeDefaults .highlight, .launch). - Self::Highlight => 1.2, - Self::Launch => 0.24, - // SidebarPinDragging.swift: lift.duration, settle.duration, - // setAnimationDuration (beginPinDrag, landPinDrag); the curves - // are `curve`. - Self::PinLift => 0.15, - Self::PinSettle => 0.25, - Self::PinShrink => 0.22, - } - } -} - -impl MotionFade { - /// The token's timing curve. Every fade eases out (cmux-next - /// `Motion.fadeCurve`) but MessagesLab's pin drag scales, which run - /// linear: `beginPinDrag`'s lift and `landPinDrag`'s settle are - /// `CABasicAnimation`s with no timing function (Core Animation paces - /// those linearly). `pinShrink` keeps ease-out: it is an implicit - /// animation in a `CATransaction` with no timing function set. - pub const fn curve(self) -> MotionCurve { - match self { - Self::PinLift | Self::PinSettle => MotionCurve::Linear, - _ => MotionCurve::EaseOut, - } - } -} - -/// A timed fade's curve (`MotionFade::curve`). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionCurve { - /// Core Animation's `easeOut` (`ease_out`). - EaseOut, - /// Core Animation's `linear` (a `CABasicAnimation` without a timing function). - Linear, -} - -impl MotionCurve { - /// The progress at time fraction `t` (0..=1). - pub fn progress(self, t: f32) -> f32 { - match self { - Self::EaseOut => ease_out(t), - Self::Linear => t.clamp(0., 1.), - } - } -} - -/// Repeating indicators. Speed does not scale them; `off` and Reduce Motion -/// stop them. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum MotionLoop { - Spinner, - Pulse, - Flash, -} - -impl MotionLoop { - pub const fn period(self) -> f64 { - match self { - Self::Spinner => 0.9, - Self::Pulse => 1.8, - Self::Flash => 0.6, - } - } -} - -/// Resolves tokens for one speed setting and Reduce Motion state. -/// -/// - `off`: nothing animates. Wins over Reduce Motion. -/// - Reduce Motion: movement (position, size, scale, scroll) is instant; -/// fades stay, capped at one crossfade (0.1 s); loops stop. -/// - `normal`: every time constant is 1.5x `fast`; damping is unchanged. -#[derive(Clone, Copy, Debug, PartialEq)] -#[repr(C)] -pub struct MotionPolicy { - pub speed: MotionSpeed, - pub reduce_motion: bool, - /// Extra time multiplier for screen recordings (`CMUX2_ANIM_SCALE`); 1 - /// normally. Not part of the cmux-next contract. - pub debug_scale: f64, -} - -impl Default for MotionPolicy { - fn default() -> Self { - Self::new(MotionSpeed::Fast, false) - } -} - -impl MotionPolicy { - pub fn new(speed: MotionSpeed, reduce_motion: bool) -> Self { - Self { speed, reduce_motion, debug_scale: 1. } - } - - pub(crate) fn scale(&self) -> f64 { - self.speed.time_scale() * self.debug_scale.max(1.) - } - - /// Position, size, scale and scroll animate. When false, snap. - pub fn animates_movement(&self) -> bool { - self.speed != MotionSpeed::Off && !self.reduce_motion - } - - /// Opacity and color changes animate. When false, apply at once. - pub fn animates_fades(&self) -> bool { - self.speed != MotionSpeed::Off - } - - /// Spinners and pulses run. - pub fn animates_loops(&self) -> bool { - self.speed != MotionSpeed::Off && !self.reduce_motion - } - - /// The spring for `token`. `off` returns the `fast` spring so a caller - /// that steps anyway never divides by zero; callers snap when - /// `animates_movement` is false (`Spring::step` does). - pub fn spring(&self, token: MotionSpring) -> SpringParameters { - if self.speed == MotionSpeed::Off { - token.base() - } else { - token.base().scaled(self.scale()) - } - } - - /// Seconds for a fade; 0 applies at once. Reduce Motion caps it at one - /// crossfade. - pub fn fade(&self, token: MotionFade) -> f64 { - if !self.animates_fades() { - return 0.; - } - let scaled = token.base() * self.scale(); - if self.reduce_motion { scaled.min(MotionFade::Crossfade.base()) } else { scaled } - } - - /// Seconds that stand in for a spring when only a timed API exists: - /// the spring's visible end. 0 when movement does not animate. - pub fn spring_duration(&self, token: MotionSpring) -> f64 { - if self.animates_movement() { self.spring(token).visible_end() } else { 0. } - } - - /// Period of a loop, or `None` when loops are stopped. - pub fn period(&self, token: MotionLoop) -> Option { - self.animates_loops().then(|| token.period()) - } -} diff --git a/cmux-tui/crates/cmux-motion/src/tab_strip.rs b/cmux-tui/crates/cmux-motion/src/tab_strip.rs new file mode 100644 index 000000000000..ccdad85a597b --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/tab_strip.rs @@ -0,0 +1,40 @@ +//! Tab strip geometry for the Chrome tab motion (design-tokens.json +//! `motion.tabStrip`, read at build time). The hosts lay out and animate +//! the strip; these are the shared numbers. + +use crate::tokens::{TAB_STRIP_COMFORTABLE, TAB_STRIP_COMPACT}; + +/// Points (pt) and, for the closing-mode delay, seconds. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct TabStripGeometry { + /// Widest an unpinned tab gets (the default of the host's max-width setting). + pub max_width: f64, + /// A new tab starts this wide at the previous tab's right edge minus this. + pub open_start_width: f64, + /// A closing tab ends this wide, its left edge pinned. + pub close_end_width: f64, + /// Crowding shrinks every tab evenly down to this; the active tab never goes below. + pub min_active_width: f64, + /// Then the inactive tabs shrink down to this (then the strip scrolls). + pub min_inactive_width: f64, + pub pinned_width: f64, + /// Pointer travel past a neighbour that reorders a dragged tab, at `max_width`. + pub reorder_threshold: f64, + /// Mouse closing mode ends once the pointer is this far below the strip ... + pub closing_mode_below_pad: f64, + /// ... or this far past its trailing edge ... + pub closing_mode_trailing_pad: f64, + /// ... and this many seconds have passed since it left. + pub closing_mode_exit_delay: f64, +} + +impl TabStripGeometry { + pub const COMPACT: Self = TAB_STRIP_COMPACT; + pub const COMFORTABLE: Self = TAB_STRIP_COMFORTABLE; + + /// The reorder threshold for a tab `tab_width` wide (16 x width / 200 by default). + pub fn reorder_threshold_at(&self, tab_width: f64) -> f64 { + if self.max_width > 0. { self.reorder_threshold * tab_width / self.max_width } else { 0. } + } +} diff --git a/cmux-tui/crates/cmux-motion/src/tokens.rs b/cmux-tui/crates/cmux-motion/src/tokens.rs new file mode 100644 index 000000000000..3701f738245a --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/tokens.rs @@ -0,0 +1,406 @@ +//! Tokens: speed, springs, fades, loops, the bounce setting and the +//! per-kind switches. The numbers come from the spec's design-tokens.json +//! (`motion`), compiled in by build.rs; no timing constant lives here. + +use crate::{SpringParameters, TabStripGeometry, TweenCurve, TweenParameters, ease_out}; + +// SPEED_NORMAL, SPRINGS, STRUCTURAL, SUBTLE_BOUNCE, DEFAULT_BOUNCE, +// DEFAULT_KINDS, SETTLE_*, FADES, TWEENS, TAB_STRIP_*, LOOPS. + +include!(concat!(env!("OUT_DIR"), "/tokens.rs")); + +// --------------------------------------------------------------------------- +// Tokens + +/// `ui.animationSpeed`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum MotionSpeed { + /// Default: short springs tuned for a pro tool. + #[default] + Fast, + /// About Apple's system pacing: every time constant x1.5. + Normal, + /// No animation: every change applies in one frame, no crossfade. + Off, +} + +impl MotionSpeed { + /// Multiplier on every spring response and fade duration. + pub fn time_scale(self) -> f64 { + match self { + Self::Fast => 1., + Self::Normal => SPEED_NORMAL, + Self::Off => 0., + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "fast" => Some(Self::Fast), + "normal" => Some(Self::Normal), + "off" | "none" | "0" => Some(Self::Off), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Fast => "fast", + Self::Normal => "normal", + Self::Off => "off", + } + } +} + +/// Spring tokens (values at speed `fast`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionSpring { + /// An existing item moves or resizes: tab reflow and reorder, sidebar + /// rows, pane and divider frames, pane zoom, sidebar drag gap. + Move, + /// Something appears or expands: tab grow-in, group expand, row insert, + /// palette scale-in. + Appear, + /// Something disappears or collapses. Faster than `Appear`. + Disappear, + /// Release after direct manipulation; carries pointer velocity and + /// overshoots slightly (0.8 pt on 200 pt). + Settle, + /// Programmatic scroll: tab strip reveal, column reveal, wheel, fling. + Scroll, + /// Screen switch slide. + Screen, + /// An overlay tracking the pointer between targets: drop zones, ghost. + Track, + /// Selection indicator glide (sidebar pill). + Selection, + /// Floating panel slide (hover card). + Panel, + /// Home list pin drag: the tiles making room, the rows below the grid + /// and a dropped tile landing (MessagesLab `SidebarPinDragging.spring`: + /// `CASpringAnimation` mass 1, stiffness 320, damping 30). Overshoots + /// about 1.6 pt on 200 pt. + PinDrag, + /// A pane zooming to fill its screen: it owns the motion, its content + /// takes the full size once and the spring rect reveals it (Leo + /// 2026-10-13). + Zoom, + /// The panes a zoom leaves fading out, faster than anything arrives + /// (macOS `Motion.exit(nearness:)` lengthens it up to 25% for the + /// panes nearer the zoomed one, so the farthest is gone first). + Exit, + /// A new tab growing in at its final slot while the tabs right of it + /// make room, on one spring (`ui.animations.newTab`). Structural. + TabOpen, +} + +impl MotionSpring { + pub const ALL: [Self; 13] = [ + Self::Move, + Self::Appear, + Self::Disappear, + Self::Settle, + Self::Scroll, + Self::Screen, + Self::Track, + Self::Selection, + Self::Panel, + Self::PinDrag, + Self::Zoom, + Self::Exit, + Self::TabOpen, + ]; + + /// The token's tuning at speed `fast` (design-tokens.json + /// `motion.springs`). Structural tokens are resolved further by + /// `MotionPolicy::spring` (the bounce setting). + pub const fn base(self) -> SpringParameters { + SPRINGS[self as usize] + } + + /// Structure: things that take or give up room (`move`, `appear`, + /// `disappear`; design-tokens.json `motion.structural`). These follow + /// the bounce setting (`MotionBounce`); release, scroll and overlay + /// springs keep their own damping. + pub const fn is_structural(self) -> bool { + STRUCTURAL[self as usize] + } +} + +/// Tween tokens (duration and curve at speed `fast`; design-tokens.json +/// `motion.tweens`): Chrome's tab strip motion, a 200 ms ease-out that +/// restarts from the value on screen when the target changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionTween { + /// A new tab opening at its slot from the previous tab's right edge, + /// and its neighbours and the + button making room + /// (`ui.animations.newTab`). + TabOpen, + /// A closing tab shrinking with its left edge pinned, its neighbours + /// closing the gap, and the resize when mouse closing mode ends + /// (`ui.animations.newTab`). + TabClose, + /// Tabs moving in a reorder or crowding change and a dragged tab + /// settling on release. A gesture: not under a kind switch. + TabMove, +} + +impl MotionTween { + pub const ALL: [Self; 3] = [Self::TabOpen, Self::TabClose, Self::TabMove]; + + /// Duration and curve at speed `fast` (design-tokens.json `motion.tweens`). + pub const fn base(self) -> TweenParameters { + TWEENS[self as usize] + } + + /// The `ui.animations.*` switch that turns this tween on (an off kind + /// applies the change at once), or none for a gesture. + pub const fn kind(self) -> Option { + match self { + Self::TabOpen | Self::TabClose => Some(MotionKind::NewTab), + Self::TabMove => None, + } + } +} + +/// Timed opacity and color changes (ease-out), seconds at speed `fast`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionFade { + /// Hover fills and hover-revealed buttons. + Hover, + /// Focus ring and inactive-pane dim. + Focus, + /// A view or panel fades in. + FadeIn, + /// A view or panel fades out. Faster than `FadeIn`. + FadeOut, + /// Content swap in place; also the Reduce Motion ceiling. + Crossfade, + /// Drag lift shadow. + Lift, + /// Theme switch recoloring in place. + Theme, + /// A highlighted row fading out after a jump to it (cmux-next: the + /// Settings row after a search jump or deep link). + Highlight, + /// The launch mark resolving on a window still connecting (cmux-next + /// `LaunchMarkView`, `Motion.revealLaunchMark`). + Launch, + /// Home list pin drag: the tile lifting to `SidebarPinDragState.lift` + /// (MessagesLab `beginPinDrag`, `transform.scale` over 0.15 s). + PinLift, + /// Home list pin drag: a dropped tile scaling back from the lift + /// (`landPinDrag`, `transform.scale` over 0.25 s). + PinSettle, + /// Home list pin drag: a ghost that is not a tile now shrinking onto + /// its row's avatar and fading out (`landPinDrag`, a 0.22 s transaction). + PinShrink, +} + +impl MotionFade { + pub const ALL: [Self; 12] = [ + Self::Hover, + Self::Focus, + Self::FadeIn, + Self::FadeOut, + Self::Crossfade, + Self::Lift, + Self::Theme, + Self::Highlight, + Self::Launch, + Self::PinLift, + Self::PinSettle, + Self::PinShrink, + ]; + + /// Seconds at speed `fast` (design-tokens.json `motion.fades`). + pub const fn base(self) -> f64 { + FADES[self as usize] + } +} + +impl MotionFade { + /// The token's timing curve. Every fade eases out (cmux-next + /// `Motion.fadeCurve`) but MessagesLab's pin drag scales, which run + /// linear: `beginPinDrag`'s lift and `landPinDrag`'s settle are + /// `CABasicAnimation`s with no timing function (Core Animation paces + /// those linearly). `pinShrink` keeps ease-out: it is an implicit + /// animation in a `CATransaction` with no timing function set. + pub const fn curve(self) -> MotionCurve { + match self { + Self::PinLift | Self::PinSettle => MotionCurve::Linear, + _ => MotionCurve::EaseOut, + } + } +} + +/// A timed fade's curve (`MotionFade::curve`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionCurve { + /// Core Animation's `easeOut` (`ease_out`). + EaseOut, + /// Core Animation's `linear` (a `CABasicAnimation` without a timing function). + Linear, +} + +impl MotionCurve { + /// The progress at time fraction `t` (0..=1). + pub fn progress(self, t: f32) -> f32 { + match self { + Self::EaseOut => ease_out(t), + Self::Linear => t.clamp(0., 1.), + } + } +} + +/// Repeating indicators. Speed does not scale them; `off` and Reduce Motion +/// stop them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionLoop { + Spinner, + Pulse, + Flash, +} + +impl MotionLoop { + /// Seconds per cycle (design-tokens.json `motion.loops`). + pub const fn period(self) -> f64 { + LOOPS[self as usize] + } +} + +/// `ui.animations.bounce`: how springy structural motion is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +#[repr(u8)] +pub enum MotionBounce { + /// Critically damped: a tab, row or pane never passes its target. The + /// default (design-tokens.json `motion.bounce.default`). + #[default] + None, + /// SwiftUI bounce `motion.bounce.subtle` (0.15): about 1% overshoot. + Subtle, +} + +impl MotionBounce { + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "subtle" => Some(Self::Subtle), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::Subtle => "subtle", + } + } + + /// SwiftUI bounce of a structural spring under this setting. + pub const fn amount(self) -> f64 { + match self { + Self::None => 0., + Self::Subtle => SUBTLE_BOUNCE, + } + } +} + +/// The structural changes a user turns animation on for one by one, under +/// `ui.animationSpeed`, one per cmux.json key (`MotionKind::config_path`): +/// `ui.paneAnimations`, `ui.animations.newTab`, `ui.animations.newWorkspace`. +/// newTab on by default, the others off (design-tokens.json `motion.kinds`); +/// an off kind applies the change in one frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MotionKind { + /// `ui.paneAnimations`: pane layout (split grow-in, close, move, swap, + /// zoom and unzoom, column reveal, screen switch, a divider released). + PaneAnimations, + /// A new tab growing into its slot (every entry point). + NewTab, + /// A new workspace row opening in the sidebar. + NewWorkspace, +} + +impl MotionKind { + pub const ALL: [Self; 3] = [Self::PaneAnimations, Self::NewTab, Self::NewWorkspace]; + + /// The setting's last key (`paneAnimations`, `newTab`, `newWorkspace`). + pub fn as_str(self) -> &'static str { + match self { + Self::PaneAnimations => "paneAnimations", + Self::NewTab => "newTab", + Self::NewWorkspace => "newWorkspace", + } + } + + /// The full cmux.json path of the kind's switch. + pub fn config_path(self) -> &'static str { + match self { + Self::PaneAnimations => "ui.paneAnimations", + Self::NewTab => "ui.animations.newTab", + Self::NewWorkspace => "ui.animations.newWorkspace", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|k| k.as_str() == s.trim()) + } +} + +/// The motion settings: one switch per `MotionKind` (`ui.paneAnimations`, +/// `ui.animations.newTab`, `ui.animations.newWorkspace`) and the bounce +/// (`ui.animations.bounce`). +/// The host reads its settings file and passes these in +/// (`set_animation_switches`, or `MotionPolicy::with_animations`); this +/// crate never reads a settings file. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(C)] +pub struct AnimationSwitches { + pub pane_animations: bool, + pub new_tab: bool, + pub new_workspace: bool, + pub bounce: MotionBounce, +} + +impl AnimationSwitches { + /// The spec defaults (design-tokens.json `motion.kinds`, `motion.bounce`). + pub const DEFAULT: Self = Self { + pane_animations: DEFAULT_KINDS[0], + new_tab: DEFAULT_KINDS[1], + new_workspace: DEFAULT_KINDS[2], + bounce: DEFAULT_BOUNCE, + }; + + /// `kind`'s switch is on. + pub const fn enabled(&self, kind: MotionKind) -> bool { + match kind { + MotionKind::PaneAnimations => self.pane_animations, + MotionKind::NewTab => self.new_tab, + MotionKind::NewWorkspace => self.new_workspace, + } + } + + /// The same switches with `kind` set to `on`. + pub fn with(mut self, kind: MotionKind, on: bool) -> Self { + match kind { + MotionKind::PaneAnimations => self.pane_animations = on, + MotionKind::NewTab => self.new_tab = on, + MotionKind::NewWorkspace => self.new_workspace = on, + } + self + } +} + +impl Default for AnimationSwitches { + fn default() -> Self { + Self::DEFAULT + } +} diff --git a/cmux-tui/crates/cmux-motion/src/tween.rs b/cmux-tui/crates/cmux-motion/src/tween.rs new file mode 100644 index 000000000000..9c42a18351d6 --- /dev/null +++ b/cmux-tui/crates/cmux-motion/src/tween.rs @@ -0,0 +1,190 @@ +//! Tweens: a value moving from where it is on screen to a target over a +//! fixed duration with a timing curve (Chrome's `views::BoundsAnimator` +//! model for the tab strip). A new target restarts the duration from the +//! current value, with no velocity carried over; setting the target it +//! already has does not restart it. Pure and synchronous like the springs. + +use crate::spring::MAX_FRAME; +use crate::{MotionPolicy, MotionTween}; + +/// Chrome's `gfx::Tween::EASE_OUT` (ui/gfx/animation/tween.cc): 1-(1-t)^2, +/// the same curve as `cubic-bezier(1/3, 2/3, 2/3, 1)`. `t` is clamped to +/// 0..=1. +pub fn ease_out_quad(t: f32) -> f32 { + let t = t.clamp(0., 1.); + 1. - (1. - t) * (1. - t) +} + +/// A tween's timing curve. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum TweenCurve { + /// `ease_out_quad` (Chrome `EASE_OUT`). + EaseOutQuad, +} + +impl TweenCurve { + /// The progress at time fraction `t` (clamped to 0..=1). + pub fn progress(self, t: f32) -> f32 { + match self { + Self::EaseOutQuad => ease_out_quad(t), + } + } + + /// `progress` in f64 (the C ABI and f64 hosts). + pub fn progress_f64(self, t: f64) -> f64 { + let t = t.clamp(0., 1.); + match self { + Self::EaseOutQuad => 1. - (1. - t) * (1. - t), + } + } +} + +/// A tween token's tuning: seconds and curve. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct TweenParameters { + pub duration: f64, + pub curve: TweenCurve, +} + +impl TweenParameters { + pub const fn new(duration: f64, curve: TweenCurve) -> Self { + Self { duration, curve } + } + + /// The same tween with its duration multiplied by `scale`. + pub fn scaled(&self, scale: f64) -> Self { + Self::new(self.duration * scale, self.curve) + } +} + +/// One scalar on a tween, in f64 (the state the C ABI passes). `from` is +/// where the running tween started, `elapsed` its time so far. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct TweenValue { + pub value: f64, + pub from: f64, + pub target: f64, + pub elapsed: f64, + pub duration: f64, +} + +impl TweenValue { + pub fn new(value: f64) -> Self { + Self { value, from: value, target: value, elapsed: 0., duration: 0. } + } + + /// Sets `target`. The same target as now changes nothing and returns + /// false (a running tween is not restarted). A new target restarts the + /// whole `duration` from the current value, with no velocity carried; + /// a duration that is not positive and finite applies it at once. + pub fn retarget(&mut self, target: f64, duration: f64) -> bool { + if target == self.target || target.is_nan() { + return false; + } + self.from = self.value; + self.target = target; + self.elapsed = 0.; + if duration.is_finite() && duration > 0. { + self.duration = duration; + } else { + self.snap(); + } + true + } + + pub fn is_moving(&self) -> bool { + self.value != self.target + } + + /// Jumps to the target and stops. + pub fn snap(&mut self) { + self.value = self.target; + self.from = self.target; + self.elapsed = 0.; + self.duration = 0.; + } + + pub fn snap_to(&mut self, target: f64) { + self.target = target; + self.snap(); + } + + /// Advances `dt` seconds (capped at 0.1 s; NaN and negative refused) on + /// `curve`. Ends exactly on the target. True while still moving. + pub fn step(&mut self, dt: f64, curve: TweenCurve) -> bool { + if !self.is_moving() { + return false; + } + let dt = dt.clamp(0., MAX_FRAME); + if dt.is_nan() { + return true; + } + self.elapsed += dt; + // 1e-9 s: twelve 1/60 s frames sum to just under 0.2 s and must end a 200 ms tween. + if self.duration <= 0. || self.elapsed >= self.duration - 1e-9 { + self.snap(); + return false; + } + let p = curve.progress_f64(self.elapsed / self.duration); + self.value = self.from + (self.target - self.from) * p; + true + } +} + +/// A scalar on a tween token (`MotionTween`), f32-facing like `Spring` and +/// `Fade`. The duration is resolved under the policy when the target is +/// set; Reduce Motion, speed off and a switched-off kind apply at once. +#[derive(Clone, Copy, Debug, PartialEq)] +#[repr(C)] +pub struct Tween { + pub state: TweenValue, + pub token: MotionTween, +} + +impl Tween { + pub fn new(value: f32, token: MotionTween) -> Self { + Self { state: TweenValue::new(f64::from(value)), token } + } + + pub fn value(&self) -> f32 { + self.state.value as f32 + } + + pub fn target(&self) -> f32 { + self.state.target as f32 + } + + pub fn is_moving(&self) -> bool { + self.state.is_moving() + } + + /// Starts toward `target` from the current value with this token's + /// duration under `policy`. The same target does not restart. + pub fn set_target(&mut self, target: f32, policy: &MotionPolicy) { + self.set_target_with(target, self.token, policy); + } + + /// As `set_target`, with another token (e.g. `TabClose` for a tab that + /// was opening). + pub fn set_target_with(&mut self, target: f32, token: MotionTween, policy: &MotionPolicy) { + if self.state.retarget(f64::from(target), policy.tween(token)) { + self.token = token; + } + } + + pub fn snap(&mut self) { + self.state.snap(); + } + + pub fn snap_to(&mut self, target: f32) { + self.state.snap_to(f64::from(target)); + } + + /// Advances `dt` seconds on the token's curve. True while still moving. + pub fn step(&mut self, dt: f64) -> bool { + self.state.step(dt, self.token.base().curve) + } +} diff --git a/cmux-tui/crates/cmux-remote/Cargo.toml b/cmux-tui/crates/cmux-remote/Cargo.toml index 9c03294b08d4..075d9e0c29fe 100644 --- a/cmux-tui/crates/cmux-remote/Cargo.toml +++ b/cmux-tui/crates/cmux-remote/Cargo.toml @@ -53,12 +53,9 @@ uuid.workspace = true x25519-dalek.workspace = true zeroize.workspace = true -[build-dependencies] -# This root package uses Cargo's Git-aware default fingerprint to notice new -# workspace-root files without recursively walking target/. -cmux-tui-source-watch = { path = "../.." } - [dev-dependencies] +# The tests bootstrap with this build's real identity, as the binary does. +cmux-build-identity.workspace = true cmux-relay = { path = "../cmux-relay" } cmux-unix-socket = { workspace = true, features = ["test-support"] } cmux-wg.workspace = true diff --git a/cmux-tui/crates/cmux-remote/src/ssh_artifacts.rs b/cmux-tui/crates/cmux-remote/src/ssh_artifacts.rs index 3e3aed237f99..59a127b521ca 100644 --- a/cmux-tui/crates/cmux-remote/src/ssh_artifacts.rs +++ b/cmux-tui/crates/cmux-remote/src/ssh_artifacts.rs @@ -305,12 +305,15 @@ const PUBLISHED_BASE_URL: &str = "https://files.cmux.com/cmux-tui"; /// cx-rppd.13). `Ok(None)` when this build ships no manifest (dev and source /// builds); a payload that does not match its pin is an error, never an /// unverified file. -pub async fn pinned_linux_payload(arch: &str) -> Result, BootstrapError> { +/// `identity` is the binary's `cmux_build_identity::BUILD_IDENTITY`. +pub async fn pinned_linux_payload( + arch: &str, + identity: &str, +) -> Result, BootstrapError> { let executable = std::env::current_exe().map_err(BootstrapError::Io)?; let cache = default_payload_cache(); let source = PayloadSource { base_url: PUBLISHED_BASE_URL, cache: cache.as_deref(), proxy_pac: None }; - let identity = crate::ssh_bootstrap::BUILD_IDENTITY; match payload(&executable, identity, "linux", arch, &source)? { None => Ok(None), Some(Payload::Bundled(path)) => { diff --git a/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs b/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs index 2792b08d230e..5e21900cc78e 100644 --- a/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs +++ b/cmux-tui/crates/cmux-remote/src/ssh_bootstrap.rs @@ -38,7 +38,6 @@ pub const DISTRIBUTION_VERSION: &str = match option_env!("CMUX_TUI_DISTRIBUTION_ None => env!("CARGO_PKG_VERSION"), }; pub const NPM_BOOTSTRAP_VERSION: Option<&str> = option_env!("CMUX_TUI_NPM_BOOTSTRAP_VERSION"); -pub const BUILD_IDENTITY: &str = env!("CMUX_TUI_BUILD_IDENTITY"); #[derive(Debug, Clone)] pub struct SshBootstrapConfig { @@ -73,7 +72,10 @@ pub struct SshBootstrapConfig { } impl SshBootstrapConfig { - pub fn defaults(destination: impl Into) -> Self { + /// `build_identity` is the binary's `cmux_build_identity::BUILD_IDENTITY`. + /// The caller passes it so that this crate does not recompile when the + /// identity changes (every source edit in a dev build). + pub fn defaults(destination: impl Into, build_identity: impl Into) -> Self { Self { ssh_binary: "ssh".into(), destination: destination.into(), @@ -83,7 +85,7 @@ impl SshBootstrapConfig { npm_package: "cmux".into(), package_version: NPM_BOOTSTRAP_VERSION.unwrap_or(DISTRIBUTION_VERSION).into(), package_installable: NPM_BOOTSTRAP_VERSION.is_some(), - build_identity: BUILD_IDENTITY.into(), + build_identity: build_identity.into(), local_binary: std::env::current_exe().ok(), payload_base_url: "https://files.cmux.com/cmux-tui".into(), payload_cache: crate::ssh_artifacts::default_payload_cache(), diff --git a/cmux-tui/crates/cmux-remote/tests/ssh_cross_platform_bootstrap.rs b/cmux-tui/crates/cmux-remote/tests/ssh_cross_platform_bootstrap.rs index 8120e9dfc53f..fee62a188dbb 100644 --- a/cmux-tui/crates/cmux-remote/tests/ssh_cross_platform_bootstrap.rs +++ b/cmux-tui/crates/cmux-remote/tests/ssh_cross_platform_bootstrap.rs @@ -1,7 +1,8 @@ #![cfg(unix)] +use cmux_build_identity::BUILD_IDENTITY; use cmux_remote::ssh_bootstrap::{ - BUILD_IDENTITY, BootstrapOutcome, DISTRIBUTION_VERSION, SshBootstrapConfig, SshBootstrapper, + BootstrapOutcome, DISTRIBUTION_VERSION, SshBootstrapConfig, SshBootstrapper, }; use cmux_remote_protocol::REMOTE_PROTOCOL_VERSION; use sha2::{Digest, Sha256}; @@ -87,7 +88,7 @@ esac installed = installed.display() ), ); - let mut config = SshBootstrapConfig::defaults("host"); + let mut config = SshBootstrapConfig::defaults("host", BUILD_IDENTITY); config.ssh_binary = script.to_string_lossy().into_owned(); config.package_installable = false; config.local_binary = Some(source); @@ -221,7 +222,7 @@ esac installed = installed.display() ), ); - let mut config = SshBootstrapConfig::defaults(host); + let mut config = SshBootstrapConfig::defaults(host, BUILD_IDENTITY); config.ssh_binary = script.to_string_lossy().into_owned(); config.package_installable = false; config.local_binary = Some(self.directory.path().join("app/cmux-tui")); @@ -559,7 +560,7 @@ esac installed = installed.display(), ), ); - let mut config = SshBootstrapConfig::defaults("host"); + let mut config = SshBootstrapConfig::defaults("host", BUILD_IDENTITY); config.ssh_binary = script.to_string_lossy().into_owned(); config.package_version = "9.9.9".into(); config.package_installable = true; @@ -722,7 +723,7 @@ exit 127 ), ); - let mut config = SshBootstrapConfig::defaults("host"); + let mut config = SshBootstrapConfig::defaults("host", BUILD_IDENTITY); config.ssh_binary = script.to_string_lossy().into_owned(); config.remote_binary = root.join("home/.local/bin/cmux-tui").to_string_lossy().into_owned(); config.package_version = "9.9.9".into(); diff --git a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs index 594fd3e5c473..70e32b89de13 100644 --- a/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs +++ b/cmux-tui/crates/cmux-tui-core/src/agent_hooks.rs @@ -73,11 +73,19 @@ pub fn agent_hook_journal_ingress( // Read before redaction: `tool_input` is redacted whole, and live // presence needs only its file target. let tool_target = tool_target_fields(&native); + // Read the structured reason before redaction replaces free-form strings. + let resumable_interruption = is_resumable_opencode_interruption(source, native_event, &native); let native = redact_agent_native(native_event, native); let mut normalized = normalized_fields(&native); normalized.extend(tool_target); add_agent_topology(source, native_event, terminal_id.as_ref(), &mut normalized); - let kind = semantic_kind(source, native_event, &normalized); + let kind = if resumable_interruption { + // OpenCode keeps a shutdown-interrupted execution and resumes it, so + // the turn is still running rather than completed. + "agent.state.changed" + } else { + semantic_kind(source, native_event, &normalized) + }; let mut subjects = Vec::with_capacity(4); if let Some(terminal_id) = terminal_id { subjects.push(JournalSubject { kind: "terminal".into(), id: terminal_id.to_string() }); @@ -414,6 +422,12 @@ fn semantic_kind( ("antigravity" | "hermes-agent", "sessionend" | "onsessionend") => "agent.turn.completed", ("opencode", "sessioncreated") => "agent.session.started", ("opencode", "sessionidle") => "agent.turn.completed", + ("opencode", "sessionexecutionsucceeded" | "sessionexecutioninterrupted") => { + "agent.turn.completed" + } + ("opencode", "sessionexecutionfailed" | "sessionnextstepfailed" | "sessionerror") => { + "agent.error.reported" + } ("opencode", "sessiondeleted") => "agent.session.ended", (_, event) if is_child_spawn(event) => "agent.child.spawned", (_, event) if is_child_completion(event) => "agent.child.completed", @@ -444,6 +458,17 @@ fn semantic_kind( } } +fn is_resumable_opencode_interruption(source: &str, native_event: &str, native: &Value) -> bool { + if source != "opencode" || semantic_key(native_event) != "sessionexecutioninterrupted" { + return false; + } + let event = native.get("event").unwrap_or(native); + ["data", "properties"] + .iter() + .filter_map(|key| event.get(key)) + .any(|properties| properties.get("reason").and_then(Value::as_str) == Some("shutdown")) +} + fn is_child_spawn(event: &str) -> bool { matches!( event, diff --git a/cmux-tui/crates/cmux-tui-core/src/agent_launch.rs b/cmux-tui/crates/cmux-tui-core/src/agent_launch.rs new file mode 100644 index 000000000000..22ed9cc706c5 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/agent_launch.rs @@ -0,0 +1,53 @@ +//! The build-session kill switch for agents (cx-9ettq, cx-tgf4h), as the +//! daemon and the CLI read it. A GUI build job runs with +//! `ACPMUX_AGENT_LAUNCH=deny` (or `CMUX_AGENTS_DISABLED=1`): acpmux starts no +//! agent (acpmux launch_policy.rs, the same test), so no Chief turn can run. +//! The daemon then starts no Chief brain and answers `chief.engine.*` and +//! `chief.stop` with [`refusal`]; `cmux chief` refuses to send at once instead +//! of waiting for a reply that cannot come. + +use serde_json::{Value, json}; + +/// The variable that names the policy; `deny` turns agents off. +pub const LAUNCH_ENV: &str = "ACPMUX_AGENT_LAUNCH"; +/// The app-wide switch; `1` turns agents off too. +pub const DISABLED_ENV: &str = "CMUX_AGENTS_DISABLED"; +/// `details.reason` of the refusal (acpmux's `data.reason`). +pub const REASON: &str = "agent_launch_disabled"; +/// What a refusal says (English; the CLI localizes its own copy). +pub const MESSAGE: &str = "agents are disabled in this build session (ACPMUX_AGENT_LAUNCH=deny or CMUX_AGENTS_DISABLED=1): the Chief does not start a turn"; + +/// Whether this process's environment turns agents off. Read at each call, +/// never cached. +pub fn disabled() -> bool { + disabled_by( + std::env::var(LAUNCH_ENV).ok().as_deref(), + std::env::var(DISABLED_ENV).ok().as_deref(), + ) +} + +/// The test of [`disabled`] on given values (acpmux launch_policy.rs). +pub fn disabled_by(launch: Option<&str>, agents_disabled: Option<&str>) -> bool { + launch.is_some_and(|v| v.trim().eq_ignore_ascii_case("deny")) + || agents_disabled.is_some_and(|v| v.trim() == "1") +} + +/// The variable that is set, for the refusal's details. +fn variable() -> &'static str { + if std::env::var(LAUNCH_ENV).is_ok_and(|v| v.trim().eq_ignore_ascii_case("deny")) { + LAUNCH_ENV + } else { + DISABLED_ENV + } +} + +/// The typed refusal as a protocol/2 `operation.failed` error object (the +/// CLI prints it as is); `message` is the caller's localized text. +pub fn refusal(message: &str) -> Value { + json!({ + "code": "operation.failed", + "message": message, + "details": {"reason": REASON, "variable": variable()}, + "retryable": false, + }) +} diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs b/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs index 8b9a88735301..a586476d3faf 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_brains.rs @@ -167,7 +167,12 @@ pub fn brain_environment( ) -> BTreeMap { let mux_home = brain_home(&config.home, chief); let app = &config.app; - let user_home = inherited("HOME").unwrap_or_else(|| "/".into()); + // The account's home, as the app gives the default Chief's host + // (`FileManager.homeDirectoryForCurrentUser`), not the HOME the daemon + // inherited: an app a job runner started (nx-remote on mac26: HOME is + // the job root) passed that on, and the brain then found no Claude + // settings env (the person's API route) in it (hq-6d P1 2026-10-14). + let user_home = account_home().or_else(|| inherited("HOME")).unwrap_or_else(|| "/".into()); let mut env = BTreeMap::new(); let mut put = |key: &str, value: String| { env.insert(key.to_owned(), value); @@ -236,6 +241,38 @@ pub fn brain_environment( env } +/// The home directory of this process's account in the user database +/// (getpwuid_r); None where there is none (or off Unix). +fn account_home() -> Option { + #[cfg(unix)] + { + let mut buffer = vec![0 as libc::c_char; 4096]; + let mut record = std::mem::MaybeUninit::::uninit(); + let mut found: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: every pointer is valid for the call and the buffer length + // is the buffer's length. + let rc = unsafe { + libc::getpwuid_r( + libc::getuid(), + record.as_mut_ptr(), + buffer.as_mut_ptr(), + buffer.len(), + &mut found, + ) + }; + if rc != 0 || found.is_null() { + return None; + } + // SAFETY: getpwuid_r succeeded, so pw_dir points into `buffer`. + let dir = unsafe { std::ffi::CStr::from_ptr((*found).pw_dir) }; + dir.to_str().ok().filter(|d| d.starts_with('/')).map(str::to_owned) + } + #[cfg(not(unix))] + { + None + } +} + /// What the supervisor hears. enum Signal { /// A committed message: its conversation and author. @@ -693,6 +730,18 @@ impl Supervisor { if is_default(&record) { return; } + // cx-tgf4h: no brain while agents are off in this build session (its + // turns could not start an agent; it would only hold the message). + if crate::agent_launch::disabled() { + let brain = self.brains.entry(chief.to_owned()).or_insert_with(Brain::new); + if !std::mem::replace(&mut brain.lock_noted, true) { + eprintln!( + "cmux-tui: Chief brain {chief} does not start: {}", + crate::agent_launch::MESSAGE + ); + } + return; + } let mux_home = brain_home(&self.config.home, chief); if !sockets_fit(&mux_home) { let brain = self.brains.entry(chief.to_owned()).or_insert_with(Brain::new); @@ -952,10 +1001,14 @@ fn remove_old_acpmux_homes(home: &Path, acpmux_bin: Option<&Path>) { let Some(user) = std::env::var_os("HOME").filter(|h| !h.is_empty()) else { return }; // NIGHTLY's Chief home, named by the build identity resolver. let nightly = BuildIdentity::new(Some(NIGHTLY_BUNDLE_ID), None); - let shared_default = - std::fs::canonicalize(nightly.chief_home(Path::new(&user), "default")).ok(); let Ok(canonical) = std::fs::canonicalize(home) else { return }; - if shared_default.as_deref() == Some(canonical.as_path()) { + // NIGHTLY's home under the inherited HOME and under the account's home + // (a job runner's HOME differs from it). + let defaults = [Some(PathBuf::from(user)), account_home().map(PathBuf::from)]; + if defaults.into_iter().flatten().any(|user| { + std::fs::canonicalize(nightly.chief_home(&user, "default")).ok().as_deref() + == Some(canonical.as_path()) + }) { return; } let Ok(entries) = std::fs::read_dir(home.join("c")) else { return }; diff --git a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs index c458495e3b26..a0d7334bd482 100644 --- a/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/chief_registry.rs @@ -88,6 +88,24 @@ pub(crate) enum Refusal { /// committed. KeyConflict(String), TooMany, + /// `chief.profile.adopt`: the participant is already a live Chief record. + AlreadyChief, + /// `chief.profile.adopt`: the id is a deleted Chief (its tombstone, and + /// its memory while kept); an adopt never revives it. + DeletedChief, + /// `chief.profile.adopt`: the default Chief is a record already. + DefaultChief, + /// `chief.profile.adopt`: no agent participant of a local conversation + /// has this id (or it is a person). + NotAgentParticipant, + /// `chief.profile.adopt`: another Chief (live or deleted) has the same + /// brain directory (`chief_brains::brain_home`: the id's last 8 + /// characters, lowercased), so adopting it would share or revive that + /// brain and its memory. + BrainHomeTaken, + /// `chief.profile.adopt`: a connection is bound to the participant (its + /// current agent session); two processes never speak as one participant. + ParticipantBound, } impl Refusal { @@ -99,6 +117,12 @@ impl Refusal { Self::RevMismatch(_) => "rev_mismatch", Self::KeyConflict(_) => "idempotency_conflict", Self::TooMany => "too_many_chiefs", + Self::AlreadyChief => "already_chief", + Self::DeletedChief => "deleted_chief", + Self::DefaultChief => "default_chief", + Self::NotAgentParticipant => "not_an_agent_participant", + Self::BrainHomeTaken => "brain_home_taken", + Self::ParticipantBound => "participant_bound", } } } @@ -473,10 +497,80 @@ pub(crate) fn create( let mut random = [0_u8; 10]; getrandom::fill(&mut random).map_err(|_| anyhow::anyhow!("chief id randomness"))?; let id = cmux_conversation::encode_id("agent_", now_ms, random); + insert_new(tx, &id, now_ms, fields).map(Ok) +} + +/// `chief.profile.adopt`'s write: a record for an existing agent participant +/// `id` (already checked to be one, and not `agent_mux`), with `create`'s +/// defaults, `display_name` unless `fields` names one, and only the given +/// fields. A live record or a tombstone of `id` refuses: an adopt never +/// revives a deleted Chief (or the memory kept for it). +pub(crate) fn adopt( + tx: &rusqlite::Transaction<'_>, + id: &str, + display_name: &str, + fields: &Map, + claim: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result> { + if id == DEFAULT_CHIEF { + return Ok(Err(Refusal::DefaultChief)); + } + let existing: Option> = + tx.query_row("SELECT record FROM chief WHERE id = ?1", [id], |r| r.get(0)).optional()?; + match existing { + Some(Some(_)) => return Ok(Err(Refusal::AlreadyChief)), + Some(None) => return Ok(Err(Refusal::DeletedChief)), + None => {} + } + // The brain directory is the id's last 8 characters (chief_brains.rs): + // a ULID's random bits for a created Chief, but any text for an adopted + // one. No two records, live or deleted, may share it. + let tail = |id: &str| id.get(id.len().saturating_sub(8)..).unwrap_or(id).to_ascii_lowercase(); + let mine = tail(id); + let taken = { + let mut statement = tx.prepare("SELECT id FROM chief")?; + let ids = statement.query_map([], |r| r.get::<_, String>(0))?; + let mut taken = false; + for other in ids { + if tail(&other?) == mine { + taken = true; + } + } + taken + }; + if taken { + return Ok(Err(Refusal::BrainHomeTaken)); + } + let live: i64 = + tx.query_row("SELECT COUNT(*) FROM chief WHERE record IS NOT NULL", [], |r| r.get(0))?; + if usize::try_from(live).unwrap_or(usize::MAX) >= MAX_CHIEFS { + return Ok(Err(Refusal::TooMany)); + } + // Last, inside this write: the participant must have no bound connection, + // and every credential issued for it is revoked (`claim`), so the old + // agent session never speaks as the Chief. The revoke commits in the + // conversation store; should the insert below then fail, the agent stays + // without a credential and no Chief is made (fails safe: the person mints + // a new credential). + if !claim()? { + return Ok(Err(Refusal::ParticipantBound)); + } + let mut fields = fields.clone(); + fields.entry("display_name").or_insert_with(|| Value::String(display_name.to_owned())); + insert_new(tx, id, now_ms(), &fields).map(Ok) +} + +/// A new live record `id` with `create`'s defaults and `fields` applied. +fn insert_new( + tx: &rusqlite::Transaction<'_>, + id: &str, + now_ms: u64, + fields: &Map, +) -> anyhow::Result { let seq: i64 = tx.query_row("SELECT COALESCE(MAX(seq), 0) + 1 FROM chief", [], |r| r.get(0))?; let now = cmux_conversation::format_rfc3339_millis(now_ms); let mut record = ChiefRecord { - id: id.clone(), + id: id.to_owned(), display_name: String::new(), role: String::new(), personality: String::new(), @@ -496,7 +590,7 @@ pub(crate) fn create( "INSERT INTO chief (id, seq, record) VALUES (?1, ?2, ?3)", params![id, seq, serde_json::to_string(&record)?], )?; - Ok(Ok(record)) + Ok(record) } /// `chief.profile.update`'s write: `fields` (already checked) on `id`, guarded by diff --git a/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs b/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs index 4c059f85b340..f108c270ed18 100644 --- a/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/conversation_store.rs @@ -273,6 +273,14 @@ impl ConversationStore { Ok(token) } + /// Removes `participant`'s credential (`chief.profile.adopt`): no token + /// issued for it before binds again; the next credential is minted anew. + pub(crate) fn revoke_agent_token(&mut self, participant: &str) -> anyhow::Result<()> { + self.connection + .execute("DELETE FROM agent_token WHERE participant = ?1", params![participant])?; + Ok(()) + } + /// Whether `token` is the current credential of `participant`. pub(crate) fn verify_agent_token( &mut self, diff --git a/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs b/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs index b1f106c05463..e1a0d0bdb049 100644 --- a/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs +++ b/cmux-tui/crates/cmux-tui-core/src/history_ops/sources.rs @@ -6,39 +6,37 @@ use cmux_history::{ClosedKind, HistoryEntry, HistoryKind}; use serde_json::Value; use super::trail::TrailEntry; +use crate::state::closed_history_query::{closed_page, closed_tabs}; /// The closed items worth reopening as entries, newest first, a page closed again once, at -/// its newest close (Leo 2026-10-14: Recently Closed is high signal). +/// its newest close (Leo 2026-10-14: Recently Closed is high signal; `closed_page`). pub(super) fn closed_entries(items: &[Value]) -> Vec { - let mut entries = items.iter().filter_map(closed_entry).collect::>(); - entries.sort_by_key(|entry| std::cmp::Reverse(entry.at_ms)); + let mut entries = items + .iter() + .filter_map(|item| Some((closed_entry(item)?, closed_page(item)))) + .collect::>(); + entries.sort_by_key(|(entry, _)| std::cmp::Reverse(entry.at_ms)); let mut seen = std::collections::HashSet::new(); - entries.retain(|entry| page_key(entry).is_none_or(|page| seen.insert(page.to_owned()))); - entries + entries.retain(|(_, page)| page.is_none_or(|page| seen.insert(page))); + entries.into_iter().map(|(entry, _)| entry).collect() } /// The closed groups the row `id` (`closed:daemon:`) stands for: its own, and for a /// page every other close of it, so removing the row leaves no older close to take its place. pub(super) fn closed_ids_of(items: &[Value], id: &str) -> Vec { - let entries = items.iter().filter_map(closed_entry).collect::>(); - let page = entries.iter().find(|entry| entry.id == id).and_then(page_key); + let row = |item: &Value| { + let closed = item.get("id").and_then(Value::as_str)?; + Some(format!("{}{closed}", super::closed::PREFIX)) + }; + let page = items.iter().find(|item| row(item).as_deref() == Some(id)).and_then(closed_page); match page { - Some(page) => entries - .iter() - .filter(|entry| page_key(entry) == Some(page)) - .map(|entry| entry.id.clone()) - .collect(), + Some(page) => { + items.iter().filter(|item| closed_page(item) == Some(page)).filter_map(row).collect() + } None => vec![id.to_owned()], } } -/// What a closed row is shown once by: a browser tab's address. A terminal, chat, screen, -/// workspace or window carries nothing that tells one close of it from another (two shells in -/// one folder), so each close stays its own row. -fn page_key(entry: &HistoryEntry) -> Option<&str> { - (entry.closed_kind == Some(ClosedKind::BrowserTab)).then_some(entry.url.as_deref()).flatten() -} - /// One closed-history item (`closed.list` shape) as an entry /// `closed:daemon:`. An item that reopens as nothing (only tabs /// `closed.list` marks `empty`: untouched New Tab pages, blank browsers) is @@ -90,24 +88,6 @@ fn closed_entry(item: &Value) -> Option { Some(entry) } -/// Every tab the item would recreate: every member's, else the top level's -/// (a v1 item), as the Swift `ClosedItem.tabs`. -fn closed_tabs(item: &Value) -> Vec<&Value> { - fn tabs_of(screens: Option<&Value>) -> Vec<&Value> { - screens - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(|screen| screen.get("tabs").and_then(Value::as_array)) - .flatten() - .collect() - } - match item.get("members").and_then(Value::as_array).filter(|members| !members.is_empty()) { - Some(members) => members.iter().flat_map(|member| tabs_of(member.get("screens"))).collect(), - None => tabs_of(item.get("screens")), - } -} - /// One trail entry as a location row (`location:`). The daemon /// cannot know whether the app reaches another machine, so every location /// reads available; the app greys offline ones itself. `current`: the newest diff --git a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs index a86996d08175..3cba57cdeaea 100644 --- a/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs +++ b/cmux-tui/crates/cmux-tui-core/src/journal_checkpoint.rs @@ -42,6 +42,19 @@ pub(crate) struct CapturedCheckpoint { } pub(crate) fn capture(mux: &Mux) -> anyhow::Result { + for _ in 0..MAX_CHECKPOINT_CAPTURE_ATTEMPTS { + if let Some(captured) = capture_attempt(mux)? { + return Ok(captured); + } + #[cfg(test)] + if let Some(hook) = tests::AFTER_RECOVERY_HOOK.with_borrow_mut(Option::take) { + hook(); + } + } + anyhow::bail!("terminal replay recovery did not settle during checkpoint capture") +} + +fn capture_attempt(mux: &Mux) -> anyhow::Result> { // Drain every output frame that reached ingress before the capture fence. // Per-terminal epochs below reject a parser snapshot taken while its // corresponding journal frame is still being enqueued. @@ -76,22 +89,14 @@ pub(crate) fn capture(mux: &Mux) -> anyhow::Result { "checkpoint contains more than {MAX_CHECKPOINT_TERMINALS} terminals" ); - let mut total_bytes = 0_u64; - let mut blobs = Vec::new(); - for terminal_id in terminal_ids { - let Some(surface) = mux.terminal_resource_surface(&terminal_id) else { continue }; - let blob = terminal_replay_blob(&surface, &terminal_id)?; - total_bytes = total_bytes - .checked_add(blob.reference.uncompressed_bytes) - .context("checkpoint content byte count overflow")?; - anyhow::ensure!( - total_bytes <= MAX_CHECKPOINT_UNCOMPRESSED_BYTES, - "checkpoint terminal content exceeds {MAX_CHECKPOINT_UNCOMPRESSED_BYTES} bytes" - ); - blobs.push(blob); + let (blobs, recovered) = capture_terminal_blobs(mux, &terminal_ids)?; + mux.flush_terminal_journal()?; + if recovered { + // CAN advances the journal. Start a complete new attempt so terminal + // IDs and extension manifests come from the new consistency cut too. + return Ok(None); } - mux.flush_terminal_journal()?; // Verify against one cut as well, so a write landing between two separate // head and cursor reads cannot fail a capture that was in fact stable. let (verify_snapshot, head_after) = @@ -102,7 +107,7 @@ pub(crate) fn capture(mux: &Mux) -> anyhow::Result { head_before == head_after && snapshot["cursor"] == cursor_after, "session changed during checkpoint capture" ); - Ok(CapturedCheckpoint { + Ok(Some(CapturedCheckpoint { source_sequence: head_after, state: json!({ "session_snapshot":snapshot, @@ -112,19 +117,44 @@ pub(crate) fn capture(mux: &Mux) -> anyhow::Result { }, }), blobs, - }) + })) +} + +fn capture_terminal_blobs( + mux: &Mux, + terminal_ids: &[TerminalPublicId], +) -> anyhow::Result<(Vec, bool)> { + let mut total_bytes = 0_u64; + let mut recovered = false; + let mut blobs = Vec::new(); + for terminal_id in terminal_ids { + let Some(surface) = mux.terminal_resource_surface(terminal_id) else { continue }; + let (blob, terminal_recovered) = terminal_replay_blob_with_recovery(&surface, terminal_id)?; + recovered |= terminal_recovered; + total_bytes = total_bytes + .checked_add(blob.reference.uncompressed_bytes) + .context("checkpoint content byte count overflow")?; + anyhow::ensure!( + total_bytes <= MAX_CHECKPOINT_UNCOMPRESSED_BYTES, + "checkpoint terminal content exceeds {MAX_CHECKPOINT_UNCOMPRESSED_BYTES} bytes" + ); + blobs.push(blob); + } + Ok((blobs, recovered)) } pub(crate) fn capture_error_is_retryable(error: &anyhow::Error) -> bool { - let message = error.to_string(); - [ - "session changed during checkpoint capture", - "session journal changed during checkpoint capture", - "terminal changed during replay capture", - "terminal journal ingress is unsettled during replay capture", - ] - .iter() - .any(|reason| message.contains(reason)) + error.chain().any(|cause| { + let message = cause.to_string(); + [ + "session changed during checkpoint capture", + "session journal changed during checkpoint capture", + "terminal changed during replay capture", + "terminal journal ingress is unsettled during replay capture", + ] + .iter() + .any(|reason| message.contains(reason)) + }) } /// Capture one terminal's bounded `cmux.vt-replay.v1` blob from its live @@ -135,6 +165,13 @@ pub(crate) fn terminal_replay_blob( surface: &crate::Surface, terminal_id: &TerminalPublicId, ) -> anyhow::Result { + terminal_replay_blob_with_recovery(surface, terminal_id).map(|(blob, _)| blob) +} + +fn terminal_replay_blob_with_recovery( + surface: &crate::Surface, + terminal_id: &TerminalPublicId, +) -> anyhow::Result<(JournalContentBlob, bool)> { let epoch_before = surface .terminal_journal_capture_epoch() .context("captured terminal is not a PTY surface")?; @@ -142,9 +179,10 @@ pub(crate) fn terminal_replay_blob( epoch_before & 1 == 0, "terminal journal ingress is unsettled during replay capture" ); - let (cols, rows, replay) = { + let (cols, rows, replay, recovered) = { let mut last_error = None; let mut captured = None; + let mut recovered = false; for budget in CHECKPOINT_REPLAY_RETRY_BUDGETS { let result = surface .try_with_terminal(|terminal| { @@ -168,14 +206,28 @@ pub(crate) fn terminal_replay_blob( } } } - captured.ok_or_else(|| { - anyhow::anyhow!( - "terminal {terminal_id} replay capture failed after retrying larger buffers: {}", - last_error - .map(|error| error.to_string()) - .unwrap_or_else(|| "buffer too small".into()) - ) - })? + if captured.is_none() { + let result = surface + .discard_unreplayable_replay_with(|terminal| { + terminal + .vt_replay_bounded(CHECKPOINT_REPLAY_RETRY_BUDGETS[0]) + .map(|replay| (terminal.cols(), terminal.rows(), replay)) + }) + .with_context(|| format!("terminal {terminal_id} replay recovery"))?; + if let Some(Ok(value)) = result { + captured = Some(value); + recovered = true; + } else { + return Err(anyhow::anyhow!( + "terminal {terminal_id} replay capture failed after retrying larger buffers: {}", + last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "buffer too small".into()) + )); + } + } + let (cols, rows, replay) = captured.expect("replay recovery must either capture or return"); + (cols, rows, replay, recovered) }; let epoch_after = surface .terminal_journal_capture_epoch() @@ -216,7 +268,7 @@ pub(crate) fn terminal_replay_blob( let digest = Sha256::digest(&uncompressed); let digest_hex = encode_hex(digest.as_slice()); let compressed = gzip_deterministic(&uncompressed)?; - JournalContentBlob::verified( + let blob = JournalContentBlob::verified( JournalContentRef { content_id: format!("jcontent_{digest_hex}"), terminal_id: terminal_id.as_str().into(), @@ -228,7 +280,8 @@ pub(crate) fn terminal_replay_blob( rows, }, compressed, - ) + )?; + Ok((blob, recovered)) } #[cfg(test)] @@ -796,6 +849,11 @@ mod tests { }; use std::sync::Arc; + thread_local! { + pub(super) static AFTER_RECOVERY_HOOK: std::cell::RefCell>> = + std::cell::RefCell::new(None); + } + fn terminal_replay_checkpoint() -> JournalCheckpoint { JournalCheckpoint { checkpoint_id: "checkpoint_terminal_replay".into(), diff --git a/cmux-tui/crates/cmux-tui-core/src/lib.rs b/cmux-tui/crates/cmux-tui-core/src/lib.rs index 63e06ecf0501..61749dcca5eb 100644 --- a/cmux-tui/crates/cmux-tui-core/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-core/src/lib.rs @@ -9,6 +9,7 @@ //! themselves, which is what makes the backend attachable. mod agent_hooks; +pub mod agent_launch; pub mod agent_message_prompt; pub mod agent_view; mod apps; @@ -179,9 +180,9 @@ pub use workspace_registry::{ JournalIngress, JournalProducer, JournalProducerManifest, JournalReplayPolicy, JournalSegment, JournalSensitivity, JournalSubject, PersistentSessionStateReset, PersistentSessionStateResetPreview, PersistentSessionStateResetter, ProjectionCommit, - RegistryCommit, RegistryEvent, RegistryQuarantined, RegistrySnapshot, RegistryWorkspace, - SessionJournalPage, SessionJournalRecord, UnsupportedWorkspaceRegistrySchema, - WorkspaceMutation, WorkspaceRegistry, + RegistryCommit, RegistryCompactError, RegistryCompaction, RegistryEvent, RegistryQuarantined, + RegistrySnapshot, RegistryWorkspace, SessionJournalPage, SessionJournalRecord, + UnsupportedWorkspaceRegistrySchema, WorkspaceMutation, WorkspaceRegistry, compact_registry, }; pub use cmux_remote_protocol::{REMOTE_CLIENT_MESSAGE_MAX_BYTES, REMOTE_SESSION_MESSAGE_MAX_BYTES}; diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs b/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs index e031c4f36ec6..cc7fe117b404 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/agent_roster_fold.rs @@ -179,19 +179,20 @@ impl Mux { } let mut deltas = Vec::new(); while host.cursor < commit.sequence { - let page = match registry.session_journal_after(host.cursor, 512) { + // Terminal output between agent events is skipped by the read: + // the roster ignores it, and this runs under the registry lock. + let (records, reached) = match registry.session_journal_without_output_after( + host.cursor, + commit.sequence, + 512, + ) { Ok(page) => page, Err(error) => { eprintln!("cmux-tui: reading agent journal tail failed: {error}"); return; } }; - if page.records.is_empty() { - break; - } - for record in - page.records.iter().take_while(|record| record.sequence <= commit.sequence) - { + for record in &records { let changes = host.roster.apply(&RosterEvent::from_record(record)); // Hooks already use the durable projector, which decides // events with the same session fence as this fold. @@ -202,8 +203,8 @@ impl Mux { { deltas.extend(changes); } - host.cursor = record.sequence; } + host.cursor = reached; } (deltas, host.cursor, host.roster.snapshot().to_string()) }; diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs b/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs index dbe67edac13d..66064e2a6099 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/conversations.rs @@ -99,6 +99,38 @@ impl Mux { Ok(()) } + /// Binds `client` to `participant` when `check` (the credential check) + /// passes, holding the bindings lock across both, so a credential revoked + /// by [`Mux::claim_unbound_participant`] never binds after it. Lock order: + /// bindings, then the conversation store. + pub(crate) fn bind_conversation_principal_if( + &self, + client: u64, + participant: String, + check: impl FnOnce() -> anyhow::Result, + ) -> anyhow::Result { + let mut bindings = lock_checked(&self.conversations.bindings, RelayLock::Bindings)?; + if !check()? { + return Ok(false); + } + bindings.insert(client, participant); + Ok(true) + } + + /// For `chief.profile.adopt`: when no connection is bound to + /// `participant`, revokes every credential issued for it and answers + /// true; a bound participant answers false and nothing changes. Under the + /// bindings lock, so no bind lands between the check and the revoke. + pub(crate) fn claim_unbound_participant(&self, participant: &str) -> anyhow::Result { + let bindings = lock_checked(&self.conversations.bindings, RelayLock::Bindings)?; + if bindings.values().any(|bound| bound == participant) { + return Ok(false); + } + self.with_conversations(|store| store.revoke_agent_token(participant))?; + drop(bindings); + Ok(true) + } + /// The bindings lock (tests poison it). #[cfg(test)] pub(crate) fn conversation_bindings(&self) -> &Mutex> { diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs b/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs index 7c89419d4dca..93d9ecf4a08a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/tab_groups.rs @@ -753,9 +753,6 @@ impl Mux { .iter() .map(|surface| tab_public_id(state, *surface)) .collect::>>()?; - if let Some(record) = edit.groups.groups.get(&group_id) { - edit.closed_group = Some(group_archive(state, &group_id, record, &tabs)); - } let panes = members .iter() .filter_map(|surface| state.pane_of(*surface)) diff --git a/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs b/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs index 84bf3665287c..2d3310f77b25 100644 --- a/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs +++ b/cmux-tui/crates/cmux-tui-core/src/mux/tab_strip.rs @@ -75,9 +75,6 @@ pub(crate) struct StripEdit { /// recorded as one closed-history group that forms it again /// (RECOVERABLE-BY-DEFAULT). pub(crate) ungrouped: Option, - /// A group that closes with its tabs (Close Group): its archive joins - /// the closed-history group of those tabs, whose reopen forms it again. - pub(crate) closed_group: Option, pub(crate) result: StripResult, } @@ -108,9 +105,11 @@ impl Mux { tab_state: Vec::new(), saved: None, ungrouped: None, - closed_group: None, result: StripResult::None, }; + // A screen, pane or workspace close leaves the rows of the + // groups it emptied: drop them before the edit sees them. + prune_tab_groups(&projected, &mut edit.groups); let result = mutate(&mux, &mut projected, &mut edit)?; Mux::rebuild_split_screen_index(&mut projected); prune_tab_groups(&projected, &mut edit.groups); @@ -200,8 +199,7 @@ fn strip_state_write( before: &crate::workspace_registry::PresentationSnapshot, edit: StripEdit, ) -> crate::resource_mutation::PlanStateWrite { - let StripEdit { groups, pins, tab_state, saved, ungrouped, closed_group, result: result_kind } = - edit; + let StripEdit { groups, pins, tab_state, saved, ungrouped, result: result_kind } = edit; let mut touched_tabs = BTreeSet::new(); for (tab, group) in &before.tab_groups.members { if groups.members.get(tab) != Some(group) { @@ -244,9 +242,6 @@ fn strip_state_write( archive, )?; } - if let Some(archive) = &closed_group { - crate::state::closed_history_store::attach_closed_tab_group(transaction, archive)?; - } write_tab_groups(transaction, &groups)?; for (tab, pinned, url) in &pins { set_tab_pinned(transaction, tab, *pinned, url.as_deref())?; diff --git a/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs b/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs index d1cb133f6380..12768b4024d7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs +++ b/cmux-tui/crates/cmux-tui-core/src/request_origin/page_access.rs @@ -355,6 +355,9 @@ const fn access(operation: Op) -> Access { | Op::ChiefProfileDelete | Op::ChiefProfileAvatarSet | Op::ChiefProfileAvatarGet + // Adopting an agent as a Chief: no page route at all (owner only, + // from a terminal; chief_registry_resource.rs refuses any page). + | Op::ChiefProfileAdopt // Avatars: a page reads them only through its allow list // (page-router.json: home-channels reads participant.avatar.get). | Op::ParticipantAvatarSet diff --git a/cmux-tui/crates/cmux-tui-core/src/resource.rs b/cmux-tui/crates/cmux-tui-core/src/resource.rs index 25d017189277..219182762b3c 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource.rs @@ -134,6 +134,8 @@ pub enum ResourceOperation { ChiefProfileGet, #[serde(rename = "chief.profile.create")] ChiefProfileCreate, + #[serde(rename = "chief.profile.adopt")] + ChiefProfileAdopt, #[serde(rename = "chief.profile.update")] ChiefProfileUpdate, #[serde(rename = "chief.profile.delete")] diff --git a/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs b/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs index d237b004bc83..5b77b5aa0602 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource/wire_name.rs @@ -54,6 +54,7 @@ impl ResourceOperation { Self::ChiefProfileList => "chief.profile.list", Self::ChiefProfileGet => "chief.profile.get", Self::ChiefProfileCreate => "chief.profile.create", + Self::ChiefProfileAdopt => "chief.profile.adopt", Self::ChiefProfileUpdate => "chief.profile.update", Self::ChiefProfileDelete => "chief.profile.delete", Self::ChiefProfileAvatarSet => "chief.profile.avatar.set", diff --git a/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs b/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs index cdda1a27a72e..223f15caa998 100644 --- a/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs +++ b/cmux-tui/crates/cmux-tui-core/src/resource_router/owner.rs @@ -295,6 +295,7 @@ pub(super) const fn operation_owner(operation: ResourceOperation) -> OperationOw | ResourceOperation::ChiefProfileList | ResourceOperation::ChiefProfileGet | ResourceOperation::ChiefProfileCreate + | ResourceOperation::ChiefProfileAdopt | ResourceOperation::ChiefProfileUpdate | ResourceOperation::ChiefProfileDelete | ResourceOperation::ChiefProfileAvatarSet diff --git a/cmux-tui/crates/cmux-tui-core/src/server.rs b/cmux-tui/crates/cmux-tui-core/src/server.rs index 5ef64846c92c..3690fd0f6251 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server.rs @@ -2585,282 +2585,13 @@ use resource_connection::{ handle_resource_connection_message, resource_stream_end, send_resource_stream_item, }; -/// One frame of a connection. `transport` is the connection's own value -/// (not a registry lookup), so a remote-entry connection always takes the -/// remote path, also after its registry record is gone. -fn handle_connection_frame( - mux: &Arc, - client: u64, - transport: ClientTransport, - message: &str, - writer: &MessageWriter, - scheduler: &Arc, -) -> bool { - // Keep an idle shutdown requester connected until owner cleanup closes - // the transport, so lifecycle clients receive authoritative completion. - // A pipelined message after the acknowledgement must not reach parsing or - // dispatch; returning false makes the connection loop close that client. - if mux.daemon_shutdown_requested() || mux.daemon_handoff_committed() { - return false; - } - if matches!(transport, ClientTransport::Remote) || mux.is_remote_client(client) { - return remote_relay::handle_frame(mux, client, message, writer); - } - // Before the acknowledgement the handoff can still fail (for example - // while `end_terminals` awaits every host) and this daemon keeps - // serving. Closing here would drop the requester's pending - // `shutdown-daemon` reply along with its connection, so a message that - // arrives meanwhile (a subscriber's snapshot refresh) is refused - // without being executed and the connection stays open. - if mux.daemon_handoff_in_progress() { - return pending_handoff::reject_message_during_pending_handoff(message, writer); - } - if let Some(request) = crate::resource_router::parse_resource_line(message) { - // A v2 line the catalog parse refuses may name a routed namespace - // operation (page-router-v1): the same JSON parse decides, so every - // spelling of the line gets one answer. - if request.is_err() - && let Some(keep_open) = page_router::try_handle(mux, client, message, writer) - { - return keep_open; - } - return origin_gate::handle_resource_line(mux, client, message, request, writer); - } - if let Some(keep_open) = loopback_forward::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = browser_runtime::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = agent_session_attach::try_handle(mux, client, message, writer) - .or_else(|| agent_transfer::try_handle(mux, client, message, writer)) - { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = apps::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = scripts::try_handle(mux, client, message, writer) { - return keep_open; - } - #[cfg(unix)] - if let Some(keep_open) = fs_wire::try_handle(mux, client, message, writer) { - return keep_open; - } - let request = match serde_json::from_str::(message) { - Ok(request) => request, - Err(error) => return send_bad_request(writer, message, &error), - }; - let mut pending = Some(request); - match scheduler.dispatch(mux.clone(), client, &mut pending, message.len(), writer.clone()) { - Some(keep_open) => keep_open, - None => handle_request(mux, client, pending.take().unwrap(), writer), - } -} - -fn handle_request(mux: &Arc, client: u64, request: Request, writer: &MessageWriter) -> bool { - handle_request_with_cancellation(mux, client, request, writer, None) -} - -fn handle_request_with_cancellation( - mux: &Arc, - client: u64, - request: Request, - writer: &MessageWriter, - cancellation: Option<&ConnectionCancellation>, -) -> bool { - let Request { id, cmd } = request; - if let Command::UrlOpen { terminal_id, url } = cmd { - return url_open::start(mux, client, id, terminal_id, url, writer); - } - if let Command::ChiefInspect(params) = cmd { - return chief_inspect::start(mux, client, id, params, writer); - } - if cloud_conversations::is_network(&cmd) { - return cloud_conversations::start(mux, client, id, cmd, writer); - } - if let Some(target) = cloud_conversations::subscribe_target(mux, &cmd) { - return cloud_conversations::subscribe_then_announce(mux, client, id, cmd, target, writer); - } - if matches!(&cmd, Command::ShutdownDaemon { .. } | Command::ReloadConfig) - && !mux.server_lifecycle_ready() - { - return send_request_error(writer, id, "server lifecycle is not ready"); - } - if let Command::VtState { surface } = &cmd { - return match send_vt_state_command_response(mux, id.clone(), *surface, writer) { - Ok(()) => true, - Err(error) => send_request_error(writer, id, &error.to_string()), - }; - } - - let detach_self = match &cmd { - Command::DetachClient { client: target, by, surface } - if target.whole_client() == Some(client) - && own_view_detach_target(mux, target, *surface).is_none() => - { - Some(detach_actor(mux, client, by.clone())) - } - _ => None, - }; - let shutdown_daemon = matches!(&cmd, Command::ShutdownDaemon { .. }); - let (mut reason, mut retryable, mut details) = (None, None, None); - let response = match handle_command_with_cancellation(mux, client, cmd, writer, cancellation) { - Ok(data) => Response { - id, - ok: true, - data: Some(data), - error: None, - error_code: None, - error_delivery: None, - }, - Err(error) => { - reason = conversations::error_reason(&error) - .or_else(|| cloud_conversations::error_reason(&error)); - retryable = cloud_conversations::error_retryable(&error); - details = renderer_grant::error_details(&error) - .or_else(|| conversations::error_details(&error)); - let error_code = response_error_code(&error); - let error_delivery = - error.downcast_ref::().map(|error| error.delivery); - Response { - id, - ok: false, - data: None, - error: Some(error.to_string()), - error_code, - error_delivery, - } - } - }; - let (response, reason) = remote_relay::redact_response(mux, client, response, reason); - let details = details.filter(|_| !mux.is_remote_client(client)); - let response_ok = response.ok; - let sent = responses::send_response_with_details(writer, response, reason, retryable, details); - // Flush the successful acknowledgement before making the owning loop - // leave, so process teardown cannot race the response writer. - if shutdown_daemon && response_ok { - if sent { - return complete_daemon_shutdown_after_ack(mux, client, writer); - } else { - mux.cancel_daemon_handoff(client); - } - } - if let Some(by) = detach_self - && response_ok - && sent - { - kick_client(mux, client, by); - return false; - } - sent -} - -fn send_vt_state_command_response( - mux: &Mux, - id: Option, - surface: SurfaceId, - writer: &MessageWriter, -) -> anyhow::Result<()> { - // Reserve the entire wire-frame allowance before copying a replay or - // starting its base64 encoder. The writer allocates only for actual - // output and releases unused logical quota before the response is queued. - let mut output = writer.render_service.reserved_control_writer()?; - let surface = get_surface(mux, surface)?; - require_pty(&surface)?; - let (cols, rows, replay) = surface.try_with_terminal(|terminal| { - terminal - .vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) - .map(|replay| (terminal.cols(), terminal.rows(), replay)) - })??; - - write_vt_state_command_json( - &mut output, - id.as_ref(), - cols, - rows, - &replay.self_contained_bytes(), - &replay.kitty_image_aliases, - replay.kitty_state, - )?; - writer.send_serialized_control(output.finish())?; - Ok(()) -} - -fn write_vt_state_command_json( - output: &mut BudgetedJsonWriter, - id: Option<&Value>, - cols: u16, - rows: u16, - replay: &[u8], - kitty_image_aliases: &[ghostty_vt::KittyImageAlias], - kitty_state: KittyReplayState, -) -> std::io::Result<()> { - output.write_all(b"{")?; - if let Some(id) = id { - output.write_all(b"\"id\":")?; - serde_json::to_writer(&mut *output, id).map_err(json_error_to_io)?; - output.write_all(b",")?; - } - write!(output, "\"ok\":true,\"data\":{{\"cols\":{cols},\"rows\":{rows},\"data\":\"")?; - { - let mut encoder = base64::write::EncoderWriter::new( - &mut *output, - &base64::engine::general_purpose::STANDARD, - ); - encoder.write_all(replay)?; - encoder.finish()?; - } - output.write_all(b"\",\"kitty_image_aliases\":")?; - write_kitty_image_aliases_json(output, kitty_image_aliases)?; - output.write_all(b",\"kitty_graphics_state\":")?; - write_kitty_replay_state_json(output, kitty_state)?; - output.write_all(b"}}")?; - Ok(()) -} - -fn auth_token(message: &str) -> Option { - let value: Value = serde_json::from_str(message).ok()?; - let object = value.as_object()?; - if object.len() != 1 { - return None; - } - let auth = object.get("auth")?.as_object()?; - if auth.len() != 1 { - return None; - } - auth.get("token")?.as_str().map(str::to_string) -} - -fn pairing_request(message: &str) -> bool { - let Ok(value) = serde_json::from_str::(message) else { return false }; - let Some(object) = value.as_object() else { return false }; - if object.len() != 1 { - return false; - } - let Some(pair) = object.get("pair").and_then(Value::as_object) else { return false }; - pair.len() == 1 && pair.get("request").and_then(Value::as_bool) == Some(true) -} - -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - let mut difference = a.len() ^ b.len(); - let length = a.len().max(b.len()); - for index in 0..length { - difference |= - usize::from(a.get(index).copied().unwrap_or(0) ^ b.get(index).copied().unwrap_or(0)); - } - difference == 0 -} - -fn zeroize_string(value: &mut str) { - // NUL remains valid UTF-8, so decoded control frames can be cleared in - // place immediately after dispatch. - value.zeroize(); -} +mod connection_frame; +#[cfg(test)] +use connection_frame::write_vt_state_command_json; +use connection_frame::{ + auth_token, constant_time_eq, handle_connection_frame, handle_request, + handle_request_with_cancellation, pairing_request, zeroize_string, +}; fn node_json(node: &Node, active_pane: PaneId) -> Value { match node { @@ -2973,874 +2704,8 @@ fn handle_command( handle_command_with_cancellation(mux, client, cmd, writer, None) } -fn handle_command_with_cancellation( - mux: &Arc, - client: u64, - cmd: Command, - writer: &MessageWriter, - cancellation: Option<&ConnectionCancellation>, -) -> anyhow::Result { - // Who a durable legacy command acts as (P8): read once per command. - let actor = origin_gate::connection_actor(mux, client); - if let Some(remote) = remote_relay::intercept(mux, client, &cmd, writer) { - return remote; - } - match cmd { - Command::SubscribeActivity => cmd_subscribe::subscribe_activity(mux, client, writer), - cmd @ (Command::UrlOpenSubscribe { .. } - | Command::UrlOpenClaim { .. } - | Command::UrlOpenResult { .. }) => url_open::handle(mux, client, cmd, writer), - cmd @ (Command::TerminalClipboardSubscribe { .. } - | Command::TerminalClipboardReply { .. }) => { - clipboard_read::handle(mux, client, cmd, writer) - } - Command::UrlOpen { .. } => { - anyhow::bail!("URL opening requires the asynchronous request path") - } - Command::ChiefInspect(_) => { - anyhow::bail!("chief-inspect requires the asynchronous request path") - } - Command::PasteImage { - surface, - terminal_id, - lease, - upload_id, - op, - mime, - size, - offset, - data, - } => cmd_terminal_io::paste_image( - mux, - client, - surface, - terminal_id, - lease, - upload_id, - op, - mime, - size, - offset, - data, - ), - command @ (Command::SetTerminalCommandHistory(_) - | Command::ListTerminalCommands(_) - | Command::DeleteTerminalCommands(_)) => command_history::handle(mux, client, command), - Command::ServerStats { include } => cmd_server::server_stats(mux, client, include), - Command::BrowserHostProvider => cmd_browser::browser_host_provider(mux, client), - Command::Identify => cmd_server::identify(mux), - Command::ShutdownDaemon { pid, generation, force, end_terminals, keep_layout } => { - cmd_server::shutdown_daemon( - mux, - client, - pid, - generation, - force, - end_terminals, - keep_layout, - ) - } - Command::Ping => cmd_server::ping(), - Command::SetClientInfo { - name, - kind, - capabilities, - user_id, - display_name, - device_kind, - device_name, - device_id, - } => cmd_server::set_client_info( - mux, - client, - name, - kind, - capabilities, - user_id, - display_name, - device_kind, - device_name, - device_id, - ), - Command::ListClients => cmd_server::list_clients(mux, client), - Command::MachineUsage => cmd_server::machine_usage(mux), - Command::MachineListeningTcp => cmd_server::machine_listening_tcp(), - Command::RegisterBrowserProvider { - provider_id, - endpoint, - authentication, - bearer_token, - hosting, - targets, - } => cmd_browser::register_browser_provider( - mux, - client, - cmd_browser::BrowserProviderRequest { - provider_id, - endpoint, - authentication, - bearer_token, - hosting, - targets, - }, - ), - Command::GetBrowserProvider => cmd_browser::get_browser_provider(mux, client), - Command::UnregisterBrowserProvider => cmd_browser::unregister_browser_provider(mux, client), - Command::ListTerminals => cmd_terminals::list_terminals(mux), - Command::TerminalEvents { after_revision } => { - cmd_terminals::terminal_events(mux, after_revision) - } - Command::SetClientSizing { surface, client: target, enabled, exclusive } => { - cmd_sizing::set_client_sizing(mux, client, surface, target, enabled, exclusive) - } - Command::PairingResponse { request, approve } => { - cmd_server::pairing_response(mux, client, request, approve) - } - Command::DetachClient { client: target, by, surface } => { - cmd_attach::detach_client(mux, client, target, by, surface) - } - Command::SetSizePolicy { surface, workspace, policy } => { - cmd_sizing::set_size_policy(mux, client, surface, workspace, policy) - } - Command::SetSizeCounts { surface, client: target, lease, view, participant, counts } => { - cmd_sizing::set_size_counts( - mux, - client, - surface, - target, - lease, - view, - participant, - counts, - ) - } - Command::NoteSizeActivity { surface, view } => { - cmd_sizing::note_size_activity(mux, client, surface, view) - } - Command::ReattachView { surface, counts } => { - cmd_attach::reattach_view(mux, client, surface, counts) - } - Command::GetSizeState { surface } => cmd_sizing::get_size_state(mux, client, surface), - Command::ReloadConfig => cmd_server::reload_config(mux), - Command::SetWindowTitle { title } => cmd_server::set_window_title(mux, title), - Command::ClearWindowTitle => cmd_server::clear_window_title(mux), - Command::ListWorkspaces => cmd_workspaces::list_workspaces(mux), - Command::GetFrontendProjection { frontend, scope, subject_key } => { - cmd_frontend::get_frontend_projection(mux, frontend, scope, subject_key) - } - Command::PutFrontendProjection { - frontend, - scope, - subject_key, - schema_version, - expected_projection_revision, - projection, - mutation, - } => cmd_frontend::put_frontend_projection( - mux, - client, - frontend, - scope, - subject_key, - schema_version, - expected_projection_revision, - projection, - mutation, - ), - Command::JournalFrontendEvent { event } => { - cmd_frontend::journal_frontend_event(mux, client, event) - } - Command::ExportLayout { screen } => cmd_panes::export_layout(mux, screen), - Command::ApplyLayout { workspace, name, layout, cols, rows } => { - cmd_panes::apply_layout(mux, actor, workspace, name, layout, cols, rows) - } - Command::Send { surface, terminal_id, text, bytes, paste } => { - cmd_terminal_io::send(mux, client, writer, surface, terminal_id, text, bytes, paste) - } - Command::ReadScreen { surface } => cmd_terminal_io::read_screen(mux, surface), - Command::ClearHistory { surface, fallback_key } => { - cmd_terminal_io::clear_history(mux, surface, fallback_key) - } - Command::ReadScrollback { surface, start, count } => { - cmd_terminal_io::read_scrollback(mux, surface, start, count) - } - Command::SidebarPlugin { cols, rows, relaunch } => { - cmd_frontend::sidebar_plugin(mux, cols, rows, relaunch) - } - Command::WaitFor { surface, pattern, timeout_ms } => { - cmd_terminal_io::wait_for(mux, cancellation, surface, pattern, timeout_ms) - } - Command::Run { argv, command, cwd, pane, new_workspace, key, name, cols, rows } => { - cmd_terminal_io::run( - mux, - actor, - argv, - command, - cwd, - pane, - new_workspace, - key, - name, - cols, - rows, - ) - } - Command::CreateSurfaceWithReceipt(request) => { - cmd_frontend::create_surface_with_receipt_command(mux, client, request) - } - Command::SendKey { surface, keys } => cmd_terminal_io::send_key(mux, client, surface, keys), - Command::Copy { surface, mode } => cmd_terminal_io::copy(mux, surface, mode), - Command::Ids { kind } => cmd_frontend::ids(mux, kind), - Command::Notify { title, body, level, surface, source } => { - cmd_frontend::notify(mux, actor, title, body, level, surface, source) - } - Command::ListAgents { surface, state } => cmd_frontend::list_agents(mux, surface, state), - Command::ReportAgent { surface, state, source, session } => { - cmd_frontend::report_agent(mux, surface, state, source, session) - } - Command::VtState { .. } => unreachable!("vt-state uses its streaming response path"), - Command::MintTerminalRenderer { surface, ttl_ms } => { - cmd_terminals::mint_terminal_renderer(mux, client, surface, ttl_ms) - } - Command::MintTerminalRendererByTerminal { terminal, ttl_ms } => { - cmd_terminals::mint_terminal_renderer_by_terminal(mux, client, terminal, ttl_ms) - } - Command::ResolveTerminal { terminal_id } => { - cmd_terminals::resolve_terminal(mux, terminal_id) - } - Command::CloseTerminal { terminal_id, terminal_incarnation, mutation } => { - cmd_terminals::close_terminal(mux, client, terminal_id, terminal_incarnation, mutation) - } - Command::SetTerminalIdlePolicy { surface, terminal_id, idle_close_seconds } => { - cmd_terminals::set_terminal_idle_policy(mux, surface, terminal_id, idle_close_seconds) - } - Command::SetTerminalKeep { surface, terminal_id, keep } => { - cmd_terminals::set_terminal_keep(mux, surface, terminal_id, keep) - } - Command::NewTab { pane, cwd, env, cols, rows, keep, terminal_id, shell_args } => { - cmd_tabs::new_tab( - mux, - client, - actor, - pane, - cwd, - env, - cols, - rows, - keep, - terminal_id, - shell_args, - ) - } - Command::NewConversationTab(params) => cmd_tabs::new_conversation_tab(mux, client, params), - Command::BindConversationTabSession(params) => { - cmd_tabs::bind_conversation_tab_session(mux, actor, params) - } - Command::RestartTab(params) => tab_restart::restart(mux, params), - Command::NewFrontendBrowserTab(params) => { - cmd_tabs::new_frontend_browser_tab(mux, client, params) - } - Command::UpdateFrontendBrowserTab(params) => { - cmd_tabs::update_frontend_browser_tab(mux, actor, params) - } - Command::SetFrontendBrowserHistory(params) => frontend_browser_history::set(mux, params), - Command::GetFrontendBrowserHistory(params) => frontend_browser_history::get(mux, params), - Command::NewRemoteTerminalTab(p) => remote_terminal_tabs_wire::create(mux, &actor, p), - Command::UpdateRemoteTerminalTab(p) => remote_terminal_tabs_wire::update(mux, p), - Command::RemoteTerminalSnapshot(p) => remote_terminal_tabs_wire::snapshot(mux, p), - Command::NewBrowserTab { url, pane, cols, rows } => { - cmd_tabs::new_browser_tab(mux, actor, url, pane, cols, rows) - } - Command::GetCellPixels => cmd_sizing::get_cell_pixels(mux), - Command::SetCellPixels { width_px, height_px } => { - cmd_sizing::set_cell_pixels(mux, width_px, height_px) - } - Command::BrowserFramePresented { surface, frame_seq } => { - cmd_browser::browser_frame_presented(mux, client, surface, frame_seq) - } - cmd @ (Command::BrowserMouse { .. } - | Command::BrowserMouseGuarded { .. } - | Command::BrowserWheel { .. } - | Command::BrowserWheelGuarded { .. } - | Command::BrowserKey { .. } - | Command::BrowserKeyPress { .. } - | Command::BrowserInsertText { .. }) => browser_input::handle(mux, client, cmd), - Command::BrowserNavigate { surface, url } => { - cmd_browser::browser_navigate(mux, surface, url) - } - Command::BrowserBack { surface } => cmd_browser::browser_back(mux, surface), - Command::BrowserForward { surface } => cmd_browser::browser_forward(mux, surface), - Command::BrowserReload { surface } => cmd_browser::browser_reload(mux, surface), - Command::BrowserActivate { surface } => cmd_browser::browser_activate(mux, surface), - Command::NewWorkspace { name, cols, rows } => { - cmd_workspaces::new_workspace(mux, actor, name, cols, rows) - } - Command::CreateWorkspace { name, key, mutation } => { - cmd_workspaces::create_workspace(mux, client, name, key, mutation) - } - Command::CreateTerminal { - workspace, - key, - argv, - shell_args, - command, - cwd, - name, - cols, - rows, - terminal_id, - env, - keep, - detached, - mutation, - } => cmd_terminals::create_terminal( - mux, - client, - actor, - workspace, - key, - argv, - shell_args, - command, - cwd, - name, - cols, - rows, - terminal_id, - env, - keep, - detached, - mutation, - ), - Command::NewScreen(params) => cmd_screens::new_screen(mux, client, params), - Command::SetScreenMetadata { screen, color, icon } => { - cmd_screens::set_screen_metadata(mux, actor, screen, color, icon) - } - Command::SetScreenPinned { screen, pinned } => { - cmd_screens::set_screen_pinned(mux, actor, screen, pinned) - } - Command::MoveScreen { screen, index, workspace, new_workspace } => { - cmd_screens::move_screen(mux, actor, screen, index, workspace, new_workspace) - } - Command::CreateScreenGroup { screens, name, color } => { - cmd_screens::create_screen_group(mux, actor, screens, name, color) - } - Command::UpdateScreenGroup { group, name, color, collapsed, icon } => { - cmd_screens::update_screen_group(mux, actor, group, name, color, collapsed, icon) - } - Command::AddScreensToScreenGroup { group, screens, index } => { - cmd_screens::add_screens_to_screen_group(mux, actor, group, screens, index) - } - Command::RemoveScreensFromScreenGroup { screens } => { - cmd_screens::remove_screens_from_screen_group(mux, actor, screens) - } - Command::MoveScreenGroup { group, index, workspace, new_workspace } => { - cmd_screens::move_screen_group(mux, actor, group, index, workspace, new_workspace) - } - Command::UngroupScreenGroup { group } => { - cmd_screens::ungroup_screen_group(mux, actor, group) - } - Command::CloseScreenGroup { group, end_terminals } => { - cmd_screens::close_screen_group(mux, actor, group, end_terminals) - } - Command::ListSavedScreenGroups => cmd_screens::list_saved_screen_groups(mux), - Command::SaveScreenGroup { group } => cmd_screens::save_screen_group(mux, actor, group), - Command::UnsaveScreenGroup { group } => cmd_screens::unsave_screen_group(mux, group), - Command::DeleteSavedScreenGroup { saved } => { - cmd_screens::delete_saved_screen_group(mux, saved) - } - Command::ReopenSavedScreenGroup { saved, workspace } => { - cmd_screens::reopen_saved_screen_group(mux, actor, saved, workspace) - } - command @ Command::NewPane { .. } => cmd_panes::new_pane(mux, client, actor, command), - Command::NewPaneRight(params) => cmd_panes::new_pane_right(mux, client, params), - Command::Split(params) => cmd_panes::split(mux, client, params), - Command::SetRatio { pane, dir, ratio } => { - cmd_panes::set_ratio(mux, actor, pane, dir, ratio) - } - Command::SetSplitRatio { split, ratio, transaction } => { - cmd_panes::set_split_ratio(mux, client, actor, split, ratio, transaction) - } - Command::SetViewportPaneWidth { pane, width, transaction } => { - cmd_panes::set_viewport_pane_width(mux, client, actor, pane, width, transaction) - } - Command::SetColumnDock { pane, dock, edge, mode, role, permanent, transaction } => { - cmd_panes::set_column_dock( - mux, - client, - actor, - pane, - dock, - edge, - mode, - role, - permanent, - transaction, - ) - } - Command::UndoLayout { pane, revision, confirm_close } => { - cmd_panes::undo_layout(mux, actor, pane, revision, confirm_close) - } - Command::PaneNeighbor { pane, dir } => cmd_panes::pane_neighbor(mux, pane, dir), - Command::FocusDirection { pane, dir } => cmd_panes::focus_direction(mux, actor, pane, dir), - Command::SwapPane { pane, dir, target } => { - cmd_panes::swap_pane(mux, actor, pane, dir, target) - } - Command::ZoomPane { pane, mode } => cmd_panes::zoom_pane(mux, actor, pane, mode), - Command::ProcessInfo { surface } => cmd_terminals::process_info(mux, surface), - Command::TerminalResources { surfaces } => cmd_terminals::terminal_resources(mux, surfaces), - Command::MoveTerminal { terminal_id, workspace_key, terminal_incarnation, mutation } => { - cmd_terminals::move_terminal( - mux, - client, - terminal_id, - workspace_key, - terminal_incarnation, - mutation, - ) - } - Command::MoveTabToWorkspace { surface, workspace, transaction } => { - cmd_tabs::move_tab_to_workspace(mux, actor, surface, workspace, transaction) - } - Command::MoveTabToSplit { - surface, - pane, - edge, - ratio, - respawn, - keep_focus, - transaction, - } => cmd_tabs::move_tab_to_split( - mux, - client, - surface, - pane, - edge, - ratio, - respawn, - keep_focus, - transaction, - ), - Command::MoveTabToColumn(params) => cmd_tabs::move_tab_to_column(mux, client, params), - Command::NewRow(params) => cmd_panes::new_row(mux, client, params), - Command::SetRowHeights(params) => cmd_panes::set_row_heights(mux, client, params), - Command::MoveTabToNewWorkspace { surface, group, index, name, transaction } => { - cmd_tabs::move_tab_to_new_workspace( - mux, - actor, - surface, - group, - index, - name, - transaction, - ) - } - Command::MoveTab { surface, pane, index, transaction } => { - cmd_tabs::move_tab(mux, actor, surface, pane, index, transaction) - } - Command::ListTabGroups => cmd_tabs::list_tab_groups(mux), - Command::CreateTabGroup { surfaces, name, color, group, transaction } => { - cmd_tabs::create_tab_group(mux, actor, surfaces, name, color, group, transaction) - } - Command::UpdateTabGroup { group, name, color, collapsed, icon } => { - cmd_tabs::update_tab_group(mux, actor, group, name, color, collapsed, icon) - } - Command::AddTabsToTabGroup { group, surfaces, transaction } => { - cmd_tabs::add_tabs_to_tab_group(mux, actor, group, surfaces, transaction) - } - Command::RemoveTabsFromTabGroup { surfaces, transaction } => { - cmd_tabs::remove_tabs_from_tab_group(mux, actor, surfaces, transaction) - } - Command::MoveTabGroup { group, pane, index, transaction } => { - cmd_tabs::move_tab_group(mux, actor, group, pane, index, transaction) - } - Command::MoveTabGroupToSplit { group, pane, edge, ratio, transaction } => { - cmd_tabs::move_tab_group_to_split(mux, actor, group, pane, edge, ratio, transaction) - } - Command::MoveTabGroupToColumn { group, pane, screen, after_column, width, transaction } => { - cmd_tabs::move_tab_group_to_column( - mux, - actor, - group, - pane, - screen, - after_column, - width, - transaction, - ) - } - Command::MoveTabGroupToNewWorkspace { group, workspace_group, index, transaction } => { - cmd_tabs::move_tab_group_to_new_workspace( - mux, - actor, - group, - workspace_group, - index, - transaction, - ) - } - Command::UngroupTabGroup { group } => cmd_tabs::ungroup_tab_group(mux, actor, group), - Command::CloseTabGroup { group, end_terminals } => { - cmd_tabs::close_tab_group(mux, actor, group, end_terminals) - } - Command::ListSavedTabGroups => cmd_tabs::list_saved_tab_groups(mux), - Command::SaveTabGroup { group } => cmd_tabs::save_tab_group(mux, actor, group), - Command::UnsaveTabGroup { group } => cmd_tabs::unsave_tab_group(mux, actor, group), - Command::DeleteSavedTabGroup { saved } => { - cmd_tabs::delete_saved_tab_group(mux, actor, saved) - } - Command::ReopenSavedTabGroup { saved, pane, transaction } => { - cmd_tabs::reopen_saved_tab_group(mux, actor, saved, pane, transaction) - } - Command::AckTabNotifications { surface } => cmd_tabs::ack_tab_notifications(mux, surface), - Command::ListNotifications { limit } => cmd_frontend::list_notifications(mux, limit), - Command::SetTabPinned { surface, pinned } => { - cmd_tabs::set_tab_pinned(mux, actor, surface, pinned) - } - Command::MoveWorkspace { workspace, key, index, mutation } => { - cmd_workspaces::move_workspace(mux, client, workspace, key, index, mutation) - } - Command::SetWorkspaceMetadata { - workspace, - key, - color, - icon, - title, - pinned, - marked_unread, - mutation, - } => cmd_workspaces::set_workspace_metadata( - mux, - client, - workspace, - key, - color, - icon, - title, - pinned, - marked_unread, - mutation, - ), - Command::ListPersonal => cmd_profiles::list_personal(mux), - Command::CreateBrowserProfile(params) => browser_profiles::create(mux, params), - Command::UpdateBrowserProfile(params) => browser_profiles::update(mux, params), - Command::MoveBrowserProfile(params) => browser_profiles::move_to(mux, params), - Command::DeleteBrowserProfile(params) => browser_profiles::delete(mux, client, params), - Command::ListBookmarks(params) => bookmarks::list(mux, params), - Command::CreateBookmark(params) => bookmarks::create(mux, &actor, params), - Command::UpdateBookmark(params) => bookmarks::update(mux, &actor, params), - Command::MoveBookmark(params) => bookmarks::move_to(mux, &actor, params), - Command::DeleteBookmark(params) => bookmarks::delete(mux, &actor, params), - Command::ImportBookmarks(params) => bookmarks::import(mux, &actor, params), - cmd @ (Command::FeedLocalList(_) - | Command::FeedLocalRead(_) - | Command::FeedLocalHandoffBegin(_) - | Command::FeedLocalHandoffAbort(_) - | Command::FeedLocalHandoffDone(_)) => feed_local::dispatch(mux, &actor, cmd), - Command::PutBlob(params) => icon_assets::put(mux, params), - Command::GetBlob(params) => icon_assets::get(mux, params), - Command::ConversationList => conversations::list(mux, client), - Command::ConversationCreate(params) => conversations::create(mux, client, params), - Command::ConversationSnapshot(params) => conversations::snapshot(mux, client, params), - Command::ConversationHistory(params) => conversations::history(mux, client, params), - Command::ConversationThread(params) => conversations::thread(mux, client, params), - Command::ConversationLinkResolve(params) => { - conversations::link_resolve(mux, client, params) - } - Command::ConversationInboxOp(params) => conversations::inbox_op(mux, client, params), - Command::ConversationSearch(params) => conversations::search(mux, client, params), - Command::HistorySearch(params) => history_search::search(mux, client, params), - Command::ConversationOp(params) => conversations::op(mux, client, params), - Command::ConversationTyping(params) => conversations::typing(mux, client, params), - Command::ConversationDraft(params) => conversations::draft(mux, client, params), - Command::ConversationBind(params) => conversations::bind(mux, client, params), - Command::ConversationAgentToken(params) => conversations::agent_token(mux, client, params), - Command::CloudSessionSet(params) => cloud_conversations::session_set(mux, client, params), - Command::CloudSessionClear => cloud_conversations::session_clear(mux, client), - Command::CloudSessionStatus => cloud_conversations::session_status(mux, client), - Command::PageCredential(params) => page_credential::page_credential(mux, client, params), - Command::WsListen(params) => page_listener::ws_listen(mux, client, params), - Command::CloudInboxList(params) => cloud_conversations::inbox_list(mux, client, params), - Command::CloudConversationSnapshot(params) => { - cloud_conversations::snapshot(mux, client, params) - } - Command::CloudConversationHistory(params) => { - cloud_conversations::history(mux, client, params) - } - Command::CloudConversationThread(params) => { - cloud_conversations::thread(mux, client, params) - } - Command::CloudConversationRuns(params) => cloud_conversations::runs(mux, client, params), - Command::CloudConversationRun(params) => cloud_conversations::run(mux, client, params), - Command::CloudParticipantAvatarGet(params) => { - cloud_conversations::participant_avatar(mux, client, params) - } - Command::CloudConversationOp(params) => cloud_conversations::op(mux, client, params), - Command::CloudInboxOp(params) => cloud_conversations::inbox_op(mux, client, params), - Command::CloudInboxSubscribe => cloud_conversations::subscribe(mux, client, None), - Command::CloudMuxSubscribe(_) => cloud_conversations::mux_subscribe(mux, client), - Command::CloudMuxUnsubscribe(_) => cloud_conversations::mux_unsubscribe(mux, client), - Command::CloudMuxAck(params) => cloud_conversations::mux_ack(mux, client, params), - Command::CloudInboxUnsubscribe => cloud_conversations::unsubscribe(mux, client, None), - Command::CloudConversationSubscribe(params) => { - cloud_conversations::subscribe(mux, client, Some(params)) - } - Command::CloudConversationUnsubscribe(params) => { - cloud_conversations::unsubscribe(mux, client, Some(params)) - } - Command::ConversationAttachmentUpload(p) => conversation_attachments::put(mux, client, p), - Command::ConversationAttachmentRead(p) => conversation_attachments::read(mux, client, p), - Command::ConversationAttachmentLink(p) => conversation_attachments::link(mux, client, p), - Command::CloudAttachmentUpload(p) => cloud_attachments::put(mux, client, p), - Command::CloudAttachmentRead(p) => cloud_attachments::read(mux, client, p), - Command::CloudAttachmentLink(p) => cloud_attachments::link(mux, client, p), - Command::ConversationRunAppend(p) => conversation_runs::append(mux, client, p), - Command::ConversationRuns(p) => conversation_runs::runs(mux, client, p), - Command::ConversationRun(p) => conversation_runs::run(mux, client, p), - Command::ConversationImport(params) => conversations::import(mux, client, params), - Command::CreateProfile { - name, - profile, - color, - icon, - theme, - index, - browser_profile_id, - default_session_id, - defaults, - follows, - } => cmd_profiles::create_profile( - mux, - name, - profile, - color, - icon, - theme, - index, - browser_profile_id, - default_session_id, - defaults, - follows, - ), - Command::UpdateProfile { - profile, - name, - color, - icon, - theme, - browser_profile_id, - default_session_id, - defaults, - } => cmd_profiles::update_profile( - mux, - profile, - name, - color, - icon, - theme, - browser_profile_id, - default_session_id, - defaults, - ), - Command::MoveProfile { profile, index } => cmd_profiles::move_profile(mux, profile, index), - Command::DeleteProfile { profile, move_to } => { - cmd_profiles::delete_profile(mux, client, profile, move_to) - } - Command::SetProfileFollows { profile, session_ids } => { - cmd_profiles::set_profile_follows(mux, profile, session_ids) - } - Command::PinWorkspace { session_id, workspace_key, profile } => { - cmd_workspaces::pin_workspace(mux, session_id, workspace_key, profile) - } - Command::UnpinWorkspace { session_id, workspace_key } => { - cmd_workspaces::unpin_workspace(mux, session_id, workspace_key) - } - Command::PutSession { - session_id, - machine_name, - session_name, - transport, - capabilities, - follow_with, - } => cmd_profiles::put_session( - mux, - session_id, - machine_name, - session_name, - transport, - capabilities, - follow_with, - ), - Command::ForgetSession { session_id, force } => { - cmd_profiles::forget_session(mux, session_id, force) - } - Command::SetSessionIcon(params) => personal::set_session_icon(mux, params), - Command::ImportSessionOrganization { session_id, groups, workspaces } => { - cmd_profiles::import_session_organization(mux, session_id, groups, workspaces) - } - Command::CreatePersonalGroup { name, group, profile, color, collapsed, index } => { - cmd_profiles::create_personal_group(mux, name, group, profile, color, collapsed, index) - } - Command::UpdatePersonalGroup { group, name, color, collapsed, profile } => { - cmd_profiles::update_personal_group(mux, group, name, color, collapsed, profile) - } - Command::DeletePersonalGroup { group } => { - cmd_profiles::delete_personal_group(mux, client, group) - } - Command::MovePersonalGroup { group, index } => { - cmd_profiles::move_personal_group(mux, group, index) - } - command @ Command::SetPersonalWorkspace { .. } => { - cmd_profiles::set_personal_workspace(mux, command) - } - Command::SetPersonalTerminal { session_id, terminal_key, theme } => { - cmd_profiles::set_personal_terminal(mux, session_id, terminal_key, theme) - } - Command::ListWorkspaceGroups => cmd_workspaces::list_workspace_groups(mux), - Command::CreateWorkspaceGroup { name, group, color, collapsed, index } => { - cmd_workspaces::create_workspace_group(mux, name, group, color, collapsed, index) - } - Command::UpdateWorkspaceGroup { group, name, color, collapsed } => { - cmd_workspaces::update_workspace_group(mux, group, name, color, collapsed) - } - Command::DeleteWorkspaceGroup { group } => { - cmd_workspaces::delete_workspace_group(mux, group) - } - Command::MoveWorkspaceGroup { group, index } => { - cmd_workspaces::move_workspace_group(mux, group, index) - } - Command::MoveWorkspaceToGroup { workspace, key, group, index, mutation } => { - cmd_workspaces::move_workspace_to_group( - mux, client, workspace, key, group, index, mutation, - ) - } - Command::SetDefaultColors { - fg, - bg, - cursor, - selection_bg, - selection_fg, - cursor_style, - cursor_blink, - palette, - complete, - } => cmd_terminal_io::set_default_colors( - mux, - fg, - bg, - cursor, - selection_bg, - selection_fg, - cursor_style, - cursor_blink, - palette, - complete, - ), - Command::CloseSurface { surface } => cmd_tabs::close_surface(mux, actor, surface), - Command::CloseTabs { surfaces, end_terminals, transaction, reason, mutation } => { - cmd_tabs::close_tabs( - mux, - client, - surfaces, - end_terminals, - transaction, - reason, - mutation, - ) - } - Command::ClosePane { pane, end_terminals } => { - cmd_panes::close_pane(mux, actor, pane, end_terminals) - } - Command::CloseScreen { screen, end_terminals } => { - cmd_screens::close_screen(mux, actor, screen, end_terminals) - } - Command::CloseWorkspace { workspace, key, end_terminals, reason, if_empty, mutation } => { - let ending = cmd_workspaces::Ending { terminals: end_terminals, reason, if_empty }; - cmd_workspaces::close_workspace(mux, client, workspace, key, ending, mutation) - } - Command::MarkWorkspacesProviderManaged { authority } => { - cmd_workspaces::mark_workspaces_provider_managed(mux, authority) - } - Command::CloseProviderManagedWorkspace { workspace, key, authority } => { - cmd_workspaces::close_provider_managed_workspace(mux, actor, workspace, key, authority) - } - Command::RenamePane { pane, name } => cmd_panes::rename_pane(mux, actor, pane, name), - Command::RenameSurface { surface, name } => { - cmd_tabs::rename_surface(mux, actor, surface, name) - } - Command::RenameScreen { screen, name } => { - cmd_screens::rename_screen(mux, actor, screen, name) - } - Command::RenameWorkspace { workspace, key, name, mutation } => { - cmd_workspaces::rename_workspace(mux, client, workspace, key, name, mutation) - } - Command::RenameProviderManagedWorkspace { workspace, key, name, authority } => { - cmd_workspaces::rename_provider_managed_workspace( - mux, actor, workspace, key, name, authority, - ) - } - Command::ResizeSurface { surface, cols, rows } => { - cmd_sizing::resize_surface(mux, client, surface, cols, rows) - } - Command::ResizeAttachedView { surface, lease, view, identity, cols, rows } => { - cmd_sizing::resize_attached_view( - mux, client, surface, lease, view, identity, cols, rows, - ) - } - Command::ReleaseSurfaceSize { surface } => { - cmd_sizing::release_surface_size(mux, client, surface) - } - Command::ReleaseAttachedViewSize { surface, lease, view } => { - cmd_sizing::release_attached_view_size(mux, client, surface, lease, view) - } - Command::DetachAttachedView { surface, lease, view } => { - cmd_attach::detach_attached_view(mux, client, surface, lease, view) - } - Command::FocusPane { pane } => cmd_panes::focus_pane(mux, actor, pane), - Command::SelectTab { pane, index, delta } => { - cmd_tabs::select_tab(mux, actor, pane, index, delta) - } - Command::SelectScreen { index, delta } => { - cmd_screens::select_screen(mux, actor, index, delta) - } - Command::SelectWorkspace { index, delta } => { - cmd_workspaces::select_workspace(mux, actor, index, delta) - } - Command::ReportFocus { client_id, pane, tab } => { - cmd_server::report_focus(mux, client_id, pane, tab) - } - Command::ClientFocus { client_id } => cmd_server::client_focus(mux, client_id), - Command::SnapshotRequest(params) => cmd_terminal_io::snapshot_request(mux, client, params), - Command::TerminalHistory(params) => cmd_terminals::terminal_history(mux, params), - Command::TerminalReadRange(params) => cmd_terminals::terminal_read_range(mux, params), - Command::ScrollSurface { surface, delta } => { - cmd_terminal_io::scroll_surface(mux, surface, delta) - } - Command::Subscribe { tree_events, surface } => { - cmd_subscribe::subscribe_command(mux, client, writer, tree_events, surface) - } - Command::AttachSurface { - surface: surface_id, - mode, - cols, - rows, - expected_generation, - expected_terminal_id, - snapshot, - } => cmd_attach::attach_surface( - mux, - client, - writer, - surface_id, - mode, - cols, - rows, - expected_generation, - expected_terminal_id, - snapshot, - ), - } -} +mod command_dispatch; +use command_dispatch::handle_command_with_cancellation; /// The `list-workspaces` reply: the tree plus the registry identity it /// belongs to. The launch snapshot stores the same value. diff --git a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs index 0c32360d186a..e78ffd2d8f3d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach.rs @@ -86,7 +86,9 @@ pub(crate) const MAX_PAGE: u64 = 500; pub(crate) const MAX_PROMPT_BYTES: usize = 64 * 1024; /// Permission ids remembered per attachment (announced, not yet answered). const MAX_PERMISSIONS: usize = 64; -const MAX_KINDS: usize = 8; +/// Record kinds or categories one page may ask for: the agent pane asks for all +/// the kinds it draws (9 since prompt bookmarks, cx-kpd0v), with room to grow. +const MAX_KINDS: usize = 32; /// Worker calls (pages, prompts, permission answers) per attachment. const MAX_CALLS: usize = 8; const CALL_TIMEOUT: Duration = Duration::from_secs(5); diff --git a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach_tests.rs index 125b72d0dcd6..39f2065eb434 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach_tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/agent_session_attach_tests.rs @@ -550,6 +550,39 @@ fn prompt_cancel_and_permission_need_an_attachment() { fake.no_connection(); } +/// cx-kpd0v: the agent pane asks for every kind it draws (9 since prompt +/// bookmarks); an attach with that list passes to acpmux unchanged, and a list +/// above the cap is still a bad request. +#[test] +fn attach_takes_the_agent_panes_whole_kind_list() { + let fake = FakeAcpmux::start("kinds"); + let mux = mux_with(&fake, "asa-kinds"); + let surface = agent_tab(&mux, Some(SESSION)); + let mut client = Client::connect(&mux, "kinds"); + let kinds = json!([ + "transcript", + "available_commands_update", + "usage_update", + "config_option_update", + "bookmarks", + "secret_request", + "secret_decision", + "session_offer", + "session_offer_decision" + ]); + let too_many: Vec = + (0..33).map(|n| format!("kind_{}", "x".repeat(n % 5 + 1))).collect(); + let refused = client.request(json!({"cmd":"agent-session-attach","surface":surface, + "kinds":too_many})); + assert_code(&refused, "agent_session.bad_request"); + let reply = client.request(json!({"cmd":"agent-session-attach","surface":surface, + "limit":400,"kinds":kinds})); + assert_eq!(reply["ok"], json!(true), "{reply}"); + let sent = fake.frame(); + assert_eq!(sent["method"], json!("_acpmux/attach")); + assert_eq!(sent["params"]["kinds"], kinds); +} + // MARK: Streaming #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs b/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs index 0c2c4e806720..d9cfb90eb2e2 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/capabilities.rs @@ -185,6 +185,7 @@ pub(super) fn advertised_capabilities( conversation_attachments::WS_ATTACHMENTS_CAPABILITY, chief_registry_resource::CHIEF_PROFILE_WS_CAPABILITY, chief_registry_resource::CHIEF_EVENTS_CAPABILITY, + chief_registry_resource::CHIEF_PROFILE_ADOPT_CAPABILITY, SCREEN_METADATA_CAPABILITY, SCREEN_GROUPS_CAPABILITY, NOTIFICATION_SOURCE_CAPABILITY, diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs new file mode 100644 index 000000000000..c0677a19e7ee --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_adopt_tests.rs @@ -0,0 +1,257 @@ +//! `chief.profile.adopt` on the request path every connection takes +//! (`handle_connection_message`: the origin gate, then the Chief registry): +//! only the owner's own Unix connection adopts. The person's page WebSocket, +//! the host's page relay (even with the person's gesture), a paired device and +//! a bound agent are refused with `origin.forbidden`, and nothing is written. + +use serde_json::{Value, json}; + +use crate::request_origin::params_sha256; +use crate::server::origin_gate::{ + set_page, set_peer_key_for_test, set_role_for_test, set_scoped_page, set_verified_app_for_test, +}; +use crate::server::*; + +struct Conn { + client: u64, + writer: MessageWriter, + outbound: Arc, + scheduler: Arc, +} + +fn connect(mux: &Arc, transport: ClientTransport) -> Conn { + let outbound = Arc::new(BoundedOutbound::default()); + let writer = MessageWriter::new(QueuedSink { outbound: outbound.clone(), control: None }); + let client = mux.control_clients.register(transport, writer.clone()); + let scheduler = + Arc::new(ConnectionSurfaceScheduler::new(mux.surface_operation_admission.clone())); + Conn { client, writer, outbound, scheduler } +} + +fn send(mux: &Arc, conn: &Conn, request: &Value) -> Value { + assert!(handle_connection_message( + mux, + conn.client, + &request.to_string(), + &conn.writer, + &conn.scheduler + )); + let message = conn.outbound.try_pop().expect("a synchronous reply"); + serde_json::from_str(&message).unwrap() +} + +/// A v1 command on `conn`'s client (its data). +fn command(mux: &Arc, conn: &Conn, request: Value) -> Value { + let command: Command = serde_json::from_value(request).unwrap(); + handle_command(mux, conn.client, command, &conn.writer).unwrap() +} + +fn params(fields: Value) -> Value { + let mut params = json!({"machine": "current", "session": "current"}); + for (key, value) in fields.as_object().unwrap() { + params[key] = value.clone(); + } + params +} + +fn v2(operation: &str, params: &Value, key: Option<&str>, origin: Option) -> Value { + let mut request = json!({ + "protocol": "cmux.protocol/2", "type": "request", "id": "r1", + "operation": operation, "params": params, + }); + if let Some(key) = key { + request["idempotency_key"] = json!(key); + } + if let Some(origin) = origin { + request["origin"] = origin; + } + request +} + +fn forbidden(reply: &Value) -> String { + assert_eq!(reply["ok"], false, "{reply}"); + assert_eq!(reply["error"]["code"], "origin.forbidden", "{reply}"); + reply["error"]["details"]["reason"].as_str().unwrap_or_default().to_owned() +} + +fn chief_ids(mux: &Arc, owner: &Conn) -> Vec { + let listed = send(mux, owner, &v2("chief.profile.list", ¶ms(json!({})), None, None)); + assert_eq!(listed["ok"], true, "{listed}"); + listed["result"]["chiefs"] + .as_array() + .unwrap() + .iter() + .map(|c| c["id"].as_str().unwrap().to_owned()) + .collect() +} + +#[test] +fn only_the_owners_own_unix_connection_adopts_an_agent() { + let mux = Mux::new_for_test("chief-adopt-callers".to_owned(), crate::SurfaceOptions::default()); + let owner = connect(&mux, ClientTransport::Unix); + let created = command( + &mux, + &owner, + json!({"cmd": "conversation-create", "idempotency_key": "dm-release", "actor": "user_local", + "title": "Release Chief", "participants": [ + {"id": "user_local", "kind": "human", "display_name": "Me"}, + {"id": "agent_release", "kind": "agent", "display_name": "Release Chief", + "agent_class": "mux", "acp_session": "release"}]}), + ); + assert!(created["conversation"]["id"].is_string(), "{created}"); + let adopt = params(json!({"participant_id": "agent_release", "role": "Release trains"})); + + // The person's page WebSocket (the Channels page, page-bound credential). + let page = connect(&mux, ClientTransport::WebSocket); + set_scoped_page(&mux, page.client, "home-channels".to_owned()); + let reason = + forbidden(&send(&mux, &page, &v2("chief.profile.adopt", &adopt, Some("p1"), None))); + assert!(matches!(reason.as_str(), "page_not_allowed" | "chief_adopt_owner_only"), "{reason}"); + // The daemon's own credential on a WebSocket with no page: still a page path. + let socket = connect(&mux, ClientTransport::WebSocket); + assert_eq!( + forbidden(&send(&mux, &socket, &v2("chief.profile.adopt", &adopt, Some("w1"), None))), + "chief_adopt_owner_only" + ); + + // The host's page relay, even with the person's gesture for exactly this request. + let app = connect(&mux, ClientTransport::Unix); + set_role_for_test(&mux, app.client, "main"); + set_peer_key_for_test(&mux, app.client, "token:20.1"); + set_verified_app_for_test(&mux, app.client, true); + let relay = connect(&mux, ClientTransport::Unix); + set_role_for_test(&mux, relay.client, "page_relay"); + set_peer_key_for_test(&mux, relay.client, "token:20.1"); + set_page(&mux, relay.client, "home-channels".to_owned()); + let issued = send( + &mux, + &app, + &v2( + "origin.confirmation.issue", + &json!({"machine": "current", "session": "current", "operation": "chief.profile.adopt", + "params_sha256": params_sha256(&adopt), + "relay_connection_id": relay.client.to_string()}), + None, + None, + ), + ); + let gesture = issued["result"]["token"] + .as_str() + .map(|token| json!({"claim": "user", "confirmation": token})); + let relayed = send(&mux, &relay, &v2("chief.profile.adopt", &adopt, Some("r1"), gesture)); + assert_eq!(relayed["error"]["code"], "origin.forbidden", "{relayed}"); + + // A paired device (a pairing-store credential). + let paired = connect(&mux, ClientTransport::WebSocket); + mux.control_clients.mark_person(paired.client); + assert_eq!( + forbidden(&send(&mux, &paired, &v2("chief.profile.adopt", &adopt, Some("d1"), None))), + "chief_registry_caller" + ); + + // A bound agent on a Unix connection: the agent itself, and a live Chief. + let token = command( + &mux, + &owner, + json!({"cmd": "conversation-agent-token", "participant": "agent_release"}), + )["token"] + .as_str() + .unwrap() + .to_owned(); + let agent = connect(&mux, ClientTransport::Unix); + command( + &mux, + &agent, + json!({"cmd": "conversation-bind", "participant": "agent_release", "token": token}), + ); + assert_eq!( + forbidden(&send(&mux, &agent, &v2("chief.profile.adopt", &adopt, Some("a1"), None))), + "chief_registry_caller" + ); + + // Nothing was written by any of them. + assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned()]); + + // The agent's session is still bound: two processes never speak as one + // participant, so the adopt waits until that session is gone. + let bound = send(&mux, &owner, &v2("chief.profile.adopt", &adopt, Some("o0"), None)); + assert_eq!(bound["error"]["code"], "operation.failed", "{bound}"); + assert_eq!(bound["error"]["details"]["reason"], "participant_bound", "{bound}"); + assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned()]); + mux.unbind_conversation_participant("agent_release"); + + // The owner's own terminal adopts it. + let adopted = send(&mux, &owner, &v2("chief.profile.adopt", &adopt, Some("o1"), None)); + assert_eq!(adopted["ok"], true, "{adopted}"); + assert_eq!(adopted["result"]["value"]["id"], "agent_release"); + assert_eq!(chief_ids(&mux, &owner), vec!["agent_mux".to_owned(), "agent_release".to_owned()]); + + // The credential issued before the adopt is revoked: the old session cannot come back. + let old = connect(&mux, ClientTransport::Unix); + let command_result = |conn: &Conn, request: Value| { + let command: Command = serde_json::from_value(request).unwrap(); + handle_command(&mux, conn.client, command, &conn.writer) + }; + assert!( + command_result( + &old, + json!({"cmd": "conversation-bind", "participant": "agent_release", "token": token}) + ) + .is_err(), + "a pre-adopt credential bound" + ); + + // With no brain supervisor, only the person's own terminal mints the + // adopted Chief's new credential: never a page relay, a WebSocket page, a + // paired or remote device, or an agent. + let mint = json!({"cmd": "conversation-agent-token", "participant": "agent_release"}); + let relay_mint = connect(&mux, ClientTransport::Unix); + set_role_for_test(&mux, relay_mint.client, "page_relay"); + set_page(&mux, relay_mint.client, "home-channels".to_owned()); + let page_mint = connect(&mux, ClientTransport::WebSocket); + set_scoped_page(&mux, page_mint.client, "home-channels".to_owned()); + let device_mint = connect(&mux, ClientTransport::Remote); + let paired_mint = connect(&mux, ClientTransport::WebSocket); + mux.control_clients.mark_person(paired_mint.client); + let other_token = command( + &mux, + &owner, + json!({"cmd": "conversation-create", "idempotency_key": "dm-helper", "actor": "user_local", + "title": "Helper", "participants": [ + {"id": "user_local", "kind": "human", "display_name": "Me"}, + {"id": "agent_helper", "kind": "agent", "display_name": "Helper", + "agent_class": "mux"}]}), + ); + assert!(other_token["conversation"]["id"].is_string()); + let helper_token = command( + &mux, + &owner, + json!({"cmd": "conversation-agent-token", "participant": "agent_helper"}), + )["token"] + .as_str() + .unwrap() + .to_owned(); + let agent_mint = connect(&mux, ClientTransport::Unix); + command( + &mux, + &agent_mint, + json!({"cmd": "conversation-bind", "participant": "agent_helper", "token": helper_token}), + ); + for (who, conn) in [ + ("page relay", &relay_mint), + ("page websocket", &page_mint), + ("remote device", &device_mint), + ("paired device", &paired_mint), + ("agent", &agent_mint), + ] { + assert!(command_result(conn, mint.clone()).is_err(), "{who} minted a Chief credential"); + } + let fresh = command(&mux, &owner, mint)["token"].as_str().unwrap().to_owned(); + let chief = connect(&mux, ClientTransport::Unix); + let bound = command( + &mux, + &chief, + json!({"cmd": "conversation-bind", "participant": "agent_release", "token": fresh}), + ); + assert_eq!(bound["participant"], "agent_release", "{bound}"); +} diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_control.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_control.rs index 00aee6666046..1e35178e8795 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_control.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_control.rs @@ -63,7 +63,7 @@ pub(super) fn handle_with( let line = tool_line(operation, &request.fields); let (mux, thread_writer, reply_id) = (mux.clone(), writer.clone(), id.clone()); let spawned = std::thread::Builder::new().name("mux-chief-control".into()).spawn(move || { - let answer = ask(operation, &line, socket.as_deref()); + let answer = disabled_unless_answered(operation, ask(operation, &line, socket.as_deref())); send_resource_response( &thread_writer, reply_id, @@ -84,6 +84,24 @@ pub(super) fn handle_with( ) } +/// cx-tgf4h: with agents off in this build session the daemon starts no +/// brain, so a brain that does not answer is not "not running" but off on +/// purpose: the typed refusal. A brain that still runs (started before) +/// answers as always, so its turn can still be stopped. +fn disabled_unless_answered( + operation: ResourceOperation, + answer: Result, +) -> Result { + match answer { + Err(_) if crate::agent_launch::disabled() => Err(ResourceError::operation_failed( + operation.wire_name(), + crate::agent_launch::REASON, + json!({"message": crate::agent_launch::MESSAGE}), + )), + other => other, + } +} + /// The owner rule of `chief-inspect`, as an `origin.forbidden` refusal. pub(super) fn require_owner(mux: &Mux, client: u64) -> Result<(), ResourceError> { let unix = matches!(mux.control_clients.transport_of(client), Some(ClientTransport::Unix)); diff --git a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs index b26a4ac90bb7..bc99f3658940 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/chief_registry_resource.rs @@ -54,6 +54,7 @@ pub(super) const fn handles(operation: ResourceOperation) -> bool { Op::ChiefProfileList | Op::ChiefProfileGet | Op::ChiefProfileCreate + | Op::ChiefProfileAdopt | Op::ChiefProfileUpdate | Op::ChiefProfileDelete | Op::ChiefProfileAvatarSet @@ -79,6 +80,10 @@ pub const CHIEF_PROFILE_WS_CAPABILITY: &str = "chief-profile-ws-v1"; /// Channels page redraw their Chiefs on it instead of polling. pub const CHIEF_EVENTS_CAPABILITY: &str = "chief-events-v1"; +/// `chief.profile.adopt`: the owner makes an existing agent participant a +/// Chief record under its own id. +pub const CHIEF_PROFILE_ADOPT_CAPABILITY: &str = "chief-profile-adopt-v1"; + pub(super) fn handle( mux: &Arc, client: u64, @@ -94,6 +99,12 @@ pub(super) fn handle( fn answer(mux: &Mux, client: u64, request: &ParsedResourceRequest) -> Result { let operation = request.envelope.operation; let fields = &request.fields; + // The participant an adopt names, read before the registry lock (the + // conversation store has its own lock; never both at once). + let adoptee = match operation { + Op::ChiefProfileAdopt => Some(adoptee(mux, operation, fields)?), + _ => None, + }; with_store(mux, operation, |store| { let caller = caller(mux, client, store)?; match operation { @@ -125,7 +136,13 @@ fn answer(mux: &Mux, client: u64, request: &ParsedResourceRequest) -> Result { let key = request.envelope.idempotency_key.clone().unwrap_or_default(); - mutate(mux, client, store, &caller, operation, fields, key) + mutate( + mux, + client, + store, + &caller, + RegistryWrite { operation, fields, key, adoptee: adoptee.as_ref() }, + ) } } }) @@ -144,7 +161,7 @@ pub(super) fn set_chief_avatar( let operation = Op::ChiefProfileAvatarSet; with_store(mux, operation, |store| { let caller = caller(mux, client, store)?; - mutate(mux, client, store, &caller, operation, fields, key) + mutate(mux, client, store, &caller, RegistryWrite { operation, fields, key, adoptee: None }) }) } @@ -195,15 +212,33 @@ pub(super) fn set_person_avatar( }) } +/// One registry write: the operation, its fields, its idempotency key and, +/// for `chief.profile.adopt`, the participant it names. +struct RegistryWrite<'a> { + operation: Op, + fields: &'a Map, + key: String, + adoptee: Option<&'a Adoptee>, +} + fn mutate( mux: &Mux, client: u64, store: &mut ChiefStore, caller: &Caller, - operation: Op, - fields: &Map, - key: String, + write: RegistryWrite<'_>, ) -> Result { + let RegistryWrite { operation, fields, key, adoptee } = write; + // Owner only, from a terminal, before anything else is checked: no page + // (a WebSocket or the host's page relay, with or without a page id), no + // Chief, no paired or remote device (caller() refuses those). + if operation == Op::ChiefProfileAdopt + && (*caller != Caller::Owner + || super::origin_gate::connection_page(mux, client).is_some() + || super::origin_gate::is_page(mux, client)) + { + return Err(forbidden_adopt()); + } if key.is_empty() { return Err(ResourceError::validation_invalid( Some("idempotency_key"), @@ -213,7 +248,10 @@ fn mutate( let profile: Map = fields .iter() .filter(|(k, _)| { - !matches!(k.as_str(), "chief" | "expected_rev" | "media_type" | "data" | "clear") + !matches!( + k.as_str(), + "chief" | "expected_rev" | "media_type" | "data" | "clear" | "participant_id" + ) }) .map(|(k, v)| (k.clone(), v.clone())) .collect(); @@ -259,6 +297,17 @@ fn mutate( let outcome = store .mutate(&actor, &key, operation.wire_name(), &body, |tx| match operation { Op::ChiefProfileCreate => chief_registry::create(tx, &profile), + Op::ChiefProfileAdopt => match adoptee { + Some(Adoptee { id, display_name: Some(name) }) => { + chief_registry::adopt(tx, id, name, &profile, || { + mux.claim_unbound_participant(id) + }) + } + Some(Adoptee { id, .. }) if id == chief_registry::DEFAULT_CHIEF => { + Ok(Err(Refusal::DefaultChief)) + } + _ => Ok(Err(Refusal::NotAgentParticipant)), + }, Op::ChiefProfileUpdate => { chief_registry::update(tx, &chief, expected_rev, &profile, None) } @@ -278,7 +327,7 @@ fn mutate( } if !replayed && rev_before != Some(record.rev) { let change = match operation { - Op::ChiefProfileCreate => "created", + Op::ChiefProfileCreate | Op::ChiefProfileAdopt => "created", Op::ChiefProfileDelete => "deleted", _ => "updated", }; @@ -290,7 +339,7 @@ fn mutate( if !replayed { mux.chiefs().brains.chief_written( &record, - operation == Op::ChiefProfileCreate, + matches!(operation, Op::ChiefProfileCreate | Op::ChiefProfileAdopt), operation == Op::ChiefProfileDelete, ); } @@ -301,6 +350,12 @@ fn mutate( Err(Refusal::KeyConflict(committed)) => { Err(ResourceError::idempotency_conflict(&key, &committed)) } + Err(Refusal::ParticipantBound) => Err(ResourceError::operation_failed( + operation.wire_name(), + Refusal::ParticipantBound.reason(), + json!({"message": "stop its current agent session first", + "hint": "stop its current agent session first"}), + )), Err(refusal) => Err(refused(operation, &refusal)), } } @@ -460,6 +515,79 @@ fn with_store( run(store.as_mut().expect("chief registry opened above")) } +/// `conversation-agent-token`'s mint, under the registry lock (lock order: +/// registry, then the conversation store, as `chief.profile.adopt`): for a +/// live Chief record other than the default Chief, refused while a brain +/// supervisor runs (its brain holds the credential) and refused unless +/// `terminal` (the person's own terminal: no page, no paired or remote +/// device); any other participant mints as before. An unreadable registry +/// refuses the mint (fail closed). +pub(super) fn mint_credential( + mux: &Mux, + participant: &str, + terminal: bool, +) -> anyhow::Result { + let operation = Op::ChiefProfileGet; + let minted = with_store(mux, operation, |store| { + let chief = participant != chief_registry::DEFAULT_CHIEF + && participant.starts_with("agent_") + && store.get(participant).map_err(|e| internal(operation, &e))?.is_some(); + if chief && crate::chief_brains::taken().is_some() { + return Ok(Err(format!("{participant} is a Chief: its brain holds its credential"))); + } + if chief && !terminal { + return Ok(Err( + "only the person, from a terminal on this machine, mints a Chief's credential" + .to_owned(), + )); + } + mux.with_conversations(|conversations| conversations.mint_agent_token(participant)) + .map(Ok) + .map_err(|e| internal(operation, &e)) + }) + .map_err(|e| anyhow::anyhow!("{e}"))?; + minted.map_err(|refusal| anyhow::anyhow!(refusal)) +} + +/// The participant `chief.profile.adopt` names: its id, and its display name +/// when it is an agent participant of a local conversation. +struct Adoptee { + id: String, + display_name: Option, +} + +fn adoptee( + mux: &Mux, + operation: Op, + fields: &Map, +) -> Result { + let id = fields.get("participant_id").and_then(Value::as_str).unwrap_or("").to_owned(); + let shaped = id.strip_prefix("agent_").is_some_and(|rest| { + (1..=64).contains(&rest.len()) + && rest.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + }); + if !shaped { + // A person, or no participant id at all: never a Chief. + return Ok(Adoptee { id, display_name: None }); + } + let summaries = + mux.with_conversations(|store| store.list()).map_err(|e| internal(operation, &e))?; + let display_name = summaries + .iter() + .flat_map(|summary| summary.participants.iter()) + .find(|p| p.id == id && p.kind == cmux_conversation::ParticipantKind::Agent) + .map(|p| p.display_name.clone()) + .filter(|name| !name.trim().is_empty()); + Ok(Adoptee { id, display_name }) +} + +fn forbidden_adopt() -> ResourceError { + crate::request_origin::forbidden( + "only the person, from a terminal on this machine, adopts an agent as a Chief", + json!({"derived": "agent", "required": "user", "reason": "chief_adopt_owner_only"}), + ) +} + fn chief_field(fields: &Map) -> &str { fields.get("chief").and_then(Value::as_str).unwrap_or("") } @@ -485,3 +613,7 @@ fn internal(operation: Op, error: &anyhow::Error) -> ResourceError { json!({"message": format!("{error:#}")}), ) } + +#[cfg(test)] +#[path = "chief_adopt_tests.rs"] +mod adopt_tests; diff --git a/cmux-tui/crates/cmux-tui-core/src/server/command_dispatch.rs b/cmux-tui/crates/cmux-tui-core/src/server/command_dispatch.rs new file mode 100644 index 000000000000..b43bab121660 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/server/command_dispatch.rs @@ -0,0 +1,873 @@ +//! The `Command` router: one arm per command, each calling its family's handler +//! (`cmd_*` modules). Moved out of server.rs unchanged (cx-6so.3). + +use super::*; + +pub(super) fn handle_command_with_cancellation( + mux: &Arc, + client: u64, + cmd: Command, + writer: &MessageWriter, + cancellation: Option<&ConnectionCancellation>, +) -> anyhow::Result { + // Who a durable legacy command acts as (P8): read once per command. + let actor = origin_gate::connection_actor(mux, client); + if let Some(remote) = remote_relay::intercept(mux, client, &cmd, writer) { + return remote; + } + match cmd { + Command::SubscribeActivity => cmd_subscribe::subscribe_activity(mux, client, writer), + cmd @ (Command::UrlOpenSubscribe { .. } + | Command::UrlOpenClaim { .. } + | Command::UrlOpenResult { .. }) => url_open::handle(mux, client, cmd, writer), + cmd @ (Command::TerminalClipboardSubscribe { .. } + | Command::TerminalClipboardReply { .. }) => { + clipboard_read::handle(mux, client, cmd, writer) + } + Command::UrlOpen { .. } => { + anyhow::bail!("URL opening requires the asynchronous request path") + } + Command::ChiefInspect(_) => { + anyhow::bail!("chief-inspect requires the asynchronous request path") + } + Command::PasteImage { + surface, + terminal_id, + lease, + upload_id, + op, + mime, + size, + offset, + data, + } => cmd_terminal_io::paste_image( + mux, + client, + surface, + terminal_id, + lease, + upload_id, + op, + mime, + size, + offset, + data, + ), + command @ (Command::SetTerminalCommandHistory(_) + | Command::ListTerminalCommands(_) + | Command::DeleteTerminalCommands(_)) => command_history::handle(mux, client, command), + Command::ServerStats { include } => cmd_server::server_stats(mux, client, include), + Command::BrowserHostProvider => cmd_browser::browser_host_provider(mux, client), + Command::Identify => cmd_server::identify(mux), + Command::ShutdownDaemon { pid, generation, force, end_terminals, keep_layout } => { + cmd_server::shutdown_daemon( + mux, + client, + pid, + generation, + force, + end_terminals, + keep_layout, + ) + } + Command::Ping => cmd_server::ping(), + Command::SetClientInfo { + name, + kind, + capabilities, + user_id, + display_name, + device_kind, + device_name, + device_id, + } => cmd_server::set_client_info( + mux, + client, + name, + kind, + capabilities, + user_id, + display_name, + device_kind, + device_name, + device_id, + ), + Command::ListClients => cmd_server::list_clients(mux, client), + Command::MachineUsage => cmd_server::machine_usage(mux), + Command::MachineListeningTcp => cmd_server::machine_listening_tcp(), + Command::RegisterBrowserProvider { + provider_id, + endpoint, + authentication, + bearer_token, + hosting, + targets, + } => cmd_browser::register_browser_provider( + mux, + client, + cmd_browser::BrowserProviderRequest { + provider_id, + endpoint, + authentication, + bearer_token, + hosting, + targets, + }, + ), + Command::GetBrowserProvider => cmd_browser::get_browser_provider(mux, client), + Command::UnregisterBrowserProvider => cmd_browser::unregister_browser_provider(mux, client), + Command::ListTerminals => cmd_terminals::list_terminals(mux), + Command::TerminalEvents { after_revision } => { + cmd_terminals::terminal_events(mux, after_revision) + } + Command::SetClientSizing { surface, client: target, enabled, exclusive } => { + cmd_sizing::set_client_sizing(mux, client, surface, target, enabled, exclusive) + } + Command::PairingResponse { request, approve } => { + cmd_server::pairing_response(mux, client, request, approve) + } + Command::DetachClient { client: target, by, surface } => { + cmd_attach::detach_client(mux, client, target, by, surface) + } + Command::SetSizePolicy { surface, workspace, policy } => { + cmd_sizing::set_size_policy(mux, client, surface, workspace, policy) + } + Command::SetSizeCounts { surface, client: target, lease, view, participant, counts } => { + cmd_sizing::set_size_counts( + mux, + client, + surface, + target, + lease, + view, + participant, + counts, + ) + } + Command::NoteSizeActivity { surface, view } => { + cmd_sizing::note_size_activity(mux, client, surface, view) + } + Command::ReattachView { surface, counts } => { + cmd_attach::reattach_view(mux, client, surface, counts) + } + Command::GetSizeState { surface } => cmd_sizing::get_size_state(mux, client, surface), + Command::ReloadConfig => cmd_server::reload_config(mux), + Command::SetWindowTitle { title } => cmd_server::set_window_title(mux, title), + Command::ClearWindowTitle => cmd_server::clear_window_title(mux), + Command::ListWorkspaces => cmd_workspaces::list_workspaces(mux), + Command::GetFrontendProjection { frontend, scope, subject_key } => { + cmd_frontend::get_frontend_projection(mux, frontend, scope, subject_key) + } + Command::PutFrontendProjection { + frontend, + scope, + subject_key, + schema_version, + expected_projection_revision, + projection, + mutation, + } => cmd_frontend::put_frontend_projection( + mux, + client, + frontend, + scope, + subject_key, + schema_version, + expected_projection_revision, + projection, + mutation, + ), + Command::JournalFrontendEvent { event } => { + cmd_frontend::journal_frontend_event(mux, client, event) + } + Command::ExportLayout { screen } => cmd_panes::export_layout(mux, screen), + Command::ApplyLayout { workspace, name, layout, cols, rows } => { + cmd_panes::apply_layout(mux, actor, workspace, name, layout, cols, rows) + } + Command::Send { surface, terminal_id, text, bytes, paste } => { + cmd_terminal_io::send(mux, client, writer, surface, terminal_id, text, bytes, paste) + } + Command::ReadScreen { surface } => cmd_terminal_io::read_screen(mux, surface), + Command::ClearHistory { surface, fallback_key } => { + cmd_terminal_io::clear_history(mux, surface, fallback_key) + } + Command::ReadScrollback { surface, start, count } => { + cmd_terminal_io::read_scrollback(mux, surface, start, count) + } + Command::SidebarPlugin { cols, rows, relaunch } => { + cmd_frontend::sidebar_plugin(mux, cols, rows, relaunch) + } + Command::WaitFor { surface, pattern, timeout_ms } => { + cmd_terminal_io::wait_for(mux, cancellation, surface, pattern, timeout_ms) + } + Command::Run { argv, command, cwd, pane, new_workspace, key, name, cols, rows } => { + cmd_terminal_io::run( + mux, + actor, + argv, + command, + cwd, + pane, + new_workspace, + key, + name, + cols, + rows, + ) + } + Command::CreateSurfaceWithReceipt(request) => { + cmd_frontend::create_surface_with_receipt_command(mux, client, request) + } + Command::SendKey { surface, keys } => cmd_terminal_io::send_key(mux, client, surface, keys), + Command::Copy { surface, mode } => cmd_terminal_io::copy(mux, surface, mode), + Command::Ids { kind } => cmd_frontend::ids(mux, kind), + Command::Notify { title, body, level, surface, source } => { + cmd_frontend::notify(mux, actor, title, body, level, surface, source) + } + Command::ListAgents { surface, state } => cmd_frontend::list_agents(mux, surface, state), + Command::ReportAgent { surface, state, source, session } => { + cmd_frontend::report_agent(mux, surface, state, source, session) + } + Command::VtState { .. } => unreachable!("vt-state uses its streaming response path"), + Command::MintTerminalRenderer { surface, ttl_ms } => { + cmd_terminals::mint_terminal_renderer(mux, client, surface, ttl_ms) + } + Command::MintTerminalRendererByTerminal { terminal, ttl_ms } => { + cmd_terminals::mint_terminal_renderer_by_terminal(mux, client, terminal, ttl_ms) + } + Command::ResolveTerminal { terminal_id } => { + cmd_terminals::resolve_terminal(mux, terminal_id) + } + Command::CloseTerminal { terminal_id, terminal_incarnation, mutation } => { + cmd_terminals::close_terminal(mux, client, terminal_id, terminal_incarnation, mutation) + } + Command::SetTerminalIdlePolicy { surface, terminal_id, idle_close_seconds } => { + cmd_terminals::set_terminal_idle_policy(mux, surface, terminal_id, idle_close_seconds) + } + Command::SetTerminalKeep { surface, terminal_id, keep } => { + cmd_terminals::set_terminal_keep(mux, surface, terminal_id, keep) + } + Command::NewTab { pane, cwd, env, cols, rows, keep, terminal_id, shell_args } => { + cmd_tabs::new_tab( + mux, + client, + actor, + pane, + cwd, + env, + cols, + rows, + keep, + terminal_id, + shell_args, + ) + } + Command::NewConversationTab(params) => cmd_tabs::new_conversation_tab(mux, client, params), + Command::BindConversationTabSession(params) => { + cmd_tabs::bind_conversation_tab_session(mux, actor, params) + } + Command::RestartTab(params) => tab_restart::restart(mux, params), + Command::NewFrontendBrowserTab(params) => { + cmd_tabs::new_frontend_browser_tab(mux, client, params) + } + Command::UpdateFrontendBrowserTab(params) => { + cmd_tabs::update_frontend_browser_tab(mux, actor, params) + } + Command::SetFrontendBrowserHistory(params) => frontend_browser_history::set(mux, params), + Command::GetFrontendBrowserHistory(params) => frontend_browser_history::get(mux, params), + Command::NewRemoteTerminalTab(p) => remote_terminal_tabs_wire::create(mux, &actor, p), + Command::UpdateRemoteTerminalTab(p) => remote_terminal_tabs_wire::update(mux, p), + Command::RemoteTerminalSnapshot(p) => remote_terminal_tabs_wire::snapshot(mux, p), + Command::NewBrowserTab { url, pane, cols, rows } => { + cmd_tabs::new_browser_tab(mux, actor, url, pane, cols, rows) + } + Command::GetCellPixels => cmd_sizing::get_cell_pixels(mux), + Command::SetCellPixels { width_px, height_px } => { + cmd_sizing::set_cell_pixels(mux, width_px, height_px) + } + Command::BrowserFramePresented { surface, frame_seq } => { + cmd_browser::browser_frame_presented(mux, client, surface, frame_seq) + } + cmd @ (Command::BrowserMouse { .. } + | Command::BrowserMouseGuarded { .. } + | Command::BrowserWheel { .. } + | Command::BrowserWheelGuarded { .. } + | Command::BrowserKey { .. } + | Command::BrowserKeyPress { .. } + | Command::BrowserInsertText { .. }) => browser_input::handle(mux, client, cmd), + Command::BrowserNavigate { surface, url } => { + cmd_browser::browser_navigate(mux, surface, url) + } + Command::BrowserBack { surface } => cmd_browser::browser_back(mux, surface), + Command::BrowserForward { surface } => cmd_browser::browser_forward(mux, surface), + Command::BrowserReload { surface } => cmd_browser::browser_reload(mux, surface), + Command::BrowserActivate { surface } => cmd_browser::browser_activate(mux, surface), + Command::NewWorkspace { name, cols, rows } => { + cmd_workspaces::new_workspace(mux, actor, name, cols, rows) + } + Command::CreateWorkspace { name, key, mutation } => { + cmd_workspaces::create_workspace(mux, client, name, key, mutation) + } + Command::CreateTerminal { + workspace, + key, + argv, + shell_args, + command, + cwd, + name, + cols, + rows, + terminal_id, + env, + keep, + detached, + mutation, + } => cmd_terminals::create_terminal( + mux, + client, + actor, + workspace, + key, + argv, + shell_args, + command, + cwd, + name, + cols, + rows, + terminal_id, + env, + keep, + detached, + mutation, + ), + Command::NewScreen(params) => cmd_screens::new_screen(mux, client, params), + Command::SetScreenMetadata { screen, color, icon } => { + cmd_screens::set_screen_metadata(mux, actor, screen, color, icon) + } + Command::SetScreenPinned { screen, pinned } => { + cmd_screens::set_screen_pinned(mux, actor, screen, pinned) + } + Command::MoveScreen { screen, index, workspace, new_workspace } => { + cmd_screens::move_screen(mux, actor, screen, index, workspace, new_workspace) + } + Command::CreateScreenGroup { screens, name, color } => { + cmd_screens::create_screen_group(mux, actor, screens, name, color) + } + Command::UpdateScreenGroup { group, name, color, collapsed, icon } => { + cmd_screens::update_screen_group(mux, actor, group, name, color, collapsed, icon) + } + Command::AddScreensToScreenGroup { group, screens, index } => { + cmd_screens::add_screens_to_screen_group(mux, actor, group, screens, index) + } + Command::RemoveScreensFromScreenGroup { screens } => { + cmd_screens::remove_screens_from_screen_group(mux, actor, screens) + } + Command::MoveScreenGroup { group, index, workspace, new_workspace } => { + cmd_screens::move_screen_group(mux, actor, group, index, workspace, new_workspace) + } + Command::UngroupScreenGroup { group } => { + cmd_screens::ungroup_screen_group(mux, actor, group) + } + Command::CloseScreenGroup { group, end_terminals } => { + cmd_screens::close_screen_group(mux, actor, group, end_terminals) + } + Command::ListSavedScreenGroups => cmd_screens::list_saved_screen_groups(mux), + Command::SaveScreenGroup { group } => cmd_screens::save_screen_group(mux, actor, group), + Command::UnsaveScreenGroup { group } => cmd_screens::unsave_screen_group(mux, group), + Command::DeleteSavedScreenGroup { saved } => { + cmd_screens::delete_saved_screen_group(mux, saved) + } + Command::ReopenSavedScreenGroup { saved, workspace } => { + cmd_screens::reopen_saved_screen_group(mux, actor, saved, workspace) + } + command @ Command::NewPane { .. } => cmd_panes::new_pane(mux, client, actor, command), + Command::NewPaneRight(params) => cmd_panes::new_pane_right(mux, client, params), + Command::Split(params) => cmd_panes::split(mux, client, params), + Command::SetRatio { pane, dir, ratio } => { + cmd_panes::set_ratio(mux, actor, pane, dir, ratio) + } + Command::SetSplitRatio { split, ratio, transaction } => { + cmd_panes::set_split_ratio(mux, client, actor, split, ratio, transaction) + } + Command::SetViewportPaneWidth { pane, width, transaction } => { + cmd_panes::set_viewport_pane_width(mux, client, actor, pane, width, transaction) + } + Command::SetColumnDock { pane, dock, edge, mode, role, permanent, transaction } => { + cmd_panes::set_column_dock( + mux, + client, + actor, + pane, + dock, + edge, + mode, + role, + permanent, + transaction, + ) + } + Command::UndoLayout { pane, revision, confirm_close } => { + cmd_panes::undo_layout(mux, actor, pane, revision, confirm_close) + } + Command::PaneNeighbor { pane, dir } => cmd_panes::pane_neighbor(mux, pane, dir), + Command::FocusDirection { pane, dir } => cmd_panes::focus_direction(mux, actor, pane, dir), + Command::SwapPane { pane, dir, target } => { + cmd_panes::swap_pane(mux, actor, pane, dir, target) + } + Command::ZoomPane { pane, mode } => cmd_panes::zoom_pane(mux, actor, pane, mode), + Command::ProcessInfo { surface } => cmd_terminals::process_info(mux, surface), + Command::TerminalResources { surfaces } => cmd_terminals::terminal_resources(mux, surfaces), + Command::MoveTerminal { terminal_id, workspace_key, terminal_incarnation, mutation } => { + cmd_terminals::move_terminal( + mux, + client, + terminal_id, + workspace_key, + terminal_incarnation, + mutation, + ) + } + Command::MoveTabToWorkspace { surface, workspace, transaction } => { + cmd_tabs::move_tab_to_workspace(mux, actor, surface, workspace, transaction) + } + Command::MoveTabToSplit { + surface, + pane, + edge, + ratio, + respawn, + keep_focus, + transaction, + } => cmd_tabs::move_tab_to_split( + mux, + client, + surface, + pane, + edge, + ratio, + respawn, + keep_focus, + transaction, + ), + Command::MoveTabToColumn(params) => cmd_tabs::move_tab_to_column(mux, client, params), + Command::NewRow(params) => cmd_panes::new_row(mux, client, params), + Command::SetRowHeights(params) => cmd_panes::set_row_heights(mux, client, params), + Command::MoveTabToNewWorkspace { surface, group, index, name, transaction } => { + cmd_tabs::move_tab_to_new_workspace( + mux, + actor, + surface, + group, + index, + name, + transaction, + ) + } + Command::MoveTab { surface, pane, index, transaction } => { + cmd_tabs::move_tab(mux, actor, surface, pane, index, transaction) + } + Command::ListTabGroups => cmd_tabs::list_tab_groups(mux), + Command::CreateTabGroup { surfaces, name, color, group, transaction } => { + cmd_tabs::create_tab_group(mux, actor, surfaces, name, color, group, transaction) + } + Command::UpdateTabGroup { group, name, color, collapsed, icon } => { + cmd_tabs::update_tab_group(mux, actor, group, name, color, collapsed, icon) + } + Command::AddTabsToTabGroup { group, surfaces, transaction } => { + cmd_tabs::add_tabs_to_tab_group(mux, actor, group, surfaces, transaction) + } + Command::RemoveTabsFromTabGroup { surfaces, transaction } => { + cmd_tabs::remove_tabs_from_tab_group(mux, actor, surfaces, transaction) + } + Command::MoveTabGroup { group, pane, index, transaction } => { + cmd_tabs::move_tab_group(mux, actor, group, pane, index, transaction) + } + Command::MoveTabGroupToSplit { group, pane, edge, ratio, transaction } => { + cmd_tabs::move_tab_group_to_split(mux, actor, group, pane, edge, ratio, transaction) + } + Command::MoveTabGroupToColumn { group, pane, screen, after_column, width, transaction } => { + cmd_tabs::move_tab_group_to_column( + mux, + actor, + group, + pane, + screen, + after_column, + width, + transaction, + ) + } + Command::MoveTabGroupToNewWorkspace { group, workspace_group, index, transaction } => { + cmd_tabs::move_tab_group_to_new_workspace( + mux, + actor, + group, + workspace_group, + index, + transaction, + ) + } + Command::UngroupTabGroup { group } => cmd_tabs::ungroup_tab_group(mux, actor, group), + Command::CloseTabGroup { group, end_terminals } => { + cmd_tabs::close_tab_group(mux, actor, group, end_terminals) + } + Command::ListSavedTabGroups => cmd_tabs::list_saved_tab_groups(mux), + Command::SaveTabGroup { group } => cmd_tabs::save_tab_group(mux, actor, group), + Command::UnsaveTabGroup { group } => cmd_tabs::unsave_tab_group(mux, actor, group), + Command::DeleteSavedTabGroup { saved } => { + cmd_tabs::delete_saved_tab_group(mux, actor, saved) + } + Command::ReopenSavedTabGroup { saved, pane, transaction } => { + cmd_tabs::reopen_saved_tab_group(mux, actor, saved, pane, transaction) + } + Command::AckTabNotifications { surface } => cmd_tabs::ack_tab_notifications(mux, surface), + Command::ListNotifications { limit } => cmd_frontend::list_notifications(mux, limit), + Command::SetTabPinned { surface, pinned } => { + cmd_tabs::set_tab_pinned(mux, actor, surface, pinned) + } + Command::MoveWorkspace { workspace, key, index, mutation } => { + cmd_workspaces::move_workspace(mux, client, workspace, key, index, mutation) + } + Command::SetWorkspaceMetadata { + workspace, + key, + color, + icon, + title, + pinned, + marked_unread, + mutation, + } => cmd_workspaces::set_workspace_metadata( + mux, + client, + workspace, + key, + color, + icon, + title, + pinned, + marked_unread, + mutation, + ), + Command::ListPersonal => cmd_profiles::list_personal(mux), + Command::CreateBrowserProfile(params) => browser_profiles::create(mux, params), + Command::UpdateBrowserProfile(params) => browser_profiles::update(mux, params), + Command::MoveBrowserProfile(params) => browser_profiles::move_to(mux, params), + Command::DeleteBrowserProfile(params) => browser_profiles::delete(mux, client, params), + Command::ListBookmarks(params) => bookmarks::list(mux, params), + Command::CreateBookmark(params) => bookmarks::create(mux, &actor, params), + Command::UpdateBookmark(params) => bookmarks::update(mux, &actor, params), + Command::MoveBookmark(params) => bookmarks::move_to(mux, &actor, params), + Command::DeleteBookmark(params) => bookmarks::delete(mux, &actor, params), + Command::ImportBookmarks(params) => bookmarks::import(mux, &actor, params), + cmd @ (Command::FeedLocalList(_) + | Command::FeedLocalRead(_) + | Command::FeedLocalHandoffBegin(_) + | Command::FeedLocalHandoffAbort(_) + | Command::FeedLocalHandoffDone(_)) => feed_local::dispatch(mux, &actor, cmd), + Command::PutBlob(params) => icon_assets::put(mux, params), + Command::GetBlob(params) => icon_assets::get(mux, params), + Command::ConversationList => conversations::list(mux, client), + Command::ConversationCreate(params) => conversations::create(mux, client, params), + Command::ConversationSnapshot(params) => conversations::snapshot(mux, client, params), + Command::ConversationHistory(params) => conversations::history(mux, client, params), + Command::ConversationThread(params) => conversations::thread(mux, client, params), + Command::ConversationLinkResolve(params) => { + conversations::link_resolve(mux, client, params) + } + Command::ConversationInboxOp(params) => conversations::inbox_op(mux, client, params), + Command::ConversationSearch(params) => conversations::search(mux, client, params), + Command::HistorySearch(params) => history_search::search(mux, client, params), + Command::ConversationOp(params) => conversations::op(mux, client, params), + Command::ConversationTyping(params) => conversations::typing(mux, client, params), + Command::ConversationDraft(params) => conversations::draft(mux, client, params), + Command::ConversationBind(params) => conversations::bind(mux, client, params), + Command::ConversationAgentToken(params) => conversations::agent_token(mux, client, params), + Command::CloudSessionSet(params) => cloud_conversations::session_set(mux, client, params), + Command::CloudSessionClear => cloud_conversations::session_clear(mux, client), + Command::CloudSessionStatus => cloud_conversations::session_status(mux, client), + Command::PageCredential(params) => page_credential::page_credential(mux, client, params), + Command::WsListen(params) => page_listener::ws_listen(mux, client, params), + Command::CloudInboxList(params) => cloud_conversations::inbox_list(mux, client, params), + Command::CloudConversationSnapshot(params) => { + cloud_conversations::snapshot(mux, client, params) + } + Command::CloudConversationHistory(params) => { + cloud_conversations::history(mux, client, params) + } + Command::CloudConversationThread(params) => { + cloud_conversations::thread(mux, client, params) + } + Command::CloudConversationRuns(params) => cloud_conversations::runs(mux, client, params), + Command::CloudConversationRun(params) => cloud_conversations::run(mux, client, params), + Command::CloudParticipantAvatarGet(params) => { + cloud_conversations::participant_avatar(mux, client, params) + } + Command::CloudConversationOp(params) => cloud_conversations::op(mux, client, params), + Command::CloudInboxOp(params) => cloud_conversations::inbox_op(mux, client, params), + Command::CloudInboxSubscribe => cloud_conversations::subscribe(mux, client, None), + Command::CloudMuxSubscribe(_) => cloud_conversations::mux_subscribe(mux, client), + Command::CloudMuxUnsubscribe(_) => cloud_conversations::mux_unsubscribe(mux, client), + Command::CloudMuxAck(params) => cloud_conversations::mux_ack(mux, client, params), + Command::CloudInboxUnsubscribe => cloud_conversations::unsubscribe(mux, client, None), + Command::CloudConversationSubscribe(params) => { + cloud_conversations::subscribe(mux, client, Some(params)) + } + Command::CloudConversationUnsubscribe(params) => { + cloud_conversations::unsubscribe(mux, client, Some(params)) + } + Command::ConversationAttachmentUpload(p) => conversation_attachments::put(mux, client, p), + Command::ConversationAttachmentRead(p) => conversation_attachments::read(mux, client, p), + Command::ConversationAttachmentLink(p) => conversation_attachments::link(mux, client, p), + Command::CloudAttachmentUpload(p) => cloud_attachments::put(mux, client, p), + Command::CloudAttachmentRead(p) => cloud_attachments::read(mux, client, p), + Command::CloudAttachmentLink(p) => cloud_attachments::link(mux, client, p), + Command::ConversationRunAppend(p) => conversation_runs::append(mux, client, p), + Command::ConversationRuns(p) => conversation_runs::runs(mux, client, p), + Command::ConversationRun(p) => conversation_runs::run(mux, client, p), + Command::ConversationImport(params) => conversations::import(mux, client, params), + Command::CreateProfile { + name, + profile, + color, + icon, + theme, + index, + browser_profile_id, + default_session_id, + defaults, + follows, + } => cmd_profiles::create_profile( + mux, + name, + profile, + color, + icon, + theme, + index, + browser_profile_id, + default_session_id, + defaults, + follows, + ), + Command::UpdateProfile { + profile, + name, + color, + icon, + theme, + browser_profile_id, + default_session_id, + defaults, + } => cmd_profiles::update_profile( + mux, + profile, + name, + color, + icon, + theme, + browser_profile_id, + default_session_id, + defaults, + ), + Command::MoveProfile { profile, index } => cmd_profiles::move_profile(mux, profile, index), + Command::DeleteProfile { profile, move_to } => { + cmd_profiles::delete_profile(mux, client, profile, move_to) + } + Command::SetProfileFollows { profile, session_ids } => { + cmd_profiles::set_profile_follows(mux, profile, session_ids) + } + Command::PinWorkspace { session_id, workspace_key, profile } => { + cmd_workspaces::pin_workspace(mux, session_id, workspace_key, profile) + } + Command::UnpinWorkspace { session_id, workspace_key } => { + cmd_workspaces::unpin_workspace(mux, session_id, workspace_key) + } + Command::PutSession { + session_id, + machine_name, + session_name, + transport, + capabilities, + follow_with, + } => cmd_profiles::put_session( + mux, + session_id, + machine_name, + session_name, + transport, + capabilities, + follow_with, + ), + Command::ForgetSession { session_id, force } => { + cmd_profiles::forget_session(mux, session_id, force) + } + Command::SetSessionIcon(params) => personal::set_session_icon(mux, params), + Command::ImportSessionOrganization { session_id, groups, workspaces } => { + cmd_profiles::import_session_organization(mux, session_id, groups, workspaces) + } + Command::CreatePersonalGroup { name, group, profile, color, collapsed, index } => { + cmd_profiles::create_personal_group(mux, name, group, profile, color, collapsed, index) + } + Command::UpdatePersonalGroup { group, name, color, collapsed, profile } => { + cmd_profiles::update_personal_group(mux, group, name, color, collapsed, profile) + } + Command::DeletePersonalGroup { group } => { + cmd_profiles::delete_personal_group(mux, client, group) + } + Command::MovePersonalGroup { group, index } => { + cmd_profiles::move_personal_group(mux, group, index) + } + command @ Command::SetPersonalWorkspace { .. } => { + cmd_profiles::set_personal_workspace(mux, command) + } + Command::SetPersonalTerminal { session_id, terminal_key, theme } => { + cmd_profiles::set_personal_terminal(mux, session_id, terminal_key, theme) + } + Command::ListWorkspaceGroups => cmd_workspaces::list_workspace_groups(mux), + Command::CreateWorkspaceGroup { name, group, color, collapsed, index } => { + cmd_workspaces::create_workspace_group(mux, name, group, color, collapsed, index) + } + Command::UpdateWorkspaceGroup { group, name, color, collapsed } => { + cmd_workspaces::update_workspace_group(mux, group, name, color, collapsed) + } + Command::DeleteWorkspaceGroup { group } => { + cmd_workspaces::delete_workspace_group(mux, group) + } + Command::MoveWorkspaceGroup { group, index } => { + cmd_workspaces::move_workspace_group(mux, group, index) + } + Command::MoveWorkspaceToGroup { workspace, key, group, index, mutation } => { + cmd_workspaces::move_workspace_to_group( + mux, client, workspace, key, group, index, mutation, + ) + } + Command::SetDefaultColors { + fg, + bg, + cursor, + selection_bg, + selection_fg, + cursor_style, + cursor_blink, + palette, + complete, + } => cmd_terminal_io::set_default_colors( + mux, + fg, + bg, + cursor, + selection_bg, + selection_fg, + cursor_style, + cursor_blink, + palette, + complete, + ), + Command::CloseSurface { surface } => cmd_tabs::close_surface(mux, actor, surface), + Command::CloseTabs { surfaces, end_terminals, transaction, reason, mutation } => { + cmd_tabs::close_tabs( + mux, + client, + surfaces, + end_terminals, + transaction, + reason, + mutation, + ) + } + Command::ClosePane { pane, end_terminals } => { + cmd_panes::close_pane(mux, actor, pane, end_terminals) + } + Command::CloseScreen { screen, end_terminals } => { + cmd_screens::close_screen(mux, actor, screen, end_terminals) + } + Command::CloseWorkspace { workspace, key, end_terminals, reason, if_empty, mutation } => { + let ending = cmd_workspaces::Ending { terminals: end_terminals, reason, if_empty }; + cmd_workspaces::close_workspace(mux, client, workspace, key, ending, mutation) + } + Command::MarkWorkspacesProviderManaged { authority } => { + cmd_workspaces::mark_workspaces_provider_managed(mux, authority) + } + Command::CloseProviderManagedWorkspace { workspace, key, authority } => { + cmd_workspaces::close_provider_managed_workspace(mux, actor, workspace, key, authority) + } + Command::RenamePane { pane, name } => cmd_panes::rename_pane(mux, actor, pane, name), + Command::RenameSurface { surface, name } => { + cmd_tabs::rename_surface(mux, actor, surface, name) + } + Command::RenameScreen { screen, name } => { + cmd_screens::rename_screen(mux, actor, screen, name) + } + Command::RenameWorkspace { workspace, key, name, mutation } => { + cmd_workspaces::rename_workspace(mux, client, workspace, key, name, mutation) + } + Command::RenameProviderManagedWorkspace { workspace, key, name, authority } => { + cmd_workspaces::rename_provider_managed_workspace( + mux, actor, workspace, key, name, authority, + ) + } + Command::ResizeSurface { surface, cols, rows } => { + cmd_sizing::resize_surface(mux, client, surface, cols, rows) + } + Command::ResizeAttachedView { surface, lease, view, identity, cols, rows } => { + cmd_sizing::resize_attached_view( + mux, client, surface, lease, view, identity, cols, rows, + ) + } + Command::ReleaseSurfaceSize { surface } => { + cmd_sizing::release_surface_size(mux, client, surface) + } + Command::ReleaseAttachedViewSize { surface, lease, view } => { + cmd_sizing::release_attached_view_size(mux, client, surface, lease, view) + } + Command::DetachAttachedView { surface, lease, view } => { + cmd_attach::detach_attached_view(mux, client, surface, lease, view) + } + Command::FocusPane { pane } => cmd_panes::focus_pane(mux, actor, pane), + Command::SelectTab { pane, index, delta } => { + cmd_tabs::select_tab(mux, actor, pane, index, delta) + } + Command::SelectScreen { index, delta } => { + cmd_screens::select_screen(mux, actor, index, delta) + } + Command::SelectWorkspace { index, delta } => { + cmd_workspaces::select_workspace(mux, actor, index, delta) + } + Command::ReportFocus { client_id, pane, tab } => { + cmd_server::report_focus(mux, client_id, pane, tab) + } + Command::ClientFocus { client_id } => cmd_server::client_focus(mux, client_id), + Command::SnapshotRequest(params) => cmd_terminal_io::snapshot_request(mux, client, params), + Command::TerminalHistory(params) => cmd_terminals::terminal_history(mux, params), + Command::TerminalReadRange(params) => cmd_terminals::terminal_read_range(mux, params), + Command::ScrollSurface { surface, delta } => { + cmd_terminal_io::scroll_surface(mux, surface, delta) + } + Command::Subscribe { tree_events, surface } => { + cmd_subscribe::subscribe_command(mux, client, writer, tree_events, surface) + } + Command::AttachSurface { + surface: surface_id, + mode, + cols, + rows, + expected_generation, + expected_terminal_id, + snapshot, + } => cmd_attach::attach_surface( + mux, + client, + writer, + surface_id, + mode, + cols, + rows, + expected_generation, + expected_terminal_id, + snapshot, + ), + } +} diff --git a/cmux-tui/crates/cmux-tui-core/src/server/connection_frame.rs b/cmux-tui/crates/cmux-tui-core/src/server/connection_frame.rs new file mode 100644 index 000000000000..e1c22ada802b --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/server/connection_frame.rs @@ -0,0 +1,287 @@ +//! One frame of a connection: authentication and pairing frames, a request's +//! dispatch with its cancellation, and the VT-state command reply. Moved out of +//! server.rs unchanged (cx-6so.3). + +use super::*; + +/// One frame of a connection. `transport` is the connection's own value +/// (not a registry lookup), so a remote-entry connection always takes the +/// remote path, also after its registry record is gone. +pub(super) fn handle_connection_frame( + mux: &Arc, + client: u64, + transport: ClientTransport, + message: &str, + writer: &MessageWriter, + scheduler: &Arc, +) -> bool { + // Keep an idle shutdown requester connected until owner cleanup closes + // the transport, so lifecycle clients receive authoritative completion. + // A pipelined message after the acknowledgement must not reach parsing or + // dispatch; returning false makes the connection loop close that client. + if mux.daemon_shutdown_requested() || mux.daemon_handoff_committed() { + return false; + } + if matches!(transport, ClientTransport::Remote) || mux.is_remote_client(client) { + return remote_relay::handle_frame(mux, client, message, writer); + } + // Before the acknowledgement the handoff can still fail (for example + // while `end_terminals` awaits every host) and this daemon keeps + // serving. Closing here would drop the requester's pending + // `shutdown-daemon` reply along with its connection, so a message that + // arrives meanwhile (a subscriber's snapshot refresh) is refused + // without being executed and the connection stays open. + if mux.daemon_handoff_in_progress() { + return pending_handoff::reject_message_during_pending_handoff(message, writer); + } + if let Some(request) = crate::resource_router::parse_resource_line(message) { + // A v2 line the catalog parse refuses may name a routed namespace + // operation (page-router-v1): the same JSON parse decides, so every + // spelling of the line gets one answer. + if request.is_err() + && let Some(keep_open) = page_router::try_handle(mux, client, message, writer) + { + return keep_open; + } + return origin_gate::handle_resource_line(mux, client, message, request, writer); + } + if let Some(keep_open) = loopback_forward::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = browser_runtime::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = agent_session_attach::try_handle(mux, client, message, writer) + .or_else(|| agent_transfer::try_handle(mux, client, message, writer)) + { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = apps::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = scripts::try_handle(mux, client, message, writer) { + return keep_open; + } + #[cfg(unix)] + if let Some(keep_open) = fs_wire::try_handle(mux, client, message, writer) { + return keep_open; + } + let request = match serde_json::from_str::(message) { + Ok(request) => request, + Err(error) => return send_bad_request(writer, message, &error), + }; + let mut pending = Some(request); + match scheduler.dispatch(mux.clone(), client, &mut pending, message.len(), writer.clone()) { + Some(keep_open) => keep_open, + None => handle_request(mux, client, pending.take().unwrap(), writer), + } +} + +pub(super) fn handle_request( + mux: &Arc, + client: u64, + request: Request, + writer: &MessageWriter, +) -> bool { + handle_request_with_cancellation(mux, client, request, writer, None) +} + +pub(super) fn handle_request_with_cancellation( + mux: &Arc, + client: u64, + request: Request, + writer: &MessageWriter, + cancellation: Option<&ConnectionCancellation>, +) -> bool { + let Request { id, cmd } = request; + if let Command::UrlOpen { terminal_id, url } = cmd { + return url_open::start(mux, client, id, terminal_id, url, writer); + } + if let Command::ChiefInspect(params) = cmd { + return chief_inspect::start(mux, client, id, params, writer); + } + if cloud_conversations::is_network(&cmd) { + return cloud_conversations::start(mux, client, id, cmd, writer); + } + if let Some(target) = cloud_conversations::subscribe_target(mux, &cmd) { + return cloud_conversations::subscribe_then_announce(mux, client, id, cmd, target, writer); + } + if matches!(&cmd, Command::ShutdownDaemon { .. } | Command::ReloadConfig) + && !mux.server_lifecycle_ready() + { + return send_request_error(writer, id, "server lifecycle is not ready"); + } + if let Command::VtState { surface } = &cmd { + return match send_vt_state_command_response(mux, id.clone(), *surface, writer) { + Ok(()) => true, + Err(error) => send_request_error(writer, id, &error.to_string()), + }; + } + + let detach_self = match &cmd { + Command::DetachClient { client: target, by, surface } + if target.whole_client() == Some(client) + && own_view_detach_target(mux, target, *surface).is_none() => + { + Some(detach_actor(mux, client, by.clone())) + } + _ => None, + }; + let shutdown_daemon = matches!(&cmd, Command::ShutdownDaemon { .. }); + let (mut reason, mut retryable, mut details) = (None, None, None); + let response = match handle_command_with_cancellation(mux, client, cmd, writer, cancellation) { + Ok(data) => Response { + id, + ok: true, + data: Some(data), + error: None, + error_code: None, + error_delivery: None, + }, + Err(error) => { + reason = conversations::error_reason(&error) + .or_else(|| cloud_conversations::error_reason(&error)); + retryable = cloud_conversations::error_retryable(&error); + details = renderer_grant::error_details(&error) + .or_else(|| conversations::error_details(&error)); + let error_code = response_error_code(&error); + let error_delivery = + error.downcast_ref::().map(|error| error.delivery); + Response { + id, + ok: false, + data: None, + error: Some(error.to_string()), + error_code, + error_delivery, + } + } + }; + let (response, reason) = remote_relay::redact_response(mux, client, response, reason); + let details = details.filter(|_| !mux.is_remote_client(client)); + let response_ok = response.ok; + let sent = responses::send_response_with_details(writer, response, reason, retryable, details); + // Flush the successful acknowledgement before making the owning loop + // leave, so process teardown cannot race the response writer. + if shutdown_daemon && response_ok { + if sent { + return complete_daemon_shutdown_after_ack(mux, client, writer); + } else { + mux.cancel_daemon_handoff(client); + } + } + if let Some(by) = detach_self + && response_ok + && sent + { + kick_client(mux, client, by); + return false; + } + sent +} + +fn send_vt_state_command_response( + mux: &Mux, + id: Option, + surface: SurfaceId, + writer: &MessageWriter, +) -> anyhow::Result<()> { + // Reserve the entire wire-frame allowance before copying a replay or + // starting its base64 encoder. The writer allocates only for actual + // output and releases unused logical quota before the response is queued. + let mut output = writer.render_service.reserved_control_writer()?; + let surface = get_surface(mux, surface)?; + require_pty(&surface)?; + let (cols, rows, replay) = surface.try_with_terminal(|terminal| { + terminal + .vt_replay_bounded(crate::surface::VT_REPLAY_MAX_BYTES) + .map(|replay| (terminal.cols(), terminal.rows(), replay)) + })??; + + write_vt_state_command_json( + &mut output, + id.as_ref(), + cols, + rows, + &replay.self_contained_bytes(), + &replay.kitty_image_aliases, + replay.kitty_state, + )?; + writer.send_serialized_control(output.finish())?; + Ok(()) +} + +pub(super) fn write_vt_state_command_json( + output: &mut BudgetedJsonWriter, + id: Option<&Value>, + cols: u16, + rows: u16, + replay: &[u8], + kitty_image_aliases: &[ghostty_vt::KittyImageAlias], + kitty_state: KittyReplayState, +) -> std::io::Result<()> { + output.write_all(b"{")?; + if let Some(id) = id { + output.write_all(b"\"id\":")?; + serde_json::to_writer(&mut *output, id).map_err(json_error_to_io)?; + output.write_all(b",")?; + } + write!(output, "\"ok\":true,\"data\":{{\"cols\":{cols},\"rows\":{rows},\"data\":\"")?; + { + let mut encoder = base64::write::EncoderWriter::new( + &mut *output, + &base64::engine::general_purpose::STANDARD, + ); + encoder.write_all(replay)?; + encoder.finish()?; + } + output.write_all(b"\",\"kitty_image_aliases\":")?; + write_kitty_image_aliases_json(output, kitty_image_aliases)?; + output.write_all(b",\"kitty_graphics_state\":")?; + write_kitty_replay_state_json(output, kitty_state)?; + output.write_all(b"}}")?; + Ok(()) +} + +pub(super) fn auth_token(message: &str) -> Option { + let value: Value = serde_json::from_str(message).ok()?; + let object = value.as_object()?; + if object.len() != 1 { + return None; + } + let auth = object.get("auth")?.as_object()?; + if auth.len() != 1 { + return None; + } + auth.get("token")?.as_str().map(str::to_string) +} + +pub(super) fn pairing_request(message: &str) -> bool { + let Ok(value) = serde_json::from_str::(message) else { return false }; + let Some(object) = value.as_object() else { return false }; + if object.len() != 1 { + return false; + } + let Some(pair) = object.get("pair").and_then(Value::as_object) else { return false }; + pair.len() == 1 && pair.get("request").and_then(Value::as_bool) == Some(true) +} + +pub(super) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + let mut difference = a.len() ^ b.len(); + let length = a.len().max(b.len()); + for index in 0..length { + difference |= + usize::from(a.get(index).copied().unwrap_or(0) ^ b.get(index).copied().unwrap_or(0)); + } + difference == 0 +} + +pub(super) fn zeroize_string(value: &mut str) { + // NUL remains valid UTF-8, so decoded control frames can be cleared in + // place immediately after dispatch. + value.zeroize(); +} diff --git a/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs b/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs index 68ac69035e9d..db9e9977add4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/conversations.rs @@ -579,9 +579,12 @@ pub(super) fn bind(mux: &Mux, client: u64, params: BindParams) -> anyhow::Result crate::conversation_store::credentialed(&participant), "only agent participants and other people bind with a token" ); - let valid = mux.with_conversations(|store| store.verify_agent_token(&participant, &token))?; + // The check and the binding under one lock: a credential that + // chief.profile.adopt revoked never binds after the adopt. + let valid = mux.bind_conversation_principal_if(client, participant.clone(), || { + mux.with_conversations(|store| store.verify_agent_token(&participant, &token)) + })?; anyhow::ensure!(valid, "conversation agent token is not valid for {participant}"); - mux.bind_conversation_principal(client, participant.clone())?; Ok(json!({"participant": participant})) } @@ -599,7 +602,17 @@ pub(super) fn agent_token( crate::conversation_store::credentialed(¶ms.participant), "participant tokens are only for agents and other people" ); - let token = mux.with_conversations(|store| store.mint_agent_token(¶ms.participant))?; + // A Chief record's credential (other than the default Chief's): with a + // brain supervisor only its brain gets one (minted in process); without + // one, only the person's own terminal mints it, never a page, a paired or + // remote device or an agent (chief.profile.adopt, hq-6d 2026-10-14). The + // check and the mint run under the registry lock, so an adopt cannot land + // between them. + let terminal = !mux.is_remote_client(client) + && super::origin_gate::connection_page(mux, client).is_none() + && !super::origin_gate::is_page(mux, client); + let token = + super::chief_registry_resource::mint_credential(mux, ¶ms.participant, terminal)?; // A replaced token also ends the connections bound with the old one. mux.unbind_conversation_participant(¶ms.participant); Ok(json!({"participant": params.participant, "token": token})) diff --git a/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs b/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs index 16c0edb27ad1..fc04ffe05a30 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/origin_gate.rs @@ -141,6 +141,12 @@ pub(super) fn is_user(mux: &Mux, client: u64) -> bool { derived_origin(mux, client) == RequestOrigin::User } +/// Whether `client` derives the page origin (a page WebSocket or the host's +/// page relay, with or without a page id). +pub(super) fn is_page(mux: &Mux, client: u64) -> bool { + derived_origin(mux, client) == RequestOrigin::Page +} + /// The origin `client` derives; a client with no record is an agent. fn derived_origin(mux: &Mux, client: u64) -> RequestOrigin { let state = mux.control_clients.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs index c899dc148def..6056da3c6481 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/page_tabs_tests.rs @@ -274,6 +274,34 @@ fn history_closed_rows_are_only_things_worth_reopening() { mux.shutdown(); } +/// Reopening a page closed several times takes every close of it out of Recently Closed: the +/// page is open again, so no older close becomes its row and Cmd-Shift-T never opens it twice. +#[test] +fn history_closed_reopening_a_page_leaves_no_older_close_of_it() { + let mux = test_mux("history-closed-reopen-once"); + let (_, pane) = terminal_pane(&mux); + let browser = |url: &str| json!({"cmd":"new-frontend-browser-tab","pane":pane,"url":url,"engine":"webkit"}); + for url in ["https://example.com/", "https://cmux.com/", "https://example.com/"] { + let created = run(&mux, browser(url)).unwrap(); + run(&mux, json!({"cmd":"close-surface","surface":surface_of(&created)})).unwrap(); + } + let closed = |mux: &Arc| { + let listed = resource(mux, "history.entries.list", json!({"kinds":["closed"]}), None); + listed["entries"].as_array().unwrap().clone() + }; + let shown = |entries: &[Value]| -> Vec { + entries.iter().map(|entry| entry["detail"].as_str().unwrap_or("-").to_owned()).collect() + }; + let entries = closed(&mux); + assert_eq!(shown(&entries), ["https://example.com/", "https://cmux.com/"], "{entries:?}"); + + let example = entries[0]["id"].as_str().unwrap().strip_prefix("closed:daemon:").unwrap(); + resource(&mux, "closed.reopen", json!({"closed": example}), Some("reopen-example")); + let entries = closed(&mux); + assert_eq!(shown(&entries), ["https://cmux.com/"], "{entries:?}"); + mux.shutdown(); +} + /// A page id is 1 to 64 lowercase letters, digits or '-', '_', '.'; a page /// and another source together are refused. #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs b/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs index a6398dbdae70..7692cd36d823 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/tests/resource_attach.rs @@ -175,7 +175,7 @@ fn connection_handler_owns_every_router_connection_operation() { ); connection_operations += usize::from(requires_connection); } - assert_eq!(connection_operations, 53); + assert_eq!(connection_operations, 54); } #[test] diff --git a/cmux-tui/crates/cmux-tui-core/src/state.rs b/cmux-tui/crates/cmux-tui-core/src/state.rs index 31ee6ccfe820..025f67ff74a0 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state.rs @@ -34,6 +34,7 @@ pub(crate) mod closed_history_store; mod closed_history_tests; #[cfg(test)] mod closed_relaunch_tests; +mod closed_tab_groups; pub(crate) mod command_history_store; pub(crate) mod commit; pub(crate) mod conversation_tabs; diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs index 5bd203de92cd..65a0fe18025f 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_history.rs @@ -13,11 +13,12 @@ //! request's result, so a retry with the same key replays it. use crate::Actor; -use crate::mux::tab_groups::pane_by_public_id; +use crate::mux::tab_groups::{group_members, pane_by_public_id}; use crate::mux::tab_strip::StripRequest; use crate::mux::*; use crate::state::closed_history_query::{ - closed_record, keep_members, newest_for_window, remove_closed, + closed_page, closed_record, keep_members, newest_for_window, public_item, remove_closed, + remove_other_closes, }; use crate::state::commit::{StateEffects, state_not_found}; use crate::state::conversation_tabs::ConversationTabTarget; @@ -140,8 +141,15 @@ fn commit_reopen( let tab_group = ["tab_group", "screen_group"] .iter() .any(|field| record.get(*field).is_some_and(Value::is_object)); + // A page reopened is open again: every other close of it leaves with this one, so none + // takes its Recently Closed row or reopens it a second time (Leo 2026-10-14). + let page = if tab_group { None } else { closed_page(&public_item(&record)).map(str::to_owned) }; + let mut others = Vec::new(); let change = if keep.is_empty() { remove_closed(transaction, closed_id)?; + if let Some(page) = &page { + others = remove_other_closes(transaction, page)?; + } state_delete("closed", closed_id) } else { state_upsert("closed", closed_id, keep_members(transaction, closed_id, record, keep)?) @@ -160,6 +168,7 @@ fn commit_reopen( }) .context("reopened item has no workspace")?; let mut changes = vec![change]; + changes.extend(others); let room_restored = match &room { Some(room) => { let local = crate::state::values::local_registry_id(transaction)?; @@ -282,11 +291,18 @@ impl Mux { for member in &restore { self.reopen_member(&mutation.actor, &closed_id, member, &mut reopened)?; } - if let Some(group) = record.get("tab_group").filter(|group| group.is_object()) { - // A closed group (Close Group) names its closed tabs: it forms - // again around those this reopen brought back. + // An ungrouped group (`tab_group`, no member) forms again whole. The + // groups a close took (`tab_groups`; `tab_group` on a Close Group + // recorded before cx-gzh.31) name their closed tabs: each forms + // again around those this reopen brought back. + let closed = !restore.is_empty(); + let groups = record + .get("tab_group") + .filter(|group| group.is_object()) + .into_iter() + .chain(record["tab_groups"].as_array().into_iter().flatten()); + for group in groups { let mut group = group.clone(); - let closed = !restore.is_empty(); if closed { let tabs = group["tab_ids"] .as_array() @@ -294,6 +310,9 @@ impl Mux { .flatten() .filter_map(|tab| reopened.reopened_as.get(tab.as_str()?).cloned()) .collect::>(); + if tabs.is_empty() { + continue; + } group["tab_ids"] = serde_json::json!(tabs); } self.reopen_tab_group(&mutation.actor, &group, closed, &mut reopened)?; @@ -360,7 +379,10 @@ impl Mux { ) -> anyhow::Result<()> { let Some(id) = archive["id"].as_str().map(str::to_string) else { return Ok(()) }; let presentation = self.presentation_snapshot(); - let live = presentation.tab_groups.groups.contains_key(&id); + // A group row a close left behind (no strip edit pruned it yet) + // holds no open tab: it is not live, and forms again. + let live = self + .with_state(|state| !group_members(state, &presentation.tab_groups, &id).is_empty()); if !live || closed { let wanted = archive["tab_ids"] .as_array() diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs index 30957477eb28..b2990e9431ec 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_query.rs @@ -141,6 +141,78 @@ pub(crate) fn public_item(record: &Value) -> Value { item } +/// Every tab a public item (`closed.list` shape) would recreate: every member's, else the top +/// level's (a v1 item), as the Swift `ClosedItem.tabs`. +pub(crate) fn closed_tabs(item: &Value) -> Vec<&Value> { + fn tabs_of(screens: Option<&Value>) -> Vec<&Value> { + screens + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|screen| screen.get("tabs").and_then(Value::as_array)) + .flatten() + .collect() + } + match item.get("members").and_then(Value::as_array).filter(|members| !members.is_empty()) { + Some(members) => members.iter().flat_map(|member| tabs_of(member.get("screens"))).collect(), + None => tabs_of(item.get("screens")), + } +} + +/// The page a public item (`closed.list` shape) is only, by its address: a closed tab of one +/// browser on a real address. Recently Closed shows a page once however often it was closed, +/// removing or reopening that row takes every close of it, and the Swift +/// `DaemonClosedHistory.pageKey` keys a page the same way (Leo 2026-10-14). A terminal, chat, +/// page tab, screen, workspace, window, group or close of several tabs is no page: nothing tells +/// one close of it from another (two shells in one folder), so each close stays its own. +pub(crate) fn closed_page(item: &Value) -> Option<&str> { + if item.get("kind").and_then(Value::as_str) != Some("tab") + || !item.get("group").is_none_or(Value::is_null) + { + return None; + } + let tabs = closed_tabs(item); + let &[tab] = tabs.as_slice() else { return None }; + let empty = tab.get("empty").and_then(Value::as_bool) == Some(true); + if tab.get("kind").and_then(Value::as_str) != Some("browser") + || empty + || !tab.get("page").is_none_or(Value::is_null) + { + return None; + } + tab.get("url").and_then(Value::as_str).filter(|url| !url.is_empty() && *url != "about:blank") +} + +/// Removes, in a reopen's commit, every other group that is only the page `page` +/// ([`closed_page`]; a closed tab group of that one page forms its group again, so it stays): +/// the page is open again, so no older close of it takes its Recently Closed row or reopens it +/// a second time. Answers the public changes. Best effort: a group it cannot read stays (and +/// so does every group, when the list cannot be read), so the reopen whose tabs already exist +/// still commits. +pub(crate) fn remove_other_closes( + transaction: &Transaction<'_>, + page: &str, +) -> anyhow::Result> { + let mut changes = Vec::new(); + let Ok(items) = closed_items(transaction) else { return Ok(changes) }; + for item in items { + let Some(id) = item.get("id").and_then(Value::as_str) else { continue }; + if closed_page(&item) != Some(page) { + continue; + } + let Ok(record) = closed_record(transaction, id) else { continue }; + let grouped = record.is_some_and(|record| { + ["tab_group", "screen_group"] + .iter() + .any(|key| record.get(*key).is_some_and(Value::is_object)) + }); + if !grouped && remove_closed(transaction, id)? { + changes.push(crate::state::store::state_delete("closed", id)); + } + } + Ok(changes) +} + /// The newest [`DEFAULT_LIST_LIMIT`] groups of every window. pub(crate) fn closed_items(connection: &Connection) -> anyhow::Result> { closed_items_in(connection, None, DEFAULT_LIST_LIMIT) diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs index 6260801481e0..4b5bdc39c4d4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_history_store.rs @@ -382,11 +382,16 @@ fn insert_group( transaction: &Transaction<'_>, members: Vec, window: Option, + tab_groups: Vec, ) -> anyhow::Result<()> { - let (closed_id, extra) = match take_pending_group(transaction)? { + let (closed_id, mut extra) = match take_pending_group(transaction)? { Some((closed_id, extra)) => (closed_id, Some(extra)), None => (new_closed_id(), None), }; + if !tab_groups.is_empty() { + let fields = extra.get_or_insert_with(|| json!({})); + fields["tab_groups"] = Value::Array(tab_groups); + } insert_record(transaction, closed_id, members, window, extra) } @@ -530,68 +535,6 @@ pub(crate) fn record_ungrouped_group( insert_record(transaction, new_closed_id(), Vec::new(), window, Some(extra)) } -/// Close of a whole tab group: `archive` (as [`record_ungrouped_group`] -/// takes it, naming the closed tabs) rides on the group that recorded its -/// tabs, so reopening them forms the group again. Nothing recorded them -/// (an incognito workspace): nothing to attach. -pub(crate) fn attach_closed_tab_group( - transaction: &Transaction<'_>, - archive: &Value, -) -> anyhow::Result<()> { - let Some(first) = archive["tab_ids"].get(0).and_then(Value::as_str) else { return Ok(()) }; - if !super::store::state_tables_ready(transaction)? { - return Ok(()); - } - let found = transaction - .query_row( - "SELECT g.closed_id, g.record_json FROM closed_groups AS g - WHERE EXISTS (SELECT 1 FROM json_tree(g.record_json, '$.members') AS t - WHERE t.key = 'tab_id' AND t.atom = ?1) - ORDER BY g.seq DESC LIMIT 1", - [first], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), - ) - .optional()?; - let Some((closed_id, record)) = found else { return Ok(()) }; - let mut record: Value = serde_json::from_str(&record)?; - // Only the group this close recorded holds every one of its tabs, as - // tab members or inside the screen the close emptied. - let mut recorded = HashSet::new(); - closed_tab_ids(&record["members"], &mut recorded); - if !archive["tab_ids"] - .as_array() - .into_iter() - .flatten() - .all(|tab| tab.as_str().is_some_and(|tab| recorded.contains(tab))) - { - return Ok(()); - } - record["tab_group"] = archive.clone(); - transaction.execute( - "UPDATE closed_groups SET record_json = ?2 WHERE closed_id = ?1", - params![closed_id, serde_json::to_string(&record)?], - )?; - queue_change(transaction, &state_upsert("closed", &closed_id, public_item(&record))) -} - -/// Every closed tab id in `value`, at any depth. -fn closed_tab_ids<'a>(value: &'a Value, ids: &mut HashSet<&'a str>) { - match value { - Value::Array(items) => items.iter().for_each(|item| closed_tab_ids(item, ids)), - Value::Object(fields) => { - for (key, field) in fields { - match field.as_str() { - Some(id) if key == "tab_id" => { - ids.insert(id); - } - _ => closed_tab_ids(field, ids), - } - } - } - _ => {} - } -} - /// A new closed group id. pub(crate) fn new_closed_id() -> String { format!("closed_{}", new_uuid_v4().replace('-', "")) @@ -628,7 +571,7 @@ pub(crate) fn flush_pending_group(transaction: &Transaction<'_>) -> anyhow::Resu let pending: bool = transaction .query_row("SELECT EXISTS(SELECT 1 FROM closed_group_pending)", [], |row| row.get(0))?; - if pending { insert_group(transaction, Vec::new(), None) } else { Ok(()) } + if pending { insert_group(transaction, Vec::new(), None, Vec::new()) } else { Ok(()) } } /// The highest level among the members: a group with a screen is a screen @@ -741,6 +684,15 @@ pub(crate) fn capture_closed( } let closing_workspaces = closing.workspaces.iter().cloned().collect::>(); let closing_screens = closing.screens.iter().cloned().collect::>(); + // Before `capture_workspace` forgets which closing workspaces are + // ephemeral: their groups are never recorded. + let closing_tabs = closing.tabs.iter().cloned().collect::>(); + let tab_groups = super::closed_tab_groups::closing_tab_groups( + transaction, + &closing_tabs, + &closing_screens, + &closing_workspaces, + )?; let mut capture = Capture::default(); for workspace_id in &closing.workspaces { capture_workspace(transaction, workspace_id, &mut capture)?; @@ -770,7 +722,7 @@ pub(crate) fn capture_closed( if capture.members.is_empty() { return Ok(()); } - insert_group(transaction, capture.members, capture.window) + insert_group(transaction, capture.members, capture.window, tab_groups) } fn capture_screen( diff --git a/cmux-tui/crates/cmux-tui-core/src/state/closed_tab_groups.rs b/cmux-tui/crates/cmux-tui-core/src/state/closed_tab_groups.rs new file mode 100644 index 000000000000..8e64e88a8781 --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/state/closed_tab_groups.rs @@ -0,0 +1,87 @@ +//! The tab groups a close takes with it (cx-gzh.31). Each group with a +//! closing tab rides on the closed-history group as an archive naming its +//! closed tabs, so reopening them forms it again: Close Group, a closed +//! screen, pane or workspace, or the last tab of a group. + +use std::collections::HashSet; + +use rusqlite::{Connection, OptionalExtension}; +use serde_json::{Value, json}; + +/// The archives of the live tab groups holding a tab that closes: one in +/// `tabs`, or in a screen of `screens` or a workspace of `workspaces`. +/// Each names only its closing tabs, in strip order, in the fields +/// `record_ungrouped_group` takes. Groups in an ephemeral workspace are +/// never recorded. +pub(super) fn closing_tab_groups( + connection: &Connection, + tabs: &HashSet, + screens: &HashSet, + workspaces: &HashSet, +) -> anyhow::Result> { + let ready = connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'tab_group_members'", + [], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !ready { + return Ok(Vec::new()); + } + let rows = { + let mut statement = connection.prepare( + "SELECT g.group_id, g.pane_id, g.name, g.color, g.collapsed, g.icon, + m.tab_id, s.public_id, s.workspace_id + FROM tab_group_members AS m + JOIN tab_groups AS g ON g.group_id = m.group_id + JOIN resource_tabs AS t ON t.public_id = m.tab_id AND t.deleted_revision IS NULL + AND t.pane_id = g.pane_id + JOIN resource_panes AS p ON p.public_id = t.pane_id + JOIN resource_screens AS s ON s.public_id = p.screen_id + ORDER BY g.group_id ASC, t.position ASC", + )?; + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + )) + })? + .collect::, _>>()? + }; + let mut archives: Vec = Vec::new(); + for (group, pane, name, color, collapsed, icon, tab, screen, workspace) in rows { + if !(tabs.contains(&tab) || screens.contains(&screen) || workspaces.contains(&workspace)) + || super::closed_history_store::is_ephemeral(connection, &workspace)? + { + continue; + } + match archives.last_mut().filter(|archive| archive["id"] == group.as_str()) { + Some(archive) => { + if let Some(tab_ids) = archive["tab_ids"].as_array_mut() { + tab_ids.push(json!(tab)); + } + } + None => archives.push(json!({ + "id": group, + "pane_id": pane, + "workspace_id": workspace, + "name": name, + "color": color, + "collapsed": collapsed != 0, + "icon": icon, + "tab_ids": [tab], + })), + } + } + Ok(archives) +} diff --git a/cmux-tui/crates/cmux-tui-core/src/surface/terminal_stream.rs b/cmux-tui/crates/cmux-tui-core/src/surface/terminal_stream.rs index 51cadac88c87..03b27238448b 100644 --- a/cmux-tui/crates/cmux-tui-core/src/surface/terminal_stream.rs +++ b/cmux-tui/crates/cmux-tui-core/src/surface/terminal_stream.rs @@ -208,4 +208,46 @@ impl Surface { }; Ok(f(&mut pty.term.lock().unwrap())) } + + /// Abort an unreplayable parser sequence and run a capture while the + /// terminal/output fence is held. The synthetic CAN bytes are published to + /// byte attachments before the fence is released, so their parsers stay + /// synchronized with the authoritative terminal. + pub(crate) fn discard_unreplayable_replay_with( + &self, + f: impl FnOnce(&mut Terminal) -> R, + ) -> anyhow::Result> { + let Some(pty) = self.as_pty() else { + anyhow::bail!("browser surface does not have a VT terminal"); + }; + // A connected terminal host owns the authoritative parser. The daemon + // terminal is only a mirror, so injecting CAN here would leave the two + // parsers at different sequence boundaries when the next host output + // frame arrives. Source-side recovery needs a host protocol command; + // refuse this local-only mutation until one exists. + #[cfg(unix)] + if matches!( + &*pty.runtime.lock().unwrap_or_else(std::sync::PoisonError::into_inner), + PtyRuntime::Hosted(_) + ) { + anyhow::bail!("cannot discard unreplayable replay on a hosted terminal"); + } + let mut terminal = pty.term.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + // The reader releases this lock before journaling its parsed frame. + // Do not let synthetic CAN overtake that frame in journal ingress. + anyhow::ensure!( + pty.journal_capture_epoch.load(Ordering::Acquire) & 1 == 0, + "terminal journal ingress is unsettled during replay capture" + ); + let Some(output) = terminal.discard_unreplayable_replay() else { + return Ok(None); + }; + pty.observe_terminal_output(&output); + pty.broadcast_attach_output(&output); + if let Some(journal_target) = pty.journal_target() { + pty.journal_output_if_open(journal_target, output); + } + pty.stream_progress.notify(); + Ok(Some(f(&mut terminal))) + } } diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs index d00402c58a20..014692239199 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/attachment/launch.rs @@ -150,9 +150,9 @@ pub(crate) fn launch_terminal_host_seeded( // endpoint short; the private durable record still carries its full // canonical identity and owner capability. let uid = sys::file_owner(root)?; - let endpoint_root = sys::endpoint_dir(uid); - prepare_endpoint_dir(&endpoint_root)?; - let endpoint = endpoint_root.join(format!("{terminal_hex}.sock")); + prepare_endpoint_dir(&sys::endpoint_dir(uid))?; + prepare_endpoint_dir(&sys::host_endpoint_dir(uid, root))?; + let endpoint = sys::host_endpoint(uid, root, &terminal_hex); let record_path = crate::platform::normalize_filesystem_path(root.join(format!("{terminal_hex}.json"))); if record_path.exists() || endpoint.exists() { diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs index a71729b00f31..9e7bb524be1e 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/records.rs @@ -103,8 +103,8 @@ pub fn validate_terminal_host_record( anyhow::bail!("terminal-host record filename is not canonical"); } let uid = sys::file_owner(parent)?; - let expected_endpoint = sys::canonical_endpoint(uid, &record.terminal_id); - if Path::new(&record.endpoint) != expected_endpoint { + let expected = sys::canonical_endpoints(uid, parent, &record.terminal_id); + if !expected.iter().any(|endpoint| Path::new(&record.endpoint) == endpoint) { anyhow::bail!("terminal-host endpoint is not canonical"); } if let Ok(metadata) = fs::symlink_metadata(record_path) diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs index b4cc8087dca1..3c1846408523 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/shared/unadoptable.rs @@ -161,11 +161,13 @@ pub fn wait_for_unadoptable_terminal_host_exit( Err(error) => return Err(error.into()), } let Some(parent) = record.record_path.parent() else { return Ok(()) }; - let endpoint = sys::canonical_endpoint(sys::file_owner(parent)?, &record.terminal_id); + let endpoints = sys::canonical_endpoints(sys::file_owner(parent)?, parent, &record.terminal_id); let _ = fs::remove_file(&record.record_path); let _ = fs::remove_file(marker); - if fs::symlink_metadata(&endpoint).is_ok_and(|metadata| sys::is_endpoint_file(&metadata)) { - let _ = fs::remove_file(endpoint); + for endpoint in endpoints { + if fs::symlink_metadata(&endpoint).is_ok_and(|metadata| sys::is_endpoint_file(&metadata)) { + let _ = fs::remove_file(endpoint); + } } Ok(()) } diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs index c564af6254ce..ac17b2163f7d 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/unix.rs @@ -80,11 +80,78 @@ pub(crate) fn endpoint_dir(owner: FileOwner) -> PathBuf { PathBuf::from("/tmp").join(format!("cmux-th-{owner}")) } -/// The only endpoint a record of `owner`'s host may name. +/// The legacy flat endpoint of `owner`'s host (hosts from before cx-t2sqm.13). pub(crate) fn canonical_endpoint(owner: FileOwner, terminal_id: &str) -> PathBuf { PathBuf::from("/tmp").join(format!("cmux-th-{owner}")).join(format!("{terminal_id}.sock")) } +/// The per-install socket directory of the records in `record_root` +/// (cx-t2sqm.13): `/tmp/cmux-th-//`, where `` is the +/// 16-hex token of the record directory's session (`terminal-hosts-`, +/// one per build: the app session names the bundle id and tag), else a +/// token of the record directory's path. A build's cleanup of its own socket +/// directory cannot touch another build's sockets. +pub(crate) fn install_endpoint_dir(owner: FileOwner, record_root: &Path) -> PathBuf { + let token = record_root + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix("terminal-hosts-")) + .filter(|token| token.len() == 16 && token.bytes().all(|b| b.is_ascii_hexdigit())) + .map(str::to_owned) + .unwrap_or_else(|| { + // Components, so `/x/root/` and `/x/root` name one directory. + let path: PathBuf = record_root.components().collect(); + super::super::shared::codec::stable_token(&path.to_string_lossy()) + }); + endpoint_dir(owner).join(token) +} + +/// Where a new host of `record_root` binds its socket. +/// +/// Two phases, so a rollback never strands a live terminal: this build +/// ACCEPTS records that name the per-install path but still BINDS new hosts +/// in the flat directory, which every older build accepts. A later build +/// switches the bind (cx-t2sqm.18) once builds without this acceptance are +/// out of the rollback window. Debug builds bind per-install when +/// `CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT=1` (behavior tests). +pub(crate) fn host_endpoint(owner: FileOwner, record_root: &Path, terminal_id: &str) -> PathBuf { + if bind_per_install() { + install_endpoint_dir(owner, record_root).join(format!("{terminal_id}.sock")) + } else { + canonical_endpoint(owner, terminal_id) + } +} + +/// The directory [`host_endpoint`] binds in. +pub(crate) fn host_endpoint_dir(owner: FileOwner, record_root: &Path) -> PathBuf { + if bind_per_install() { install_endpoint_dir(owner, record_root) } else { endpoint_dir(owner) } +} + +fn bind_per_install() -> bool { + #[cfg(debug_assertions)] + { + std::env::var_os("CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT").is_some_and(|value| value == "1") + } + #[cfg(not(debug_assertions))] + { + false + } +} + +/// The endpoints a record in `record_root` may name: its install's +/// directory, or the legacy flat directory (a host an older build spawned +/// keeps working after an update). +pub(crate) fn canonical_endpoints( + owner: FileOwner, + record_root: &Path, + terminal_id: &str, +) -> [PathBuf; 2] { + [ + install_endpoint_dir(owner, record_root).join(format!("{terminal_id}.sock")), + canonical_endpoint(owner, terminal_id), + ] +} + pub(crate) fn is_endpoint_file(metadata: &fs::Metadata) -> bool { metadata.file_type().is_socket() } diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs index eae684d51bd4..f99ae0fe8c3a 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/sys/windows/seams.rs @@ -82,6 +82,32 @@ pub(crate) fn canonical_endpoint(_owner: FileOwner, terminal_id: &str) -> PathBu endpoint::endpoint_dir().join(format!("{terminal_id}.sock")) } +/// Windows keeps one flat endpoint directory per user (no per-install +/// directory yet, cx-t2sqm.13). +pub(crate) fn install_endpoint_dir(owner: FileOwner, _record_root: &Path) -> PathBuf { + endpoint_dir(owner) +} + +/// The directory [`host_endpoint`] binds in. +pub(crate) fn host_endpoint_dir(owner: FileOwner, _record_root: &Path) -> PathBuf { + endpoint_dir(owner) +} + +/// Where a new host binds its endpoint. +pub(crate) fn host_endpoint(owner: FileOwner, _record_root: &Path, terminal_id: &str) -> PathBuf { + canonical_endpoint(owner, terminal_id) +} + +/// The endpoints a record may name. +pub(crate) fn canonical_endpoints( + owner: FileOwner, + record_root: &Path, + terminal_id: &str, +) -> [PathBuf; 2] { + let endpoint = host_endpoint(owner, record_root, terminal_id); + [endpoint.clone(), endpoint] +} + /// An AF_UNIX socket file is a reparse point (`IO_REPARSE_TAG_AF_UNIX`); /// std does not expose the tag, so any non-directory reparse point counts. /// Callers only remove such an entry inside our owner-only directory. diff --git a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs index 6a0a6d440bac..4c230a1cd7b7 100644 --- a/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs +++ b/cmux-tui/crates/cmux-tui-core/src/terminal_host_runtime/unix/adopt_launch.rs @@ -382,9 +382,9 @@ pub(crate) fn launch_terminal_host_adopting_with_program_status( let terminal_hex = terminal_id.to_hex(); let launch_publication_lock = reserve_terminal_host_publication(root)?; let uid = fs::metadata(root)?.uid(); - let endpoint_root = PathBuf::from("/tmp").join(format!("cmux-th-{uid}")); - prepare_endpoint_dir(&endpoint_root)?; - let endpoint = endpoint_root.join(format!("{terminal_hex}.sock")); + prepare_endpoint_dir(&sys::endpoint_dir(uid))?; + prepare_endpoint_dir(&sys::host_endpoint_dir(uid, root))?; + let endpoint = sys::host_endpoint(uid, root, &terminal_hex); let record_path = crate::platform::normalize_filesystem_path(root.join(format!("{terminal_hex}.json"))); let predecessor = predecessor(&record_path, &adoption.identity)?; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs index 5769c322851f..deba3c1f5827 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs @@ -32,11 +32,13 @@ use crate::terminal_host_runtime::TerminalHostLiveness; pub use open_guard::RegistryQuarantined; use open_guard::preflight_unsupported_schema; +mod compact; mod detached_terminals; pub(crate) use detached_terminals::DETACHED_TERMINAL_WORKSPACE_KEY; use detached_terminals::require_live_workspace; mod effect_store; pub(crate) mod feed_local_store; +pub use compact::{RegistryCompactError, RegistryCompaction, compact_registry}; mod idle_policy_store; mod journal_extensions; mod mutation_ledger; @@ -2826,11 +2828,7 @@ impl WorkspaceRegistry { if let Err(error) = crate::state::sidebar_doc_store::migrate_at_open(&connection) { eprintln!("cmux-tui: sidebar document migration skipped ({error:#})"); } - let quick_check: String = - connection.query_row("PRAGMA quick_check", [], |row| row.get(0))?; - if quick_check != "ok" { - anyhow::bail!("workspace registry integrity check failed: {quick_check}"); - } + check_registry_integrity(&connection)?; { let tx = connection.unchecked_transaction()?; initialize_compatibility_active_workspace(&tx)?; @@ -3714,6 +3712,57 @@ fn migrate_resource_api_frontend_projection_envelopes( Ok(()) } +/// Tables that hold journal history rather than registry state. They grow +/// with every terminal output chunk (millions of rows, gigabytes on a +/// long-lived machine) and are append-only or sealed archives whose records +/// are validated when they are read, so the open-time check skips them. +const JOURNAL_HISTORY_TABLES: [&str; 7] = [ + "session_journal", + "journal_subject_index", + "journal_event_index", + "journal_segments", + "journal_checkpoints", + "journal_content_blobs", + "journal_segment_kinds", +]; + +/// Structural check of the registry state on open. An unscoped quick_check +/// reads every page of the file, so its cost tracked total journal history; +/// checking each state table and its indexes keeps startup proportional to +/// the registry, not to how much output its terminals ever printed. +fn check_registry_integrity(connection: &Connection) -> anyhow::Result<()> { + let tables = { + let mut statement = connection.prepare( + "SELECT name FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' + ORDER BY name", + )?; + statement.query_map([], |row| row.get::<_, String>(0))?.collect::, _>>()? + }; + for table in tables.iter().filter(|table| !JOURNAL_HISTORY_TABLES.contains(&table.as_str())) { + let pragma = format!("PRAGMA quick_check('{}')", table.replace('\'', "''")); + let result: String = connection.query_row(&pragma, [], |row| row.get(0))?; + if result != "ok" { + anyhow::bail!("workspace registry integrity check failed: {result}"); + } + } + // History is validated incrementally when replayed. Touch only each + // table's newest indexed row during open so a corrupt tail page is caught + // without turning startup back into a full-journal scan. + for (table, order) in [ + ("session_journal", "sequence"), + ("journal_subject_index", "sequence"), + ("journal_event_index", "sequence"), + ("journal_segments", "end_sequence"), + ("journal_checkpoints", "source_sequence"), + ("journal_content_blobs", "created_at_ms"), + ] { + let query = format!("SELECT 1 FROM {table} ORDER BY {order} DESC LIMIT 1"); + let _: Option = connection.query_row(&query, [], |row| row.get(0)).optional()?; + } + Ok(()) +} + fn checkpoint_and_truncate_wal(connection: &Connection) -> anyhow::Result<()> { let (busy, _, _): (i64, i64, i64) = connection.query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| { diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/compact.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/compact.rs new file mode 100644 index 000000000000..e7270ce2ec4e --- /dev/null +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/compact.rs @@ -0,0 +1,228 @@ +//! Offline compaction of a session's workspace registry (cx-ossub). +//! +//! A registry written before journal maintenance (auto_vacuum=NONE) keeps +//! every free page: sealing reuses them, but the file never shrinks, and a +//! legacy registry with more live data than the in-place bound is never +//! converted (a full VACUUM under the registry stalled its owner for minutes, +//! cx-4bd8m). `cmux-tui server compact-registry` rewrites it while NO owner +//! runs: it takes the same session guard and writer lease an owner takes, so +//! it refuses while an owner holds the session, and no write can happen +//! between the copy and the swap. +//! +//! The copy is `VACUUM INTO` a staged file beside the registry, with +//! incremental auto-vacuum set, so the next owner marks the registry migrated +//! and gives free pages back from then on. The staged file is checked +//! (`quick_check`), synced and renamed over the registry. The registry's WAL +//! is checkpointed into it (the source connection closes before the rename) +//! and its sidecars are removed first, so an old WAL is never applied to the +//! new file. On any error the staged file is removed and the registry is left +//! as it was. + +use super::open_guard::{refuse_orphaned_journal, sidecar_path}; +use super::*; + +/// What [`compact_registry`] did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryCompaction { + /// The registry file that was rewritten. + pub path: PathBuf, + /// Registry file plus WAL bytes before the rewrite. + pub before_bytes: u64, + /// Registry file bytes after the rewrite. + pub after_bytes: u64, + /// Set when the new registry is installed but its directory could not be + /// synced (the swap may not survive a power loss yet). + pub warning: Option, +} + +/// Why [`compact_registry`] did not rewrite the registry. +#[derive(Debug)] +pub enum RegistryCompactError { + /// The session has no registry at this path. + NoRegistry(PathBuf), + /// Another process (a running owner) holds the session. + OwnerRunning(anyhow::Error), + /// The rewrite failed; the registry is unchanged. + Failed(anyhow::Error), +} + +impl std::fmt::Display for RegistryCompactError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoRegistry(path) => write!(f, "no workspace registry at {}", path.display()), + Self::OwnerRunning(error) => { + write!(f, "the session is held by a running owner: {error:#}") + } + Self::Failed(error) => write!(f, "{error:#}"), + } + } +} + +impl std::error::Error for RegistryCompactError {} + +/// Rewrites the registry of `session_name` under the state root `root` into +/// a compact file with incremental auto-vacuum. Refuses while an owner runs. +pub fn compact_registry( + root: &Path, + session_name: &str, +) -> Result { + let root = platform::normalize_filesystem_path(root.to_path_buf()); + let session_dir = + platform::normalize_filesystem_path(root.join(session_storage_component(session_name))); + let db_path = platform::normalize_filesystem_path(session_dir.join(WORKSPACE_REGISTRY_FILE)); + if !db_path.is_file() { + return Err(RegistryCompactError::NoRegistry(db_path)); + } + if let Some(error) = preflight_unsupported_schema(&db_path) { + return Err(RegistryCompactError::Failed(error.into())); + } + // The owner's own locks, held until the swap is durable. + let _session_guard = + acquire_existing_session_guard(&root, session_name).map_err(classify_lock_error)?; + let session_lock = + platform::normalize_filesystem_path(session_dir.join(SESSION_WRITER_LOCK_FILE)); + let _lease = SessionLease::acquire(&session_lock).map_err(classify_lock_error)?; + // Checked again under the locks: the file may have gone meanwhile, and a + // leftover journal of a missing file must not be applied to a new one. + if !db_path.is_file() { + return Err(RegistryCompactError::NoRegistry(db_path)); + } + refuse_orphaned_journal(&db_path).map_err(RegistryCompactError::Failed)?; + compact_locked(&db_path).map_err(RegistryCompactError::Failed) +} + +/// A lock another process holds means a running owner; anything else is a +/// failure to report as is. +fn classify_lock_error(error: anyhow::Error) -> RegistryCompactError { + let held = error.chain().any(|cause| { + cause + .downcast_ref::() + .is_some_and(|error| matches!(error, fs4::TryLockError::WouldBlock)) + }); + if held { + RegistryCompactError::OwnerRunning(error) + } else { + RegistryCompactError::Failed(error) + } +} + +fn compact_locked(db_path: &Path) -> anyhow::Result { + let wal = sidecar_path(db_path, "-wal"); + let shm = sidecar_path(db_path, "-shm"); + let before_bytes = file_len(db_path) + file_len(&wal); + let staged = staged_path(db_path)?; + remove_staged(&staged)?; + if let Err(error) = write_staged(db_path, &staged) { + let _ = remove_staged(&staged); + return Err(error); + } + // The source connection is closed: its WAL was checkpointed into the + // registry. A WAL that still holds frames would be applied to the new + // file by the next open, so refuse instead. + if file_len(&wal) > 0 { + let _ = remove_staged(&staged); + anyhow::bail!("the registry WAL still holds data after the copy: {}", wal.display()); + } + let parent = db_path.parent().context("registry path has no directory")?; + // The removal of the old sidecars is durable before the rename: an old + // WAL that came back after a crash would be applied to the new file. + let installed = (|| -> anyhow::Result<()> { + remove_if_present(&wal)?; + remove_if_present(&shm)?; + platform::sync_directory(parent) + .with_context(|| format!("sync registry directory {}", parent.display()))?; + fs::rename(&staged, db_path) + .with_context(|| format!("install compacted registry {}", db_path.display())) + })(); + if let Err(error) = installed { + let _ = remove_staged(&staged); + return Err(error); + } + // The registry is replaced from here on: a failed sync is a warning, not + // an "unchanged" failure. + let warning = platform::sync_directory(parent) + .err() + .map(|error| format!("sync registry directory {}: {error}", parent.display())); + Ok(RegistryCompaction { + path: db_path.to_path_buf(), + before_bytes, + after_bytes: file_len(db_path), + warning, + }) +} + +/// Copies the registry into `staged` with incremental auto-vacuum and checks +/// the copy. Both connections are closed on return. +fn write_staged(db_path: &Path, staged: &Path) -> anyhow::Result<()> { + { + // Never create a database: the registry exists (checked under the locks). + let source = open_registry_database_with_flags( + db_path, + OpenFlags::default().difference(OpenFlags::SQLITE_OPEN_CREATE), + ) + .with_context(|| format!("open workspace registry {}", db_path.display()))?; + source.busy_timeout(std::time::Duration::from_secs(5))?; + // On an existing database this only records the mode; SQLite applies + // it to the VACUUM INTO output. + source.execute_batch("PRAGMA auto_vacuum=INCREMENTAL;")?; + let staged_sqlite = sqlite_filesystem_path(staged)?; + let staged_text = staged_sqlite.to_str().context("compacted registry path is not UTF-8")?; + source + .execute("VACUUM INTO ?1", [staged_text]) + .with_context(|| format!("copy workspace registry {}", db_path.display()))?; + } + { + let copy = open_registry_database(staged) + .with_context(|| format!("open compacted registry {}", staged.display()))?; + let auto_vacuum = copy.query_row("PRAGMA auto_vacuum", [], |row| row.get::<_, i64>(0))?; + if auto_vacuum != 2 { + // This SQLite did not carry the mode into VACUUM INTO: convert + // the copy (a separate file; nothing waits on it). + copy.execute_batch("PRAGMA auto_vacuum=INCREMENTAL; VACUUM;")?; + } + let check = copy.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0))?; + anyhow::ensure!(check == "ok", "compacted registry failed quick_check: {check}"); + let auto_vacuum = copy.query_row("PRAGMA auto_vacuum", [], |row| row.get::<_, i64>(0))?; + anyhow::ensure!(auto_vacuum == 2, "compacted registry is not incremental auto-vacuum"); + } + // The copy's own sidecars, if its connection made any, are empty after + // the close; a non-empty WAL would be lost by the rename. + anyhow::ensure!( + file_len(&sidecar_path(staged, "-wal")) == 0, + "the compacted registry WAL still holds data" + ); + remove_if_present(&sidecar_path(staged, "-wal"))?; + remove_if_present(&sidecar_path(staged, "-shm"))?; + platform::restrict_file(staged)?; + // Windows FlushFileBuffers needs write access. + OpenOptions::new().write(true).open(staged)?.sync_all()?; + Ok(()) +} + +fn staged_path(db_path: &Path) -> anyhow::Result { + let name = db_path.file_name().context("registry path has no file name")?; + let mut staged = std::ffi::OsString::from("."); + staged.push(name); + staged.push(".compact"); + Ok(db_path.with_file_name(staged)) +} + +fn remove_staged(staged: &Path) -> anyhow::Result<()> { + remove_if_present(staged)?; + for suffix in ["-wal", "-shm", "-journal"] { + remove_if_present(&sidecar_path(staged, suffix))?; + } + Ok(()) +} + +fn remove_if_present(path: &Path) -> anyhow::Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).with_context(|| format!("remove {}", path.display())), + } +} + +fn file_len(path: &Path) -> u64 { + fs::metadata(path).map(|metadata| metadata.len()).unwrap_or(0) +} diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs index baf092667f8a..0d5b0f8e2cb4 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/journal_extensions.rs @@ -323,6 +323,7 @@ struct PreparedJournalSegment { metadata: JournalSegment, compressed: Vec, digest: [u8; 32], + kinds: Vec, } pub(super) fn create_journal_extensions_schema( @@ -414,6 +415,12 @@ pub(super) fn create_journal_extensions_schema( sealed_at_ms INTEGER NOT NULL CHECK(sealed_at_ms >= 0), actors_json TEXT ); + CREATE TABLE IF NOT EXISTS journal_segment_kinds ( + segment_id TEXT NOT NULL, + kind TEXT NOT NULL, + PRIMARY KEY(segment_id, kind), + FOREIGN KEY(segment_id) REFERENCES journal_segments(segment_id) + ) WITHOUT ROWID; CREATE TRIGGER IF NOT EXISTS journal_segments_reject_update BEFORE UPDATE ON journal_segments BEGIN @@ -2068,6 +2075,7 @@ impl WorkspaceRegistry { let segments = prepared_segments.iter().map(|segment| segment.metadata.clone()).collect::>(); for segment in prepared_segments { + let kinds = segment.kinds.clone(); tx.execute( "INSERT INTO journal_segments( segment_id, start_sequence, end_sequence, record_count, codec, content, @@ -2089,6 +2097,12 @@ impl WorkspaceRegistry { )?, ], )?; + for kind in kinds { + tx.execute( + "INSERT INTO journal_segment_kinds(segment_id, kind) VALUES(?1, ?2)", + params![segment.metadata.segment_id, kind], + )?; + } } tx.execute_batch("DROP TRIGGER IF EXISTS session_journal_reject_delete;")?; let deleted = tx.execute( @@ -2174,6 +2188,7 @@ impl JournalSegmentSealPlan { uncompressed.push(b'['); let mut start_sequence = None; let mut record_count = 0_u64; + let mut kinds = BTreeSet::new(); 'segment: while cursor < self.through_sequence && record_count < u64::try_from(JOURNAL_SEGMENT_RECORD_LIMIT)? { @@ -2190,6 +2205,7 @@ impl JournalSegmentSealPlan { record.sequence ); let sequence = record.sequence; + let kind = record.kind.clone(); let record = session_journal::journal_record_for_archive(&record); let record_bytes = serde_json::to_vec(&record)?; anyhow::ensure!( @@ -2207,6 +2223,7 @@ impl JournalSegmentSealPlan { { break 'segment; } + kinds.insert(kind); if separator != 0 { uncompressed.push(b','); } @@ -2247,6 +2264,7 @@ impl JournalSegmentSealPlan { }, compressed, digest, + kinds: kinds.into_iter().collect(), }); } validate_prepared_journal_segments(&self, &segments)?; diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs index 308f3260bf25..0a6caf079009 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/session_journal.rs @@ -442,14 +442,15 @@ impl ResourceEffectJournalState { } pub(crate) fn create_session_journal_schema(transaction: &Transaction<'_>) -> anyhow::Result<()> { - let subject_index_existed = transaction.query_row( - "SELECT EXISTS( - SELECT 1 FROM sqlite_master - WHERE type = 'table' AND name = 'journal_subject_index' - )", - [], - |row| row.get::<_, bool>(0), - )?; + let table_exists = |name: &str| { + transaction.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)", + [name], + |row| row.get::<_, bool>(0), + ) + }; + let subject_index_existed = table_exists("journal_subject_index")?; + let event_index_existed = table_exists("journal_event_index")?; transaction.execute_batch( "CREATE TABLE IF NOT EXISTS session_journal ( sequence INTEGER PRIMARY KEY AUTOINCREMENT, @@ -511,8 +512,6 @@ pub(crate) fn create_session_journal_schema(transaction: &Transaction<'_>) -> an ) ) ); - INSERT OR IGNORE INTO journal_event_index(event_id, sequence, causation_depth) - SELECT event_id, sequence, causation_depth FROM session_journal; CREATE TRIGGER IF NOT EXISTS session_journal_reject_update BEFORE UPDATE ON session_journal BEGIN @@ -534,6 +533,21 @@ pub(crate) fn create_session_journal_schema(transaction: &Transaction<'_>) -> an SELECT RAISE(ABORT, 'journal subject index is append-only'); END;", )?; + // Every append writes its event index row in the same transaction, so an + // existing index can only lack a tail left by a build that predates it. + // Repairing from its highest sequence keeps this open-time step a seek + // instead of a pass over the whole journal. + transaction.execute( + if event_index_existed { + "INSERT OR IGNORE INTO journal_event_index(event_id, sequence, causation_depth) + SELECT event_id, sequence, causation_depth FROM session_journal + WHERE sequence > (SELECT COALESCE(MAX(sequence), 0) FROM journal_event_index)" + } else { + "INSERT OR IGNORE INTO journal_event_index(event_id, sequence, causation_depth) + SELECT event_id, sequence, causation_depth FROM session_journal" + }, + [], + )?; if !subject_index_existed { transaction.execute_batch( "INSERT OR IGNORE INTO journal_subject_index(sequence, kind, id) @@ -1129,6 +1143,22 @@ impl WorkspaceRegistry { query_session_journal_after(&self.connection.get(), sequence, limit) } + /// Records in `(sequence, through]` other than `terminal.output`, oldest + /// first, at most `limit`, and the sequence the read covered through. + /// Terminal output is nearly the whole journal, and folding agent state + /// never reads it: its producer is the terminal runtime and its format is + /// fixed, so it can be neither an agent-hook nor an agent-plugin event. + /// Replaying such a reducer through this read costs the other records, + /// not the output history. + pub(crate) fn session_journal_without_output_after( + &self, + sequence: u64, + through: u64, + limit: usize, + ) -> anyhow::Result<(Vec, u64)> { + query_session_journal_without_output_after(&self.connection.get(), sequence, through, limit) + } + /// Return the highest sequence in the active or archived journal without /// decoding any records. Reducer recovery uses this to reject a stale /// snapshot cursor while remaining valid when the journal has compacted @@ -1348,6 +1378,251 @@ pub(crate) fn query_session_journal_after( Ok(SessionJournalPage { head_sequence, records }) } +fn query_session_journal_without_output_after( + connection: &Connection, + sequence: u64, + through: u64, + limit: usize, +) -> anyhow::Result<(Vec, u64)> { + anyhow::ensure!(limit > 0, "journal page limit must be positive"); + anyhow::ensure!( + limit <= MAX_JOURNAL_PAGE_SIZE, + "journal page limit exceeds {MAX_JOURNAL_PAGE_SIZE}" + ); + if sequence >= through { + return Ok((Vec::new(), through)); + } + let archive_end = query_archived_journal_head(connection)?; + let archive_through = through.min(archive_end); + let mut records = Vec::new(); + let mut reached = sequence; + if sequence < archive_through { + if let Some((archived, archived_reached)) = + archived_non_output_after(connection, sequence, archive_through, archive_end, limit)? + { + records = archived; + reached = archived_reached; + } else { + // Older sealed segments predate journal_segment_kinds. Decode those + // segments with the validating legacy path, then use the indexed + // active query below for any remaining tail. + let archived = archived_records_after(connection, sequence, limit)?; + let covered = archived + .into_iter() + .take_while(|record| record.sequence <= archive_through) + .collect::>(); + reached = covered.last().map_or(archive_through, |record| record.sequence); + records.extend(covered.into_iter().filter(|record| record.kind != "terminal.output")); + if records.len() >= limit || reached >= through { + records.truncate(limit); + return Ok((records, reached)); + } + } + if records.len() >= limit || reached >= through { + records.truncate(limit); + return Ok((records, reached)); + } + } + // Two ranges of the (kind, sequence) index around 'terminal.output' + // visit only the other kinds' entries, however much output there is. + let remaining = limit - records.len(); + let mut statement = connection.prepare( + "SELECT sequence, event_id, schema_version, kind, class, replay_policy, + occurred_at_ms, committed_at_ms, producer_json, authority_json, + causation_id, correlation_id, causation_depth, subjects_json, + sensitivity, payload_json, content, resource_revision, + previous_resource_revision + FROM session_journal INDEXED BY session_journal_by_kind_sequence + WHERE kind < 'terminal.output' AND sequence > ?1 AND sequence <= ?2 + UNION ALL + SELECT sequence, event_id, schema_version, kind, class, replay_policy, + occurred_at_ms, committed_at_ms, producer_json, authority_json, + causation_id, correlation_id, causation_depth, subjects_json, + sensitivity, payload_json, content, resource_revision, + previous_resource_revision + FROM session_journal INDEXED BY session_journal_by_kind_sequence + WHERE kind > 'terminal.output' AND sequence > ?1 AND sequence <= ?2 + ORDER BY sequence ASC + LIMIT ?3", + )?; + let active = statement + .query_map( + params![ + i64::try_from(sequence).context("journal sequence exceeds SQLite range")?, + i64::try_from(through).context("journal sequence exceeds SQLite range")?, + i64::try_from(remaining).context("journal page limit exceeds SQLite range")?, + ], + stored_record_row, + )? + .map(|row| decode_record(row?)) + .collect::>>()?; + reached = if active.len() < remaining { + through + } else { + active.last().map_or(reached, |record| record.sequence) + }; + records.extend(active); + Ok((records, reached)) +} + +fn query_archived_journal_head(connection: &Connection) -> anyhow::Result { + let end: i64 = connection.query_row( + "SELECT COALESCE(MAX(end_sequence), 0) FROM journal_segments", + [], + |row| row.get(0), + )?; + u64::try_from(end).context("archived journal sequence is negative") +} + +/// Uses the seal-time kind map to skip output-only compressed segments without +/// inflating them. A missing map means an older segment, so callers retain the +/// validating legacy decoder for that range. +fn archived_non_output_after( + connection: &Connection, + sequence: u64, + through: u64, + archive_end: u64, + limit: usize, +) -> anyhow::Result, u64)>> { + backfill_legacy_journal_segment_kinds(connection)?; + // This helper only owns the sealed prefix. Let the active indexed query + // handle an entirely-active range or a cursor at the archive boundary. + if sequence >= archive_end { + return Ok(None); + } + let mut statement = connection.prepare( + "SELECT segment_id, start_sequence, end_sequence + FROM journal_segments + WHERE end_sequence > ?1 AND start_sequence <= ?2 + ORDER BY start_sequence ASC", + )?; + let rows = statement + .query_map(params![i64::try_from(sequence)?, i64::try_from(through)?], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?)) + })? + .collect::, _>>()?; + if rows.is_empty() { + return Ok(None); + } + let first_start = rows.first().map(|row| row.1).unwrap_or_default(); + anyhow::ensure!( + first_start <= i64::try_from(sequence.saturating_add(1))?, + "journal segments contain a gap before sequence {first_start}" + ); + let mut previous_end = None; + for row in &rows { + if let Some(previous_end) = previous_end { + anyhow::ensure!( + row.1 == previous_end + 1, + "journal segments contain a gap before sequence {}", + row.1 + ); + } + previous_end = Some(row.2); + } + let mut reached = sequence; + let mut records = Vec::new(); + for row in rows { + let segment_id = row.0.as_str(); + let (has_kind_rows, has_non_output): (bool, bool) = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM journal_segment_kinds WHERE segment_id = ?1), + EXISTS(SELECT 1 FROM journal_segment_kinds + WHERE segment_id = ?1 AND kind != 'terminal.output')", + [segment_id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + if !has_kind_rows { + return Ok(None); + } + let end = u64::try_from(row.2)?.min(through); + if !has_non_output { + reached = reached.max(end); + continue; + } + let segment = load_journal_segment(connection, segment_id)?; + let decoded = decode_journal_segment(segment)?; + for record in decoded.records.into_iter().filter(|r| { + r.sequence > sequence && r.sequence <= through && r.kind != "terminal.output" + }) { + reached = record.sequence; + records.push(record); + if records.len() == limit { + return Ok(Some((records, reached))); + } + } + reached = reached.max(end); + } + Ok(Some((records, reached.max(through.min(reached))))) +} + +/// Populate the seal-time kind index for segments written before the index was +/// introduced. The marker makes this an open-time migration: subsequent reads +/// use only the indexed metadata and retain the validating fallback for a +/// segment that is concurrently discovered without an index row. +fn backfill_legacy_journal_segment_kinds(connection: &Connection) -> anyhow::Result<()> { + let complete: Option = connection + .query_row( + "SELECT value FROM meta WHERE key = 'journal_segment_kinds_backfill'", + [], + |row| row.get(0), + ) + .optional()?; + if complete.as_deref() == Some("1") { + return Ok(()); + } + connection.execute( + "INSERT OR IGNORE INTO meta(key, value) VALUES('journal_segment_kinds_backfill', '0')", + [], + )?; + let ids = { + let mut statement = connection.prepare( + "SELECT segment_id FROM journal_segments + WHERE NOT EXISTS ( + SELECT 1 FROM journal_segment_kinds + WHERE journal_segment_kinds.segment_id = journal_segments.segment_id + ) + ORDER BY start_sequence ASC", + )?; + statement.query_map([], |row| row.get::<_, String>(0))?.collect::, _>>()? + }; + for segment_id in ids { + let segment = load_journal_segment(connection, &segment_id)?; + let kinds = decode_journal_segment(segment)? + .records + .into_iter() + .map(|record| record.kind) + .collect::>(); + for kind in kinds { + connection.execute( + "INSERT OR IGNORE INTO journal_segment_kinds(segment_id, kind) VALUES(?1, ?2)", + params![segment_id, kind], + )?; + } + } + connection + .execute("UPDATE meta SET value = '1' WHERE key = 'journal_segment_kinds_backfill'", [])?; + Ok(()) +} + +fn load_journal_segment( + connection: &Connection, + segment_id: &str, +) -> anyhow::Result { + let mut statement = connection.prepare( + "SELECT segment_id, start_sequence, end_sequence, record_count, codec, + content, uncompressed_bytes, sha256 + FROM journal_segments + WHERE segment_id = ?1 + AND length(content) <= ?2", + )?; + statement + .query_row( + params![segment_id, i64::try_from(MAX_JOURNAL_SEGMENT_COMPRESSED_BYTES)?], + journal_segment_row, + ) + .with_context(|| format!("load journal segment {segment_id}")) +} + fn query_journal_head(connection: &Connection) -> anyhow::Result { let head_sequence = connection.query_row( "SELECT MAX( diff --git a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs index 4c5a07599f39..fd7e9b7731b5 100644 --- a/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs +++ b/cmux-tui/crates/cmux-tui-core/src/workspace_registry/topology_close_store.rs @@ -96,9 +96,6 @@ impl WorkspaceRegistry { } None => None, }; - if let Some(tab_groups) = tab_groups { - presentation_store::write_tab_group_state(&tx, tab_groups)?; - } let terminal_batch = close_terminals_in_transaction(&tx, mutation, terminals, "topology-closed")?; // A close that is part of another change (a space delete) writes that @@ -115,6 +112,11 @@ impl WorkspaceRegistry { if before_patch.is_some() { crate::state::closed_history_store::flush_pending_group(&tx)?; } + // After the patch: its closed-history capture reads the groups the + // close takes (Close Group) before this write drops them. + if let Some(tab_groups) = tab_groups { + presentation_store::write_tab_group_state(&tx, tab_groups)?; + } tx.execute( "UPDATE meta SET value = ?1 WHERE key = 'resource_revision'", [revision.to_string()], diff --git a/cmux-tui/crates/cmux-tui-hooks/assets/agent-hooks/opencode.js b/cmux-tui/crates/cmux-tui-hooks/assets/agent-hooks/opencode.js index 14c569d6f9da..11b8ce0276e6 100644 --- a/cmux-tui/crates/cmux-tui-hooks/assets/agent-hooks/opencode.js +++ b/cmux-tui/crates/cmux-tui-hooks/assets/agent-hooks/opencode.js @@ -7,6 +7,11 @@ const events = new Set([ "session.updated", "session.status", "session.idle", + "session.execution.succeeded", + "session.execution.interrupted", + "session.execution.failed", + "session.next.step.failed", + "session.error", "session.compacted", "session.deleted", "message.updated", @@ -19,10 +24,11 @@ const events = new Set([ "question.replied", "question.rejected", ]); +const NON_TERMINAL_SESSION_ERRORS = new Set(["MessageAbortedError", "ContextOverflowError"]); const sessionRoots = new Map(); function topology(event) { - const properties = event?.properties || {}; + const properties = event?.data || event?.properties || {}; const info = properties.info || {}; const sessionId = info.id || properties.sessionID || properties.sessionId; if (!sessionId) return {}; @@ -62,6 +68,10 @@ const CmuxTuiJournal = async (context) => ({ // already records their visible bytes, so spawning one process per token // would add load without adding a semantic transition. if (!events.has(nativeEvent)) return; + // An interrupt (Esc) or a recoverable context overflow arrives as + // session.error but the turn continues to idle; it is not a failure. + const errorName = (event.properties || event.data || {}).error?.name; + if (nativeEvent === "session.error" && NON_TERMINAL_SESSION_ERRORS.has(errorName)) return; const sessionTopology = topology(event); emit(nativeEvent, { event, diff --git a/cmux-tui/crates/cmux-tui-hooks/src/lib.rs b/cmux-tui/crates/cmux-tui-hooks/src/lib.rs index fcbe01a1aec2..3a34682f277f 100644 --- a/cmux-tui/crates/cmux-tui-hooks/src/lib.rs +++ b/cmux-tui/crates/cmux-tui-hooks/src/lib.rs @@ -1717,9 +1717,16 @@ fn rewrite_json_hooks_with_helper( ) -> anyhow::Result<()> { let hooks = root.entry("hooks").or_insert_with(|| json!({})); let hooks = hooks.as_object_mut().context("agent hooks field must be a JSON object")?; - for entries in hooks.values_mut() { + // Where each event's first fully-owned group was, so a reinstall puts our + // group back in that slot instead of behind every other producer's: codex + // keys hook trust by position, and moving ours shifts theirs off their + // trusted keys. + let mut owned_slots = BTreeMap::new(); + for (event, entries) in hooks.iter_mut() { if nested { - rewrite_nested_entries(entries)?; + if let Some(slot) = rewrite_nested_entries(entries)? { + owned_slots.insert(event.clone(), slot); + } } else { rewrite_flat_entries(entries)?; } @@ -1749,20 +1756,27 @@ fn rewrite_json_hooks_with_helper( json!({"command":command,"timeout":timeout}) }; hooks.entry(*event).or_insert_with(|| json!([])); - hooks + let entries = hooks .get_mut(*event) .and_then(Value::as_array_mut) - .with_context(|| format!("hook event {event:?} must be an array"))? - .push(entry); + .with_context(|| format!("hook event {event:?} must be an array"))?; + match owned_slots.get(*event) { + Some(slot) if *slot <= entries.len() => entries.insert(*slot, entry), + _ => entries.push(entry), + } } Ok(()) } -fn rewrite_nested_entries(value: &mut Value) -> anyhow::Result<()> { +/// Removes the owned commands from every group, dropping groups left empty. +/// Returns the position, among the kept groups, of the first group that held +/// only owned commands. +fn rewrite_nested_entries(value: &mut Value) -> anyhow::Result> { let Some(groups) = value.as_array_mut() else { - return Ok(()); + return Ok(None); }; let mut rewritten = Vec::with_capacity(groups.len()); + let mut owned_slot = None; for mut group in std::mem::take(groups) { let Some(group_object) = group.as_object_mut() else { rewritten.push(group); @@ -1772,13 +1786,16 @@ fn rewrite_nested_entries(value: &mut Value) -> anyhow::Result<()> { rewritten.push(group); continue; }; + let was_empty = commands.is_empty(); rewrite_command_entries(commands); if !commands.is_empty() { rewritten.push(group); + } else if !was_empty && owned_slot.is_none() { + owned_slot = Some(rewritten.len()); } } *groups = rewritten; - Ok(()) + Ok(owned_slot) } fn rewrite_flat_entries(value: &mut Value) -> anyhow::Result<()> { @@ -2031,6 +2048,12 @@ fn codex_contextual_hook_command( /// commands generated here, leaving all other hooks' trust decisions intact. /// Codex appends hooks from lower layers, so disable the installed cmux copies /// for this invocation instead of sending each event twice. +/// +/// Codex splits a `-c` key on every `.`, even inside TOML quotes +/// (codex-rs/config/src/overrides.rs `apply_toml_override`), and every state +/// key contains one (`config.toml`, `hooks.json`). The whole state therefore +/// goes in one inline-table value at `hooks.state`, which Codex merges with +/// the persistent config's trust instead of replacing it. #[cfg(unix)] pub fn codex_session_hook_settings( socket: &Path, @@ -2058,8 +2081,11 @@ fn codex_session_hook_settings_with_context( let root = read_json_object(&path)?; let key_path = codex_state_key_hooks_path(&path, context.provider_path_from_env_override(provider)); + let mut state = toml_edit::InlineTable::new(); for key in codex_owned_entry_keys(&key_path, &root) { - settings.push(format!("hooks.state.{}.enabled=false", toml_edit::Key::new(key))); + let mut entry = toml_edit::InlineTable::new(); + entry.insert("enabled", false.into()); + state.insert(&key, entry.into()); } for event in CODEX_EVENTS { let command = codex_contextual_hook_command(event, socket, terminal, launcher); @@ -2070,9 +2096,11 @@ fn codex_session_hook_settings_with_context( "hooks.{event}=[{{hooks=[{{type=\"command\",command={},timeout={timeout}}}]}}]", toml_edit::Value::from(command), )); - let key = toml_edit::Key::new(format!("//config.toml:{label}:0:0")); - settings.push(format!("hooks.state.{key}.trusted_hash={}", toml_edit::Value::from(hash))); + let mut entry = toml_edit::InlineTable::new(); + entry.insert("trusted_hash", hash.into()); + state.insert(format!("//config.toml:{label}:0:0"), entry.into()); } + settings.push(format!("hooks.state={state}")); Ok(settings) } diff --git a/cmux-tui/crates/cmux-tui/Cargo.toml b/cmux-tui/crates/cmux-tui/Cargo.toml index e3f246bbc154..2079d0c03f93 100644 --- a/cmux-tui/crates/cmux-tui/Cargo.toml +++ b/cmux-tui/crates/cmux-tui/Cargo.toml @@ -90,6 +90,9 @@ cmux-host.workspace = true acpmux = { path = "../acpmux" } # Reads the enclosing app bundle's Info.plist (app_identity.rs). plist = "1.10" +# The build identity (daemon skew, SSH re-bootstrap). Only this crate reads +# it, so an edit does not recompile cmux-remote (cmux-build-identity docs). +cmux-build-identity.workspace = true cmux-remote.workspace = true cmux-remote-protocol.workspace = true cmux-wg.workspace = true diff --git a/cmux-tui/crates/cmux-tui/src/cli/chief/control.rs b/cmux-tui/crates/cmux-tui/src/cli/chief/control.rs index 448385325dcf..f6c02db52d55 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/chief/control.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/chief/control.rs @@ -71,6 +71,10 @@ pub(super) fn refusal(error: &LinkError) -> String { LinkError::Rejected { code, .. } if code.starts_with("validation.invalid") => { messages().control_unsupported.to_owned() } + // cx-tgf4h: agents are off in this build session (the daemon's typed refusal). + LinkError::Rejected { code, .. } if code.ends_with(cmux_tui_core::agent_launch::REASON) => { + messages().agents_disabled.to_owned() + } other => other.to_string(), } } diff --git a/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs b/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs index 9084a31eac3f..2c08045ef434 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/chief/messages.rs @@ -27,6 +27,7 @@ pub(super) struct Messages { pub started_brain: &'static str, pub daemon_failed: &'static str, pub no_brain: &'static str, + pub agents_disabled: &'static str, pub home_and_socket: &'static str, pub stop_sent: &'static str, pub shutdown_needs_home: &'static str, @@ -41,7 +42,7 @@ pub(super) struct Messages { } static ENGLISH: Messages = Messages { - usage: "usage: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | edit | delete (cmux chief new --help)\n\n Talk to your Chief: the same conversation as Home, live in both.\n On a terminal it opens the chat. With -p, or with text on stdin, it sends\n one message, prints the Chief's reply and exits when the turn ends.\n\n -p, --prompt TEXT send TEXT (pipe mode)\n --timeout SECONDS pipe mode: give up after SECONDS (default 1800, 0: no limit; exit 124)\n --no-wait-agents pipe mode: exit when the turn that answers ends, not after the subagents it started\n --history N chat: show the last N messages first (default 20)\n --chief-home DIR an isolated Chief (its own daemon and brain), also CMUX_CHIEF_HOME;\n without it and without --socket the CLI starts your Chief when it is not running\n --json pipe mode: print the reply messages as JSON lines\n engine show the Chief's engine, or set --harness, --model, --effort\n stop stop the Chief's running turn\n shutdown stop the Chief: its brain, acpmux daemon, agent sessions and session daemon", + usage: "usage: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | adopt | edit | delete (cmux chief new --help)\n\n Talk to your Chief: the same conversation as Home, live in both.\n On a terminal it opens the chat. With -p, or with text on stdin, it sends\n one message, prints the Chief's reply and exits when the turn ends.\n\n -p, --prompt TEXT send TEXT (pipe mode)\n --timeout SECONDS pipe mode: give up after SECONDS (default 1800, 0: no limit; exit 124)\n --no-wait-agents pipe mode: exit when the turn that answers ends, not after the subagents it started\n --history N chat: show the last N messages first (default 20)\n --chief-home DIR an isolated Chief (its own daemon and brain), also CMUX_CHIEF_HOME;\n without it and without --socket the CLI starts your Chief when it is not running\n --json pipe mode: print the reply messages as JSON lines\n engine show the Chief's engine, or set --harness, --model, --effort\n stop stop the Chief's running turn\n shutdown stop the Chief: its brain, acpmux daemon, agent sessions and session daemon", help: "Enter sends. Alt+Enter or Ctrl+J adds a line. Up and Down recall sent messages.\nCtrl+C stops the Chief's turn (or clears the input); Ctrl+D quits.\n/model [NAME] show or set the Chief's model\n/effort [LEVEL] show or set the Chief's effort\n/thoughts show or hide the Chief's thinking\n/help this text\n/quit leave (the Chief keeps working)", you: "You", chief: "Chief", @@ -66,6 +67,7 @@ static ENGLISH: Messages = Messages { started_brain: "started the Chief at {home} (pid {pid}); its log is host.log there", daemon_failed: "the Chief's session daemon is not running and {why}", no_brain: "the Chief is not running and this cmux has no Chief brain (optchat-chief) beside it or on PATH; open Home in a cmux DEV or NIGHTLY app once, or set CMUX_CHIEF_BRAIN_BIN", + agents_disabled: "agents are disabled in this build session (ACPMUX_AGENT_LAUNCH=deny or CMUX_AGENTS_DISABLED=1): the Chief does not start a turn, so nothing was sent; unset the variable to talk to the Chief", home_and_socket: "give --chief-home or --socket/--session, not both", stop_sent: "stopping the Chief's turn", shutdown_needs_home: "cmux chief shutdown stops a Chief home: give --chief-home (or none for your Chief), not --socket or --session", @@ -80,7 +82,7 @@ static ENGLISH: Messages = Messages { }; static JAPANESE: Messages = Messages { - usage: "使い方: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | edit | delete (cmux chief new --help)\n\n Chief と話します。Home と同じ会話で、両方にすぐ表示されます。\n 端末ではチャットを開きます。-p または標準入力のテキストがあると、\n メッセージを 1 件送り、Chief の返信を表示して、ターンの終わりに終了します。\n\n -p, --prompt TEXT TEXT を送信 (パイプモード)\n --timeout SECONDS パイプモード: SECONDS 秒で中止 (既定 1800、0 で無制限、終了コード 124)\n --no-wait-agents パイプモード: 開始したサブエージェントを待たず、答えたターンの終わりに終了\n --history N チャット: 最初に直近 N 件を表示 (既定 20)\n --chief-home DIR 独立した Chief (専用のデーモンと頭脳)。CMUX_CHIEF_HOME でも可。\n 指定も --socket もないとき、動いていなければあなたの Chief を開始します\n --json パイプモード: 返信を JSON Lines で出力\n engine Chief のエンジンを表示、または --harness、--model、--effort を設定\n stop Chief の実行中のターンを停止\n shutdown Chief を停止 (頭脳、acpmux デーモン、エージェントのセッション、セッションデーモン)", + usage: "使い方: cmux chief [-p TEXT] [--timeout SECONDS] [--history N]\n cmux chief engine [--harness H] [--model M] [--effort E] | cmux chief stop | cmux chief shutdown\n cmux chief list | new | adopt | edit | delete (cmux chief new --help)\n\n Chief と話します。Home と同じ会話で、両方にすぐ表示されます。\n 端末ではチャットを開きます。-p または標準入力のテキストがあると、\n メッセージを 1 件送り、Chief の返信を表示して、ターンの終わりに終了します。\n\n -p, --prompt TEXT TEXT を送信 (パイプモード)\n --timeout SECONDS パイプモード: SECONDS 秒で中止 (既定 1800、0 で無制限、終了コード 124)\n --no-wait-agents パイプモード: 開始したサブエージェントを待たず、答えたターンの終わりに終了\n --history N チャット: 最初に直近 N 件を表示 (既定 20)\n --chief-home DIR 独立した Chief (専用のデーモンと頭脳)。CMUX_CHIEF_HOME でも可。\n 指定も --socket もないとき、動いていなければあなたの Chief を開始します\n --json パイプモード: 返信を JSON Lines で出力\n engine Chief のエンジンを表示、または --harness、--model、--effort を設定\n stop Chief の実行中のターンを停止\n shutdown Chief を停止 (頭脳、acpmux デーモン、エージェントのセッション、セッションデーモン)", help: "Enter で送信。Alt+Enter または Ctrl+J で改行。上下キーで送信済みメッセージを呼び出します。\nCtrl+C で Chief のターンを停止 (または入力を消去)、Ctrl+D で終了します。\n/model [NAME] Chief のモデルを表示または設定\n/effort [LEVEL] Chief の effort を表示または設定\n/thoughts Chief の思考の表示を切り替え\n/help このテキスト\n/quit 終了 (Chief は作業を続けます)", you: "あなた", chief: "Chief", @@ -105,6 +107,7 @@ static JAPANESE: Messages = Messages { started_brain: "{home} で Chief を開始しました (pid {pid})。ログはそこの host.log です", daemon_failed: "Chief のセッションデーモンが動いておらず、{why}", no_brain: "Chief が動いておらず、この cmux の隣にも PATH にも Chief の頭脳 (optchat-chief) がありません。cmux DEV か NIGHTLY のアプリで Home を一度開くか、CMUX_CHIEF_BRAIN_BIN を設定してください", + agents_disabled: "このビルドセッションではエージェントが無効です (ACPMUX_AGENT_LAUNCH=deny または CMUX_AGENTS_DISABLED=1)。Chief はターンを始めないため、何も送信していません。Chief と話すには環境変数の設定を解除してください", home_and_socket: "--chief-home と --socket/--session は同時に指定できません", stop_sent: "Chief のターンを停止します", shutdown_needs_home: "cmux chief shutdown は Chief ホームを停止します。--socket や --session ではなく --chief-home を指定してください (省略するとあなたの Chief)", diff --git a/cmux-tui/crates/cmux-tui/src/cli/chief/mod.rs b/cmux-tui/crates/cmux-tui/src/cli/chief/mod.rs index 7332152a44a0..350ab4f8950c 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/chief/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/chief/mod.rs @@ -189,6 +189,13 @@ fn run(global: GlobalArgs, args: &[String]) -> i32 { if let Some(control) = &args.control { return control::run(&global, args.chief_home.as_deref(), control, global.output); } + // cx-tgf4h: with agents off in this build session no Chief turn can + // run; refuse before anything starts or is sent (a send would wait for + // a reply that never comes). + if cmux_tui_core::agent_launch::disabled() { + let refusal = cmux_tui_core::agent_launch::refusal(m.agents_disabled); + return super::wire::print_operation_error(&refusal, global.output); + } let stdin_tty = std::io::stdin().is_terminal(); let pipe_mode = args.prompt.is_some() || !stdin_tty; if !pipe_mode && !std::io::stdout().is_terminal() { diff --git a/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs b/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs index b4a7861b46d2..e40eb3c32b9b 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/chief/registry.rs @@ -1,5 +1,5 @@ -//! `cmux chief list|new|edit|delete`: the person's Chiefs, through the -//! daemon's Chief registry (`chief.profile.list`, `chief.profile.create`, `chief.profile.update`, +//! `cmux chief list|new|adopt|edit|delete`: the person's Chiefs, through the +//! daemon's Chief registry (`chief.profile.list`, `chief.profile.create`, `chief.profile.adopt`, `chief.profile.update`, //! `chief.profile.delete`, `chief.profile.avatar.set`). One call per command on the Chief //! home's owner, as `cmux chief engine` finds it; nothing is started. A //! CHIEF argument is an id or a display name that names one Chief. @@ -18,7 +18,7 @@ use crate::cli::OutputMode; const MAX_AVATAR_BYTES: u64 = 1024 * 1024; /// The registry words `cmux chief` takes first. -pub(super) const WORDS: [&str; 4] = ["list", "new", "edit", "delete"]; +pub(super) const WORDS: [&str; 5] = ["list", "new", "adopt", "edit", "delete"]; #[derive(Debug, Default, PartialEq, Eq)] struct Request { @@ -40,6 +40,8 @@ struct Text { no_such: &'static str, ambiguous: &'static str, created: &'static str, + needs_agent: &'static str, + adopted: &'static str, updated: &'static str, deleted: &'static str, default_mark: &'static str, @@ -50,12 +52,14 @@ struct Text { } static ENGLISH: Text = Text { - usage: "usage: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n Your Chiefs: each has its own name, role, picture, personality, instructions,\n tools and memory. CHIEF is an id (agent_...) or a name.\n\n PROFILE:\n --name NAME 1 to 100 characters\n --role TEXT one line, for example \"Release captain\"\n --personality TEXT tone and style\n --instructions TEXT added to the Chief's system prompt (after the base rules)\n --instructions-file F the same, read from file F\n --avatar FILE a PNG, JPEG, GIF or WebP picture, at most 1 MiB\n --tools a,b from browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces\n --harnesses a,b the harnesses its subagents may use (empty: all)\n --self-edit a,b what the Chief may change itself: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E its engine\n --chief-home DIR an isolated Chief home", + usage: "usage: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief adopt AGENT [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n Your Chiefs: each has its own name, role, picture, personality, instructions,\n tools and memory. CHIEF is an id (agent_...) or a name.\n adopt makes AGENT, an agent already in your conversations (agent_...), a Chief\n under its own id: its conversations and history stay; it gets a new Chief's defaults.\n\n PROFILE:\n --name NAME 1 to 100 characters\n --role TEXT one line, for example \"Release captain\"\n --personality TEXT tone and style\n --instructions TEXT added to the Chief's system prompt (after the base rules)\n --instructions-file F the same, read from file F\n --avatar FILE a PNG, JPEG, GIF or WebP picture, at most 1 MiB\n --tools a,b from browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces\n --harnesses a,b the harnesses its subagents may use (empty: all)\n --self-edit a,b what the Chief may change itself: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E its engine\n --chief-home DIR an isolated Chief home", needs_name: "cmux chief new needs --name NAME", needs_chief: "give the Chief: an id (agent_...) or its name; cmux chief list shows them", no_such: "no Chief is named {chief}; cmux chief list shows them", ambiguous: "more than one Chief is named {chief}; use its id", created: "created {name} ({id})", + needs_agent: "cmux chief adopt needs the agent: its participant id (agent_...)", + adopted: "adopted {name} ({id}) as a Chief", updated: "updated {name} ({id}, rev {rev})", deleted: "deleted {name} ({id})", default_mark: "default", @@ -66,12 +70,14 @@ static ENGLISH: Text = Text { }; static JAPANESE: Text = Text { - usage: "使い方: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n あなたの Chief たち。それぞれに名前、役割、画像、性格、指示、ツール、記憶があります。\n CHIEF は id (agent_...) または名前です。\n\n PROFILE:\n --name NAME 1 から 100 文字\n --role TEXT 1 行。例: \"リリース担当\"\n --personality TEXT 口調とスタイル\n --instructions TEXT Chief のシステムプロンプトに追加 (基本ルールの後)\n --instructions-file F 同じ内容をファイル F から読む\n --avatar FILE PNG、JPEG、GIF または WebP の画像 (1 MiB まで)\n --tools a,b browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces から\n --harnesses a,b サブエージェントが使えるハーネス (空ならすべて)\n --self-edit a,b Chief が自分で変えられる項目: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E エンジン\n --chief-home DIR 独立した Chief ホーム", + usage: "使い方: cmux chief list\n cmux chief new --name NAME [PROFILE]\n cmux chief adopt AGENT [PROFILE]\n cmux chief edit CHIEF [PROFILE] [--clear-avatar]\n cmux chief delete CHIEF\n\n あなたの Chief たち。それぞれに名前、役割、画像、性格、指示、ツール、記憶があります。\n CHIEF は id (agent_...) または名前です。\n adopt は会話にすでにいるエージェント AGENT (agent_...) を、その id のまま Chief にします。\n 会話と履歴はそのまま残り、新しい Chief の既定値が付きます。\n\n PROFILE:\n --name NAME 1 から 100 文字\n --role TEXT 1 行。例: \"リリース担当\"\n --personality TEXT 口調とスタイル\n --instructions TEXT Chief のシステムプロンプトに追加 (基本ルールの後)\n --instructions-file F 同じ内容をファイル F から読む\n --avatar FILE PNG、JPEG、GIF または WebP の画像 (1 MiB まで)\n --tools a,b browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces から\n --harnesses a,b サブエージェントが使えるハーネス (空ならすべて)\n --self-edit a,b Chief が自分で変えられる項目: avatar, display_name, role, personality, instructions\n --harness H --model M --effort E エンジン\n --chief-home DIR 独立した Chief ホーム", needs_name: "cmux chief new には --name NAME が必要です", needs_chief: "Chief を指定してください: id (agent_...) または名前。cmux chief list で一覧を表示します", no_such: "{chief} という名前の Chief はいません。cmux chief list で一覧を表示します", ambiguous: "{chief} という名前の Chief が複数います。id を使ってください", created: "{name} を作成しました ({id})", + needs_agent: "cmux chief adopt にはエージェントの参加者 id (agent_...) が必要です", + adopted: "{name} ({id}) を Chief にしました", updated: "{name} を更新しました ({id}、rev {rev})", deleted: "{name} を削除しました ({id})", default_mark: "既定", @@ -145,6 +151,9 @@ fn parse(args: &[String]) -> Result { if word == "list" && (request.chief.is_some() || !request.fields.is_empty()) { return Err("cmux chief list takes no Chief or profile".into()); } + if word == "adopt" && !request.help && request.chief.is_none() { + return Err(text().needs_agent.into()); + } if word == "new" && request.chief.is_some() { return Err("cmux chief new takes --name, not a Chief".into()); } @@ -179,7 +188,7 @@ impl Request { /// connection. `None` for a read (list). fn write(&self) -> Option<&str> { match self.word.as_str() { - "new" | "edit" | "delete" => Some(self.word.as_str()), + "new" | "adopt" | "edit" | "delete" => Some(self.word.as_str()), _ => None, } } @@ -356,7 +365,14 @@ fn execute(link: &mut Link, request: &Request, output: OutputMode) -> Result<(), return Ok(()); } let avatar = request.avatar.as_deref().map(read_avatar).transpose().map_err(Failure::Usage)?; - let mut record = if request.word == "new" { + let mut record = if request.word == "adopt" { + let agent = request.chief.as_deref().ok_or_else(|| Failure::Usage(t.needs_agent.into()))?; + let mut params = request.fields.clone(); + params.insert("participant_id".into(), json!(agent)); + let key = super::link::new_message_id(); + let adopted = link.call("chief.profile.adopt", Value::Object(params), Some(&key))?; + adopted.get("value").cloned().unwrap_or(Value::Null) + } else if request.word == "new" { if !request.fields.contains_key("display_name") { return Err(Failure::Usage(t.needs_name.into())); } @@ -406,13 +422,21 @@ fn execute(link: &mut Link, request: &Request, output: OutputMode) -> Result<(), match link.call("chief.profile.avatar.set", params, Some(&key)) { Ok(set) => record = set.get("value").cloned().unwrap_or(Value::Null), Err(error) => { - let template = if request.word == "new" { t.created } else { t.updated }; + let template = match request.word.as_str() { + "new" => t.created, + "adopt" => t.adopted, + _ => t.updated, + }; let done = format!("{}; {}", fill(&record, template), t.picture_not_set); return Err(Failure::Partial { done, error }); } } } - let template = if request.word == "new" { t.created } else { t.updated }; + let template = match request.word.as_str() { + "new" => t.created, + "adopt" => t.adopted, + _ => t.updated, + }; print_result(&record, template, json_out); Ok(()) } diff --git a/cmux-tui/crates/cmux-tui/src/cli/command.rs b/cmux-tui/crates/cmux-tui/src/cli/command.rs index 7801a273fd21..b1d80d01365c 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/command.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/command.rs @@ -198,6 +198,9 @@ fn parse_server(words: &[String], flags: &mut Flags) -> Result super::lifecycle::ServerAction::ReloadConfig, + ["compact-registry"] => super::lifecycle::ServerAction::CompactRegistry { + state_dir: flags.take("state-dir").map(PathBuf::from), + }, ["start"] => { return Err(UsageError::new( crate::localization::catalog().local_server.start_options_after_action, @@ -209,7 +212,15 @@ fn parse_server(words: &[String], flags: &mut Flags) -> Result, + }, } pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { @@ -70,6 +77,9 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { ); } let expected_session = global.session.clone(); + if let ServerAction::CompactRegistry { state_dir } = plan.action { + return run_compact_registry(expected_session, state_dir, global.output); + } let (socket, socket_is_derived) = match super::wire::resolve_socket_with_origin(&global) { Ok(resolved) => resolved, Err(error) => { @@ -226,7 +236,9 @@ pub(super) fn run(mut global: GlobalArgs, plan: ServerPlan) -> i32 { } match plan.action { - ServerAction::Ensure { .. } => unreachable!("ensure returns before the lifecycle exchange"), + ServerAction::Ensure { .. } | ServerAction::CompactRegistry { .. } => { + unreachable!("ensure and compact-registry return before the lifecycle exchange") + } ServerAction::Status => print_success( json!({ "status":"running", @@ -704,6 +716,66 @@ fn usage_error(message: &str, output: OutputMode) -> i32 { local_error("usage.invalid", message, output, 2) } +/// `server compact-registry --session [--state-dir ]`: offline, +/// with the owner's session locks, so it needs no socket. The session is +/// never guessed: a wrong default would compact another session than the one +/// the user stopped. Exit 3 with `server.compact_owner_running` while an +/// owner holds the session. +fn run_compact_registry( + expected_session: Option, + state_dir: Option, + output: OutputMode, +) -> i32 { + let messages = &crate::localization::catalog().local_server; + let Some(session) = expected_session else { + return local_error("usage.invalid", messages.registry_compact_needs_session, output, 2); + }; + let Some(root) = state_dir.or_else(cmux_tui_core::platform::workspace_state_dir) else { + return local_error_with_details( + "server.compact_no_registry", + messages.registry_compact_no_registry, + json!({"session": session, "reason": "no_state_dir"}), + output, + 3, + ); + }; + match cmux_tui_core::compact_registry(&root, &session) { + Ok(compaction) => print_success( + json!({ + "status": "compacted", + "session": session, + "path": compaction.path.to_string_lossy(), + "before_bytes": compaction.before_bytes, + "after_bytes": compaction.after_bytes, + "warning": compaction.warning, + "message": messages.registry_compacted, + }), + output, + ), + Err(cmux_tui_core::RegistryCompactError::NoRegistry(path)) => local_error_with_details( + "server.compact_no_registry", + messages.registry_compact_no_registry, + json!({"session": session, "path": path.to_string_lossy()}), + output, + 3, + ), + Err(cmux_tui_core::RegistryCompactError::OwnerRunning(error)) => local_error_with_details( + "server.compact_owner_running", + messages.registry_compact_owner_running, + json!({"session": session, "reason": format!("{error:#}")}), + output, + 3, + ), + Err(cmux_tui_core::RegistryCompactError::Failed(error)) => local_error_with_details( + "server.compact_failed", + messages.registry_compact_failed, + json!({"session": session, "reason": format!("{error:#}")}), + output, + 1, + ), + } +} + fn local_error(code: &str, message: &str, output: OutputMode, exit_code: i32) -> i32 { local_error_with_details(code, message, json!({}), output, exit_code) } diff --git a/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs b/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs index d3a00db810ec..0e6d2677d6e8 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/mcp/v2_tools.rs @@ -96,6 +96,7 @@ pub(super) const EXCLUDED: &[(&str, &str)] = &[ ("chief.profile.list", CHIEF_REGISTRY_REASON), ("chief.profile.get", CHIEF_REGISTRY_REASON), ("chief.profile.create", CHIEF_REGISTRY_REASON), + ("chief.profile.adopt", CHIEF_REGISTRY_REASON), ("chief.profile.update", CHIEF_REGISTRY_REASON), ("chief.profile.delete", CHIEF_REGISTRY_REASON), ("chief.profile.avatar.set", CHIEF_REGISTRY_REASON), diff --git a/cmux-tui/crates/cmux-tui/src/cli/resolve.rs b/cmux-tui/crates/cmux-tui/src/cli/resolve.rs index 6c9ffe712956..7f99b9baff53 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/resolve.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/resolve.rs @@ -5,12 +5,14 @@ //! fingerprint. use std::io::{BufReader, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; use cmux_tui_core::platform::transport; use cmux_tui_core::resource::{MAX_MESSAGE_BYTES, PROTOCOL, ResourceOperation, ResponseEnvelope}; use serde_json::{Map, Value, json}; -use super::command::{RequestPlan, Resolve, ZoomStep}; +use super::command::{RequestPlan, Resolve, ZoomStep, random_prefixed}; use super::wire::{print_local_error, print_operation_error, random_request_id, read_envelope}; use super::{GlobalArgs, OutputMode}; @@ -51,11 +53,19 @@ impl Failure { } } +/// The session socket a request's connection reached, so a lookup can open +/// a second connection to the same daemon. +pub(super) struct SocketRoute<'a> { + pub(super) path: &'a Path, + pub(super) derived: bool, +} + /// Applies every pending lookup of `plan` and clears them. pub(super) fn apply( reader: &mut Reader, plan: &mut RequestPlan, caller_route: bool, + socket: &SocketRoute<'_>, ) -> Result<(), Failure> { let steps = std::mem::take(&mut plan.resolve); let params = plan @@ -67,7 +77,7 @@ pub(super) fn apply( match step { Resolve::CallerWorkspace { terminal } => { let workspace = if caller_route { - caller_workspace(reader, &route, &terminal)? + registered_caller_workspace(reader, &route, &terminal, socket)? } else { "current".to_string() }; @@ -187,6 +197,101 @@ fn route(params: &Map) -> Map { .collect() } +/// How long a lookup waits for the caller's terminal to join its workspace. +const CALLER_TERMINAL_WAIT: Duration = Duration::from_secs(3); + +/// [`caller_workspace`], waiting for the caller's terminal to be placed. A +/// new tab's shell runs before the daemon commits the tab, so a command at +/// its first prompt can ask for a terminal the daemon does not show yet. +/// The wait follows the session's event stream on a second connection and +/// looks again after each event, up to [`CALLER_TERMINAL_WAIT`]. +fn registered_caller_workspace( + reader: &mut Reader, + route: &Map, + terminal: &str, + socket: &SocketRoute<'_>, +) -> Result { + match caller_workspace(reader, route, terminal) { + Err(Failure::Resource(error)) if is_missing(&error, terminal) => {} + settled => return settled, + } + let deadline = Instant::now() + CALLER_TERMINAL_WAIT; + let mut events = session_events(socket, route, deadline); + loop { + let changed = events.as_mut().is_some_and(|events| next_event(events, deadline)); + match caller_workspace(reader, route, terminal) { + Err(Failure::Resource(error)) if is_missing(&error, terminal) => { + if !changed { + return Err(unregistered_terminal(terminal)); + } + } + settled => return settled, + } + } +} + +/// The lookup found no placed `terminal`; any other miss (an unknown +/// `--machine`, say) is reported as it is. +fn is_missing(error: &Value, terminal: &str) -> bool { + let details = &error["details"]; + error.get("code").and_then(Value::as_str) == Some("selector.not_found") + && (details["selector"] == terminal || details["terminal"] == terminal) +} + +fn unregistered_terminal(terminal: &str) -> Failure { + Failure::Resource(json!({ + "code": "selector.not_found", + "message": format!( + "the caller's terminal {terminal} did not join a workspace of this cmux session \ + within {}s; name one: cmux workspace …", + CALLER_TERMINAL_WAIT.as_secs() + ), + "details": {"terminal": terminal}, + "retryable": false, + })) +} + +/// A `session.events` stream of the request's session on its own +/// connection, or `None` when the daemon cannot give one before `deadline`. +fn session_events( + socket: &SocketRoute<'_>, + route: &Map, + deadline: Instant, +) -> Option { + let stream = cmux_tui_core::server::connect_session_socket(socket.path, socket.derived).ok()?; + let mut events = BufReader::new(stream); + let remaining = deadline.checked_duration_since(Instant::now())?; + events.get_mut().set_read_timeout(Some(remaining)).ok()?; + let id = random_request_id().ok()?; + let mut params = route.clone(); + params.entry("session").or_insert_with(|| Value::String("current".into())); + params.insert("stream_id".into(), Value::String(random_prefixed("stream").ok()?)); + let request = json!({ + "protocol": PROTOCOL, + "type": "request", + "id": id, + "operation": ResourceOperation::SessionEvents.wire_name(), + "params": params, + }); + send(&mut events, &serde_json::to_vec(&request).ok()?).ok()?; + match read_response(&mut events, &id) { + Ok(Ok(_)) => Some(events), + _ => None, + } +} + +/// Blocks until the stream's next item; `false` at `deadline` or when the +/// stream ends. +fn next_event(events: &mut Reader, deadline: Instant) -> bool { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return false; + }; + if remaining.is_zero() || events.get_mut().set_read_timeout(Some(remaining)).is_err() { + return false; + } + matches!(read_envelope(events, false), Ok(Some(_))) +} + /// The workspace that holds `terminal`: terminal -> tab -> pane -> screen. fn caller_workspace( reader: &mut Reader, diff --git a/cmux-tui/crates/cmux-tui/src/cli/skew.rs b/cmux-tui/crates/cmux-tui/src/cli/skew.rs index b3d84db93eb3..733bf41e2e97 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/skew.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/skew.rs @@ -293,7 +293,7 @@ fn tunnel_ssh_destination(socket: &Path) -> Option { } fn own_build_id() -> &'static str { - cmux_remote::ssh_bootstrap::BUILD_IDENTITY + cmux_build_identity::BUILD_IDENTITY } fn team_check(path: &Path) -> Result<(), String> { diff --git a/cmux-tui/crates/cmux-tui/src/cli/wire.rs b/cmux-tui/crates/cmux-tui/src/cli/wire.rs index ed07001b5bd6..431620abaf18 100644 --- a/cmux-tui/crates/cmux-tui/src/cli/wire.rs +++ b/cmux-tui/crates/cmux-tui/src/cli/wire.rs @@ -97,7 +97,8 @@ pub(super) fn run(global: GlobalArgs, mut plan: RequestPlan) -> i32 { return code; } if !plan.resolve.is_empty() { - if let Err(failure) = super::resolve::apply(&mut reader, &mut plan, caller_route) { + let route = super::resolve::SocketRoute { path: &socket, derived: socket_is_derived }; + if let Err(failure) = super::resolve::apply(&mut reader, &mut plan, caller_route, &route) { // A browser tab's page zoom: the app hosts the page and owns it. #[cfg(unix)] if let super::resolve::Failure::AppAction { action, target } = &failure { diff --git a/cmux-tui/crates/cmux-tui/src/daemon_build.rs b/cmux-tui/crates/cmux-tui/src/daemon_build.rs index d65b3bfa23a0..bf698d2ecf23 100644 --- a/cmux-tui/crates/cmux-tui/src/daemon_build.rs +++ b/cmux-tui/crates/cmux-tui/src/daemon_build.rs @@ -5,11 +5,11 @@ /// path cannot be resolved reports its build id only. #[cfg(unix)] pub(crate) fn install() { - // cmux-remote (which stamps the source revision) is a Unix-only + // cmux-build-identity (which stamps the source revision) is a Unix-only // dependency; a Windows daemon reports its package version until the - // stamp moves to a cross-platform crate. + // stamp is a cross-platform dependency. #[cfg(unix)] - let build_id = cmux_remote::ssh_bootstrap::BUILD_IDENTITY.to_owned(); + let build_id = cmux_build_identity::BUILD_IDENTITY.to_owned(); #[cfg(not(unix))] let build_id = env!("CARGO_PKG_VERSION").to_owned(); // Test seam, debug builds only: a daemon that reports another build, so @@ -26,7 +26,7 @@ pub(crate) fn install() { )); } -/// The build id comes from cmux-remote's build script, and cmux-remote is a +/// The build id comes from cmux-build-identity's build script, a /// unix-only dependency. A Windows daemon installs no build, so `identify` /// reports `null` for both fields and leaves out `daemon-build-v1`, as an /// embedder does. diff --git a/cmux-tui/crates/cmux-tui/src/link/ensure.rs b/cmux-tui/crates/cmux-tui/src/link/ensure.rs index 434fb245ff24..5bb548d40c53 100644 --- a/cmux-tui/crates/cmux-tui/src/link/ensure.rs +++ b/cmux-tui/crates/cmux-tui/src/link/ensure.rs @@ -47,7 +47,7 @@ const STATUS_BUDGET: Duration = Duration::from_secs(3); /// The build of this binary, which a link reports in `link.status`. fn build() -> &'static str { - cmux_remote::ssh_bootstrap::BUILD_IDENTITY + cmux_build_identity::BUILD_IDENTITY } /// The write end of the readiness pipe that `ensure` gave `serve` diff --git a/cmux-tui/crates/cmux-tui/src/link/machine_gate.rs b/cmux-tui/crates/cmux-tui/src/link/machine_gate.rs index 7fbe2896bede..ff860821c308 100644 --- a/cmux-tui/crates/cmux-tui/src/link/machine_gate.rs +++ b/cmux-tui/crates/cmux-tui/src/link/machine_gate.rs @@ -75,7 +75,7 @@ pub(super) fn outdated_identity(identity: &Value, needed: &[&str]) -> Option Result<(PathBuf, String), Failed> { .enable_all() .build() .map_err(|e| Failed::new("machine_update_failed", e.to_string()))?; - match runtime.block_on(cmux_remote::pinned_linux_payload(CLOUD_ARCH)) { + match runtime.block_on(cmux_remote::pinned_linux_payload( + CLOUD_ARCH, + cmux_build_identity::BUILD_IDENTITY, + )) { Ok(Some((path, sha256))) if apply::is_sha256(&sha256) => Ok((path, sha256)), Ok(Some(_)) => { Err(Failed::new("machine_update_payload_rejected", "the pinned checksum is malformed")) @@ -237,7 +240,7 @@ fn stage( /// Waits for the apply step's recorded outcome; the new build on success. fn wait_for_outcome(socket: &Path, result: &str, daemon_pid: u64) -> Result { - let own = cmux_remote::ssh_bootstrap::BUILD_IDENTITY; + let own = cmux_build_identity::BUILD_IDENTITY; let deadline = Instant::now() + OUTCOME_TIMEOUT; let mut new_seen: Option = None; while Instant::now() < deadline { diff --git a/cmux-tui/crates/cmux-tui/src/link/machine_update/apply.rs b/cmux-tui/crates/cmux-tui/src/link/machine_update/apply.rs index 38dee1658e39..67d71c617965 100644 --- a/cmux-tui/crates/cmux-tui/src/link/machine_update/apply.rs +++ b/cmux-tui/crates/cmux-tui/src/link/machine_update/apply.rs @@ -186,7 +186,7 @@ fn apply(args: &Args) -> (&'static str, String) { "the daemon did not stop; the new build runs after its next restart".into(), ); } - let own_build = cmux_remote::ssh_bootstrap::BUILD_IDENTITY; + let own_build = cmux_build_identity::BUILD_IDENTITY; if serving(|build| build == own_build, args.daemon_pid) { return ("updated", format!("the daemon runs {own_build}")); } @@ -286,7 +286,7 @@ fn restore(args: &Args, previous: &Path, why: &str) -> (&'static str, String) { // SAFETY: as above. unsafe { libc::kill(pid, libc::SIGTERM) }; } - let new_build = cmux_remote::ssh_bootstrap::BUILD_IDENTITY; + let new_build = cmux_build_identity::BUILD_IDENTITY; if serving(|build| build != new_build, args.daemon_pid) { ("rolled_back", format!("{why}; the previous binary serves again")) } else { diff --git a/cmux-tui/crates/cmux-tui/src/localization/local_server.rs b/cmux-tui/crates/cmux-tui/src/localization/local_server.rs index a6e01fbfec50..56f30dfe5ea3 100644 --- a/cmux-tui/crates/cmux-tui/src/localization/local_server.rs +++ b/cmux-tui/crates/cmux-tui/src/localization/local_server.rs @@ -31,6 +31,11 @@ pub(crate) struct LocalServerMessages { pub owner_restoring: &'static str, pub replaced: &'static str, pub replace_refused: &'static str, + pub registry_compacted: &'static str, + pub registry_compact_owner_running: &'static str, + pub registry_compact_no_registry: &'static str, + pub registry_compact_failed: &'static str, + pub registry_compact_needs_session: &'static str, pub reloaded: &'static str, pub connect_failed: &'static str, pub starting: &'static str, @@ -117,6 +122,11 @@ pub(super) const ENGLISH: LocalServerMessages = LocalServerMessages { owner_restoring: "cmux: the session owner is restoring its terminals; waiting for it (up to 2 minutes)", replaced: "the unresponsive session owner was stopped and a new local server started", replace_refused: "cannot replace the unresponsive session owner; inspect the client log under the session state directory and retry", + registry_compacted: "the session registry was compacted", + registry_compact_owner_running: "the session is running; stop it (cmux-tui server stop) and retry the compaction", + registry_compact_no_registry: "the session has no saved registry to compact", + registry_compact_failed: "cannot compact the session registry; it was left unchanged", + registry_compact_needs_session: "server compact-registry needs --session (the stopped session to compact)", reloaded: "local server configuration reloaded", connect_failed: "cannot connect to the local server; check that the named session is running and retry", starting: "the local server is still starting; retry the lifecycle command", @@ -180,6 +190,11 @@ pub(super) const JAPANESE: LocalServerMessages = LocalServerMessages { owner_restoring: "cmux: セッション所有者がターミナルを復元しています。完了を待っています (最大 2 分)", replaced: "応答しないセッション所有者を停止し、新しいローカルサーバーを起動しました", replace_refused: "応答しないセッション所有者を置き換えられません。セッション状態ディレクトリのクライアントログを確認して再試行してください", + registry_compacted: "セッションレジストリを圧縮しました", + registry_compact_owner_running: "セッションが実行中です。停止してから (cmux-tui server stop) 圧縮を再試行してください", + registry_compact_no_registry: "圧縮する保存済みレジストリがセッションにありません", + registry_compact_failed: "セッションレジストリを圧縮できません。レジストリは変更されていません", + registry_compact_needs_session: "server compact-registry には --session <名前> (圧縮する停止済みセッション) が必要です", reloaded: "ローカルサーバーの設定を再読み込みしました", connect_failed: "ローカルサーバーに接続できません。名前付きセッションが実行中であることを確認して再試行してください", starting: "ローカルサーバーは起動中です。ライフサイクルコマンドを再試行してください", diff --git a/cmux-tui/crates/cmux-tui/src/remote_cli.rs b/cmux-tui/crates/cmux-tui/src/remote_cli.rs index ac9c60c7dede..6813195382bf 100644 --- a/cmux-tui/crates/cmux-tui/src/remote_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/remote_cli.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, anyhow}; use base64::Engine; +use cmux_build_identity::BUILD_IDENTITY; use cmux_remote::admin::{ AdminRequest, AdminResponse, UnixPeerAuthError, call_admin, call_admin_with_peer_exit, verify_unix_peer_owner, @@ -32,7 +33,7 @@ use cmux_remote::provider::{ WireGuardDialer, sanitized_route, }; use cmux_remote::secure_directory::{DirectoryAccess, ensure_secure_directory}; -use cmux_remote::ssh_bootstrap::{BUILD_IDENTITY, DISTRIBUTION_VERSION, NPM_BOOTSTRAP_VERSION}; +use cmux_remote::ssh_bootstrap::{DISTRIBUTION_VERSION, NPM_BOOTSTRAP_VERSION}; use cmux_remote_protocol::{ LanePolicy, REMOTE_PROTOCOL_VERSION, RoutePolicy, SessionId, WorkspaceRequest, WorkspaceResponse, diff --git a/cmux-tui/crates/cmux-tui/src/remote_runtime.rs b/cmux-tui/crates/cmux-tui/src/remote_runtime.rs index 17c5e0211168..756c340fd3a1 100644 --- a/cmux-tui/crates/cmux-tui/src/remote_runtime.rs +++ b/cmux-tui/crates/cmux-tui/src/remote_runtime.rs @@ -1154,7 +1154,7 @@ async fn bootstrap_initial_ssh_route( shutdown: Option>, ) -> anyhow::Result<()> { let (destination, port) = ssh_bootstrap_destination(endpoint)?; - let mut config = SshBootstrapConfig::defaults(destination); + let mut config = SshBootstrapConfig::defaults(destination, cmux_build_identity::BUILD_IDENTITY); config.ssh_binary = ssh.ssh_binary.clone(); config.port = port; config.remote_binary = ssh.remote_binary.clone(); @@ -5834,7 +5834,7 @@ mod tests { distribution_version: Some(cmux_remote::ssh_bootstrap::DISTRIBUTION_VERSION.into()), npm_bootstrap_version: cmux_remote::ssh_bootstrap::NPM_BOOTSTRAP_VERSION .map(str::to_owned), - build_identity: Some(cmux_remote::ssh_bootstrap::BUILD_IDENTITY.into()), + build_identity: Some(cmux_build_identity::BUILD_IDENTITY.into()), remote_protocol: cmux_remote_protocol::REMOTE_PROTOCOL_VERSION, os: "test".into(), arch: "test".into(), diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief.rs index 3a68c5d58842..84ff885bf57c 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief.rs @@ -6,6 +6,8 @@ use super::*; use serde_json::{Value, json}; use std::os::fd::FromRawFd; +#[path = "chief_adopt.rs"] +mod adopt; #[path = "chief_autostart.rs"] mod autostart; #[cfg(unix)] diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs new file mode 100644 index 000000000000..f87c117dd2ad --- /dev/null +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_adopt.rs @@ -0,0 +1,370 @@ +//! `chief.profile.adopt` (`chief-profile-adopt-v1`) against a real headless +//! daemon: the person makes an agent that is already in a conversation a +//! Chief record under its own id with `cmux chief adopt`. The DM and its +//! history stay the agent's, its credential still works, and the record gets +//! a new Chief's defaults. Each refusal is one behavior: the default Chief, a +//! person, an unknown agent, a live Chief, a deleted Chief (its tombstone and +//! kept memory are never revived), an idempotency key reused for another +//! request, and an agent caller of the CLI. The adopt reaches the Chief brain +//! supervisor like a create. + +use serde_json::{Value, json}; + +use super::*; + +fn v2(conn: &mut Conn, operation: &str, params: Value, key: Option<&str>) -> Value { + let id = format!("adopt-{}", conn.next); + conn.next += 1; + let mut params = params.as_object().cloned().unwrap_or_default(); + params.insert("machine".into(), json!("current")); + params.insert("session".into(), json!("current")); + let mut frame = json!({"protocol": "cmux.protocol/2", "type": "request", "id": id, + "operation": operation, "params": params}); + if let Some(key) = key { + frame["idempotency_key"] = json!(key); + } + writeln!(conn.writer, "{frame}").unwrap(); + loop { + let line = conn.line(); + if line["id"] == json!(id) && line["type"] == "response" { + return line; + } + } +} + +fn ok(response: &Value) -> Value { + assert_eq!(response["ok"], true, "{response}"); + response["result"].clone() +} + +fn reason(response: &Value) -> String { + assert_eq!(response["ok"], false, "{response}"); + let code = response["error"]["code"].as_str().unwrap_or("").to_owned(); + match response["error"]["details"]["reason"].as_str() { + Some(reason) if code == "operation.failed" => reason.to_owned(), + _ => code, + } +} + +/// A DM between the person and `agent` (display name `name`); its id. +fn dm_with(home: &mut Conn, agent: &str, name: &str) -> String { + let created = home.request( + "conversation-create", + json!({"idempotency_key": format!("dm-{agent}"), "actor": "user_local", "title": name, + "participants": [ + {"id": "user_local", "kind": "human", "display_name": "Tester"}, + {"id": agent, "kind": "agent", "display_name": name, "agent_class": "mux", + "acp_session": agent}]}), + ); + created["conversation"]["id"].as_str().unwrap().to_owned() +} + +/// One v1 request; the whole reply, refused or not. +fn try_request(conn: &mut Conn, cmd: &str, params: Value) -> Value { + let id = json!(format!("try-{}", conn.next)); + conn.next += 1; + let mut body = params.as_object().cloned().unwrap_or_default(); + body.insert("id".into(), id.clone()); + body.insert("cmd".into(), json!(cmd)); + writeln!(conn.writer, "{}", Value::Object(body)).unwrap(); + loop { + let value = conn.line(); + if value.get("event").is_none() && value["id"] == id { + return value; + } + } +} + +/// `chief.profile.adopt` on `home`, retried while the participant's old +/// connection is still being closed by the daemon (`participant_bound`). +fn adopt_once_unbound(home: &mut Conn, params: Value, key: &str) -> Value { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let response = v2(home, "chief.profile.adopt", params.clone(), Some(key)); + if response["ok"] == true || reason(&response) != "participant_bound" { + return response; + } + assert!(Instant::now() < deadline, "the old connection never went away: {response}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn say(conn: &mut Conn, conversation: &str, key: &str, text: &str) { + conn.request( + "conversation-op", + json!({"conversation": conversation, "idempotency_key": key, + "op": {"kind": "message.send", "client_msg_id": key, + "parts": [{"type": "text", "text": text}]}}), + ); +} + +fn texts(home: &mut Conn, conversation: &str) -> Vec<(String, String)> { + let snapshot = + home.request("conversation-snapshot", json!({"conversation": conversation, "tail": 50})); + snapshot["messages"] + .as_array() + .unwrap() + .iter() + .map(|m| { + ( + m["author"].as_str().unwrap_or("").to_owned(), + m["parts"][0]["text"].as_str().unwrap_or("").to_owned(), + ) + }) + .collect() +} + +#[test] +fn the_person_adopts_an_agent_and_it_keeps_its_dm_history_and_credential() { + let server = HeadlessServer::start("chief-adopt"); + let mut home = Conn::open(&server.socket); + let identity = home.request("identify", json!({})); + assert!( + identity["capabilities"].as_array().unwrap().iter().any(|c| c == "chief-profile-adopt-v1"), + "{identity}" + ); + let dm = dm_with(&mut home, "agent_release", "Release Chief"); + let token = + home.request("conversation-agent-token", json!({"participant": "agent_release"}))["token"] + .as_str() + .unwrap() + .to_owned(); + let mut agent = Conn::open(&server.socket); + agent.request("conversation-bind", json!({"participant": "agent_release", "token": token})); + say(&mut home, &dm, "q1", "Is the train ready?"); + say(&mut home, &dm, "q2", "The 0.66 notes too, please."); + let before = texts(&mut home, &dm); + // A subscriber that asked for Chief changes hears the adopt as a create. + let mut watcher = Conn::open(&server.socket); + watcher.request( + "set-client-info", + json!({"name": "watcher", "capabilities": ["chief-events-v1"]}), + ); + watcher.request("subscribe", json!({})); + + // While its agent session is bound, the adopt is refused with a hint: + // two processes never speak as one participant. + let output = + chief_cli(&server, &["adopt", "agent_release", "--role", "Release trains", "--json"], ""); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("participant_bound"), "{output:?}"); + // The session stops; the adopt goes through. + drop(agent); + let record = adopt_once_unbound( + &mut home, + json!({"participant_id": "agent_release", "role": "Release trains"}), + "adopt-release", + ); + let record = ok(&record)["value"].clone(); + assert_eq!(record["id"], "agent_release", "{record}"); + assert_eq!(record["display_name"], "Release Chief"); + assert_eq!(record["role"], "Release trains"); + assert_eq!(record["self_edit"], json!(["avatar"])); + assert_eq!(record["tools"], json!(["spawn", "tell", "workspaces"])); + assert_eq!(record["instructions"], ""); + assert_eq!(record["is_default"], false); + + let change = loop { + let event = watcher.event(); + if event["event"] == "chief-changed" { + break event; + } + }; + assert_eq!(change["chief"], "agent_release", "{change}"); + assert_eq!(change["change"], "created", "{change}"); + + // The DM and its history are the same. The credential issued before the + // adopt is revoked; the person mints the Chief's new one (no brain + // supervisor here) and it posts in the same DM. + assert_eq!(texts(&mut home, &dm), before); + let mut old = Conn::open(&server.socket); + let refused = try_request( + &mut old, + "conversation-bind", + json!({"participant": "agent_release", "token": token}), + ); + assert_eq!(refused["ok"], false, "a pre-adopt credential bound: {refused}"); + let fresh = + home.request("conversation-agent-token", json!({"participant": "agent_release"}))["token"] + .as_str() + .unwrap() + .to_owned(); + let mut agent = Conn::open(&server.socket); + agent.request("conversation-bind", json!({"participant": "agent_release", "token": fresh})); + say(&mut agent, &dm, "a2", "Adopted and still here."); + let after = texts(&mut home, &dm); + assert_eq!(after.len(), before.len() + 1); + assert_eq!( + after.last().unwrap(), + &("agent_release".to_owned(), "Adopted and still here.".to_owned()) + ); + let listed = ok(&v2(&mut home, "chief.profile.list", json!({}), None)); + let ids: Vec<&str> = + listed["chiefs"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert_eq!(ids, vec!["agent_mux", "agent_release"], "{listed}"); + + // Idempotent per key: a retry is the committed record; another request under the key is refused. + let dm_infra = dm_with(&mut home, "agent_infra", "Infra Chief"); + let first = ok(&v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_infra", "display_name": "Infra"}), + Some("adopt-infra"), + )); + assert_eq!(first["replayed"], false); + assert_eq!(first["value"]["display_name"], "Infra"); + let again = ok(&v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_infra", "display_name": "Infra"}), + Some("adopt-infra"), + )); + assert_eq!(again["replayed"], true); + assert_eq!(again["value"], first["value"]); + let clash = v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_infra", "display_name": "Other"}), + Some("adopt-infra"), + ); + assert_eq!(reason(&clash), "idempotency.conflict"); + let _ = dm_infra; + + // Typed refusals. + let refusal = |home: &mut Conn, participant: &str, key: &str| { + reason(&v2(home, "chief.profile.adopt", json!({"participant_id": participant}), Some(key))) + }; + let _ = dm_with(&mut home, "agent_mux", "mux"); + assert_eq!(refusal(&mut home, "agent_mux", "r-default"), "default_chief"); + assert_eq!(refusal(&mut home, "user_local", "r-person"), "not_an_agent_participant"); + assert_eq!(refusal(&mut home, "agent_nobody", "r-unknown"), "not_an_agent_participant"); + assert_eq!(refusal(&mut home, "agent_release", "r-live"), "already_chief"); + // Another agent whose brain directory (the last 8 characters) is agent_release's. + let _ = dm_with(&mut home, "agent_x_release", "Twin"); + assert_eq!(refusal(&mut home, "agent_x_release", "r-twin"), "brain_home_taken"); + + // A deleted Chief that was in a conversation: its id is never revived. + let made = + ok(&v2(&mut home, "chief.profile.create", json!({"display_name": "Gone"}), Some("mk"))); + let gone = made["value"]["id"].as_str().unwrap().to_owned(); + let _ = dm_with(&mut home, &gone, "Gone"); + ok(&v2(&mut home, "chief.profile.delete", json!({"chief": gone}), Some("rm"))); + assert_eq!(refusal(&mut home, &gone, "r-deleted"), "deleted_chief"); + + // The CLI refuses an agent caller before it connects; nothing is written. + let dm_design = dm_with(&mut home, "agent_design", "Design Chief"); + let output = chief_cli_as( + &server, + &["adopt", "agent_design", "--json"], + "", + &[("ACPMUX_SESSION_ID", "s_chief_turn")], + ); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + let error = json_error(&output); + assert_eq!(error["code"], "origin.forbidden", "{error}"); + assert_eq!(error["details"]["reason"], "agent_caller", "{error}"); + assert_eq!(error["details"]["marker"], "ACPMUX_SESSION_ID", "{error}"); + let listed = ok(&v2(&mut home, "chief.profile.list", json!({}), None)); + assert!( + !listed["chiefs"].as_array().unwrap().iter().any(|c| c["id"] == "agent_design"), + "{listed}" + ); + let _ = dm_design; +} + +#[test] +fn an_adopt_counts_toward_the_chief_limit() { + let server = HeadlessServer::start("chief-adopt-limit"); + let mut home = Conn::open(&server.socket); + let _ = dm_with(&mut home, "agent_release", "Release Chief"); + // The default Chief and 63 more: 64 live Chiefs, the limit. + for n in 0..63 { + ok(&v2( + &mut home, + "chief.profile.create", + json!({"display_name": format!("Chief {n}")}), + Some(&format!("fill-{n}")), + )); + } + let refused = v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_release"}), + Some("adopt-full"), + ); + assert_eq!(reason(&refused), "too_many_chiefs"); +} + +const BRAIN_STUB: &str = r#"#!/bin/sh +n=$(cat "$MUX_HOME/starts" 2>/dev/null || echo 0) +n=$((n+1)) +echo "$n" > "$MUX_HOME/starts" +exec sleep 600 +"#; + +/// An adopt is a committed Chief write like a create: the brain supervisor +/// starts the adopted Chief's brain. +#[test] +fn an_adopted_chief_reaches_the_brain_supervisor_like_a_create() { + use std::os::unix::fs::PermissionsExt; + let files = unique_temp_dir("adopt-b"); + fs::create_dir_all(&files).unwrap(); + let stub = files.join("brain.sh"); + fs::write(&stub, BRAIN_STUB).unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap(); + let chief_home = files.join("chief-home"); + fs::create_dir_all(&chief_home).unwrap(); + let brains = json!({"executable": stub, "home": chief_home, + "app": {"bin_dir": "/opt/cmux-test/bin", "user_name": "Test Person", + "chief_title": "Chief", "acpmux_bin": "/opt/cmux-test/bin/acpmux"}}) + .to_string(); + let mut server = HeadlessServer::start_with_options( + "chief-adopt-brains", + None, + None, + &[("CMUX_TUI_CHIEF_BRAINS", brains.as_str())], + ); + let mut home = Conn::open(&server.socket); + let _ = dm_with(&mut home, "agent_release", "Release Chief"); + let token = + home.request("conversation-agent-token", json!({"participant": "agent_release"}))["token"] + .as_str() + .unwrap() + .to_owned(); + let mut session = Conn::open(&server.socket); + session.request("conversation-bind", json!({"participant": "agent_release", "token": token})); + let bound = v2( + &mut home, + "chief.profile.adopt", + json!({"participant_id": "agent_release"}), + Some("a0"), + ); + assert_eq!(reason(&bound), "participant_bound"); + drop(session); + ok(&adopt_once_unbound(&mut home, json!({"participant_id": "agent_release"}), "adopt-release")); + // With a brain supervisor, only the brain holds the Chief's credential. + let mint = + try_request(&mut home, "conversation-agent-token", json!({"participant": "agent_release"})); + assert_eq!(mint["ok"], false, "the person minted a supervised Chief's credential: {mint}"); + let id = "agent_release"; + let brain = chief_home.join("c").join(id[id.len() - 8..].to_ascii_lowercase()); + let starts = brain.join("starts"); + let deadline = Instant::now() + Duration::from_secs(20); + while !starts.exists() { + assert!(Instant::now() < deadline, "the adopted Chief's brain never started"); + std::thread::sleep(Duration::from_millis(25)); + } + // The brain's credential (minted by the supervisor) binds; the old one does not. + let brain_token = fs::read_to_string(brain.join("agent-token")).unwrap(); + let mut chief = Conn::open(&server.socket); + chief.request("conversation-bind", json!({"participant": id, "token": brain_token.trim()})); + let mut old = Conn::open(&server.socket); + let refused = + try_request(&mut old, "conversation-bind", json!({"participant": id, "token": token})); + assert_eq!(refused["ok"], false, "{refused}"); + let daemon = i32::try_from(server.child.id()).unwrap(); + // SAFETY: the test's own daemon child. + unsafe { libc::kill(daemon, libc::SIGTERM) }; + let _ = server.child.wait(); + let _ = fs::remove_dir_all(&files); +} diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs index 814b4042d3ce..530deefb3d71 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_autostart.rs @@ -151,6 +151,9 @@ impl Sandbox { .env_remove("CMUX_SOCKET_PATH") .env_remove("CMUX_BUNDLE_ID") .env_remove("CMUX_TAG") + // The build-session kill switch only where a test sets it. + .env_remove("ACPMUX_AGENT_LAUNCH") + .env_remove("CMUX_AGENTS_DISABLED") // The test's own variables last, so no removal above drops one. .envs(env.iter().copied()) .stdin(Stdio::null()) @@ -411,3 +414,35 @@ fn chief_shutdown_tells_the_app_until_the_next_chief_starts_it() { assert!(!marker.exists(), "the next cmux chief takes the marker away"); assert_success(&sandbox.chief(&["shutdown", "--chief-home", home_arg])); } + +#[test] +fn chief_says_at_once_that_agents_are_disabled_and_starts_nothing() { + // cx-tgf4h: a GUI build job runs with ACPMUX_AGENT_LAUNCH=deny (or + // CMUX_AGENTS_DISABLED=1). `cmux chief -p` waited 240 s for a reply that + // no turn could give; it must fail at once with the typed refusal and + // start no daemon and no brain. + let sandbox = Sandbox::new("chief-agents-disabled"); + let home = sandbox.isolated(); + let home_arg = home.to_str().unwrap(); + for (key, value) in [("ACPMUX_AGENT_LAUNCH", "deny"), ("CMUX_AGENTS_DISABLED", "1")] { + let started = Instant::now(); + let output = + sandbox.chief_with(&["--chief-home", home_arg, "-p", "hello"], &[(key, value)]); + assert!(started.elapsed() < Duration::from_secs(10), "{key}: not at once"); + assert_eq!(output.status.code(), Some(1), "{key}: {}", stderr_of(&output)); + assert!(output.stdout.is_empty(), "{key}: no reply"); + let stderr = stderr_of(&output); + assert!(stderr.contains("agents are disabled in this build session"), "{key}: {stderr}"); + + let json = sandbox + .chief_with(&["--chief-home", home_arg, "--json", "-p", "hello"], &[(key, value)]); + assert_eq!(json.status.code(), Some(1), "{key}: {}", stderr_of(&json)); + let error: Value = serde_json::from_str(stderr_of(&json).trim()) + .unwrap_or_else(|e| panic!("{key}: typed JSON error: {e}: {}", stderr_of(&json))); + assert_eq!(error["code"], "operation.failed", "{key}: {error}"); + assert_eq!(error["details"]["reason"], "agent_launch_disabled", "{key}: {error}"); + assert_eq!(error["retryable"], false, "{key}: {error}"); + } + assert_eq!(brain_starts(&home), 0, "no brain started"); + assert!(!home.join("tui").exists(), "no session daemon started"); +} diff --git a/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs b/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs index ee01a36a830d..8fb457e3d872 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/chief_brains.rs @@ -383,3 +383,127 @@ fn a_chief_brain_finds_claude_on_the_persons_login_path() { let _ = owned.child.wait(); wait_gone(pid, "after daemon shutdown"); } + +/// The account's home directory (the user database), as the app's default +/// Chief host gets it (`FileManager.homeDirectoryForCurrentUser`). +fn account_home() -> String { + // SAFETY: getpwuid returns a pointer into static storage or null; it is + // read at once, on this thread. + unsafe { + let record = libc::getpwuid(libc::getuid()); + assert!(!record.is_null(), "no user database record"); + std::ffi::CStr::from_ptr((*record).pw_dir).to_str().unwrap().to_owned() + } +} + +/// A new Chief's brain gets the same HOME as the default Chief's (hq-6d P1 +/// 2026-10-14, mac26 401): the account's home, not the HOME of the process +/// that started the app. An app started by a job runner with its own HOME +/// (nx-remote on mac26: HOME is the job root) gave the daemon that HOME; the +/// new Chief's host then read the person's Claude settings env (the +/// subrouter route) from `/.claude/settings.json`, found none, +/// and its turns signed in with the account's expired login. +#[test] +fn a_chief_brain_gets_the_accounts_home_like_the_default_chief() { + use std::os::unix::fs::PermissionsExt; + + let files = unique_temp_dir("cbh"); + fs::create_dir_all(&files).unwrap(); + let stub = files.join("brain.sh"); + fs::write(&stub, STUB).unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap(); + let chief_home = files.join("h"); + fs::create_dir_all(&chief_home).unwrap(); + // The job runner's own HOME, which the daemon inherits. + let job_home = files.join("job-home"); + fs::create_dir_all(job_home.join(".claude")).unwrap(); + let brains = json!({ + "executable": stub, + "home": chief_home, + "app": {"bin_dir": "/opt/cmux-test/bin", "user_name": "Test Person", + "chief_title": "Chief", "acpmux_bin": "/usr/bin/false"}, + }) + .to_string(); + let job_home_text = job_home.display().to_string(); + let mut owned = HeadlessServer::start_with_options( + "chief-brains-home", + None, + None, + &[("CMUX_TUI_CHIEF_BRAINS", brains.as_str()), ("HOME", job_home_text.as_str())], + ); + let server = &owned; + let scout = make_chief(server, "Scout", "Researcher"); + let scout_home = brain_dir(&chief_home, &scout); + let pid: i32 = wait_file(&scout_home.join("pid.1")).parse().unwrap(); + let env = env_of(&scout_home.join("env.1")); + assert_eq!( + env.get("HOME").map(String::as_str), + Some(account_home().as_str()), + "the brain's HOME is not the account's (the job runner's was {job_home_text})" + ); + + let daemon = i32::try_from(owned.child.id()).unwrap(); + // SAFETY: the test's own daemon child. + unsafe { libc::kill(daemon, libc::SIGTERM) }; + let _ = owned.child.wait(); + wait_gone(pid, "after daemon shutdown"); +} + +/// cx-tgf4h: a daemon started with agents off in its build session +/// (`ACPMUX_AGENT_LAUNCH=deny`, as a GUI build job sets it) starts no brain +/// for a new Chief, also on a message to it, and `cmux chief engine` says +/// why at once instead of "not running". +#[test] +fn a_daemon_with_agents_off_starts_no_brain_and_says_why() { + use std::os::unix::fs::PermissionsExt; + + let files = unique_temp_dir("cbd"); + fs::create_dir_all(&files).unwrap(); + let stub = files.join("brain.sh"); + fs::write(&stub, STUB).unwrap(); + fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap(); + let chief_home = files.join("h"); + fs::create_dir_all(&chief_home).unwrap(); + let brains = json!({ + "executable": stub, + "home": chief_home, + "app": {"bin_dir": "/opt/cmux-test/bin", "user_name": "Test Person", + "chief_title": "Chief", "acpmux_bin": "/usr/bin/false"}, + }) + .to_string(); + let mut owned = HeadlessServer::start_with_options( + "chief-brains-deny", + None, + None, + &[("CMUX_TUI_CHIEF_BRAINS", brains.as_str()), ("ACPMUX_AGENT_LAUNCH", "deny")], + ); + let server = &owned; + let mut home = Conn::open(&server.socket); + let scout = make_chief(server, "Scout", "Researcher"); + let conversation = dm(&mut home, "deny-dm", &scout, "Scout"); + say(&mut home, &conversation, "deny-1", "hello"); + // The supervisor says, once, that the brain does not start. + let deadline = Instant::now() + Duration::from_secs(20); + let line = format!("Chief brain {scout} does not start: agents are disabled"); + while !server.stderr_tail().contains(&line) { + assert!( + Instant::now() < deadline, + "no refusal line; daemon stderr: {}", + server.stderr_tail() + ); + std::thread::sleep(Duration::from_millis(25)); + } + let scout_home = brain_dir(&chief_home, &scout); + assert!(!scout_home.join("starts").exists(), "a brain started under deny"); + + let engine = chief_cli(server, &["engine", "--json"], ""); + assert_eq!(engine.status.code(), Some(1), "{}", String::from_utf8_lossy(&engine.stderr)); + let stderr = String::from_utf8_lossy(&engine.stderr); + assert!(stderr.contains("agents are disabled in this build session"), "{stderr}"); + + let daemon = i32::try_from(owned.child.id()).unwrap(); + // SAFETY: the test's own daemon child. + unsafe { libc::kill(daemon, libc::SIGTERM) }; + let _ = owned.child.wait(); + let _ = fs::remove_dir_all(&files); +} diff --git a/cmux-tui/crates/cmux-tui/tests/cli/status.rs b/cmux-tui/crates/cmux-tui/tests/cli/status.rs index f72dfacfae03..8c24694db316 100644 --- a/cmux-tui/crates/cmux-tui/tests/cli/status.rs +++ b/cmux-tui/crates/cmux-tui/tests/cli/status.rs @@ -373,6 +373,70 @@ fn status_set_refuses_a_forged_owner() { assert_eq!(entries(&server, &workspace), Vec::::new()); } +/// cx-odyw4: a new terminal's shell can run `cmux status set` before the +/// daemon has committed its tab. The set waits for the terminal to join its +/// workspace, exits 0, and the entry is there. +#[test] +fn status_set_from_a_terminal_at_its_first_command_reaches_its_workspace() { + let server = HeadlessServer::start("status-new-terminal"); + state_cli(&server, None, &["workspace", "create", "--name", "delta"]); + let workspace = workspace_id_named(&server, "delta"); + let mut runs = Vec::new(); + for index in 0..5 { + let code = server.dir.join(format!("set-{index}.code")); + let log = server.dir.join(format!("set-{index}.log")); + let script = format!( + "'{bin}' --json status set race-{index} 'Ready {index}' > '{log}' 2>&1; \ + echo $? > '{code}.tmp' && mv '{code}.tmp' '{code}'", + bin = bin(), + log = log.display(), + code = code.display(), + ); + let output = status_output( + &server, + None, + &["workspace", &workspace, "run", "--on-exit", "keep", "--", "/bin/sh", "-c", &script], + ); + assert_success(&output); + let terminal = json_output(&output)["value"]["terminal_id"].as_str().unwrap().to_owned(); + runs.push((index, terminal, code, log)); + } + for (index, terminal, code, log) in runs { + let deadline = Instant::now() + Duration::from_secs(20); + let exit = loop { + if let Ok(text) = fs::read_to_string(&code) { + break text.trim().to_owned(); + } + assert!(Instant::now() < deadline, "status set in {terminal} never finished"); + std::thread::sleep(Duration::from_millis(50)); + }; + let printed = fs::read_to_string(&log).unwrap_or_default(); + assert_eq!(exit, "0", "status set in new terminal {terminal} failed: {printed}"); + let key = format!("race-{index}"); + let set = entry(&server, &workspace, &key) + .unwrap_or_else(|| panic!("{key} from {terminal} is not in its workspace: {printed}")); + assert_eq!(set["text"], format!("Ready {index}"), "{set}"); + } +} + +/// A caller terminal the session never shows fails the set after a bounded +/// wait, non-zero, with a message that names the terminal and the way out. +#[test] +fn status_set_from_an_unknown_terminal_exits_non_zero_after_a_bounded_wait() { + let server = HeadlessServer::start("status-unknown-terminal"); + state_cli(&server, None, &["workspace", "create", "--name", "epsilon"]); + let unknown = format!("term_{}", "0".repeat(32)); + let started = Instant::now(); + let output = status_output(&server, Some(&unknown), &["status", "set", "lost", "Lost"]); + assert!(started.elapsed() < Duration::from_secs(15), "the wait is not bounded"); + assert!(!output.status.success(), "a set from an unknown terminal succeeded"); + let error = json_error(&output); + assert_eq!(error["code"], "selector.not_found", "{error}"); + let message = error["message"].as_str().unwrap_or_default(); + assert!(message.contains(&unknown), "{error}"); + assert!(message.contains("cmux workspace "), "{error}"); +} + /// cx-uf8b (nxdog94 preflight): the root help lists `status`, `status --help` /// and `status set --help` print the status usage (they printed the root /// help), and `status set KEY TEXT`, the `workspace status set` form, sets the diff --git a/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs index 1986eafebbce..72d45613d473 100644 --- a/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs @@ -13,6 +13,17 @@ use std::time::{Duration, Instant}; const TERMINAL_ONE: &str = "term_00000000000000000000000000000001"; const TERMINAL_TWO: &str = "term_00000000000000000000000000000002"; +#[cfg(target_os = "linux")] +struct RouteOwner(std::process::Child); + +#[cfg(target_os = "linux")] +impl Drop for RouteOwner { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + struct Launch { root: tempfile::TempDir, socket: PathBuf, @@ -105,14 +116,31 @@ impl Launch { } } +/// Applies the `-c` overrides exactly as Codex does (codex-rs/utils/cli +/// `parse_overrides` plus codex-rs/config `apply_toml_override`): the key is +/// everything before the first `=` and is split on EVERY `.`, ignoring TOML +/// quoting, and each override replaces the value at its path. Parsing the +/// overrides as a TOML document instead would honor quoted keys that Codex +/// splits, hiding settings Codex never applies. fn settings(argv: &[String]) -> toml::Value { - let text = argv - .windows(2) - .filter(|pair| pair[0] == "-c") - .map(|pair| pair[1].as_str()) - .collect::>() - .join("\n"); - toml::from_str(&text).unwrap() + let mut root = toml::Value::Table(toml::Table::new()); + for pair in argv.windows(2).filter(|pair| pair[0] == "-c") { + let (path, raw) = pair[1].split_once('=').unwrap(); + let value = toml::from_str::(&format!("_x_ = {}", raw.trim())) + .unwrap_or_else(|error| panic!("Codex cannot parse override {:?}: {error}", pair[1])) + .remove("_x_") + .unwrap(); + let segments: Vec<&str> = path.trim().split('.').collect(); + let (last, parents) = segments.split_last().unwrap(); + let mut current = &mut root; + for segment in parents { + let table = current.as_table_mut().unwrap(); + current = + table.entry(*segment).or_insert_with(|| toml::Value::Table(toml::Table::new())); + } + current.as_table_mut().unwrap().insert((*last).to_owned(), value); + } + root } fn hook_command(config: &toml::Value, event: &str) -> String { @@ -177,6 +205,67 @@ fn codex_wrapper_remote_hooks_keep_each_terminal_under_a_shared_daemon_environme assert_eq!(stale.listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock); } +#[cfg(target_os = "linux")] +#[test] +fn codex_wrapper_resolves_the_attached_tmux_terminal_when_launch_environment_is_missing() { + let launch = Launch::new(); + // This process stands in for a tmux attach client. Its initial environment + // is readable through /proc, and the open stdin keeps it alive without a timer. + let route_owner = RouteOwner( + Command::new("/bin/cat") + .env_clear() + .env("CMUX_TUI_SOCKET", &launch.socket) + .env("CMUX_TUI_TERMINAL_ID", TERMINAL_TWO) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + + let tmux_dir = launch.root.path().join("tmux-bin"); + fs::create_dir(&tmux_dir).unwrap(); + let tmux = tmux_dir.join("tmux"); + fs::write( + &tmux, + format!( + "#!/bin/sh\ncase \"$1\" in\ndisplay-message) printf '%b\\n' '$1\\t\\t@1';;\nlist-clients) printf '%b\\n' '{}\\t999\\t$1\\t\\t@1';;\nesac\n", + route_owner.0.id() + ), + ) + .unwrap(); + fs::set_permissions(&tmux, fs::Permissions::from_mode(0o700)).unwrap(); + + let mut command = launch.command(); + command + .env_remove("CMUX_TUI_SOCKET") + .env_remove("CMUX_TUI_TERMINAL_ID") + .env("TMUX", "fake-server,1,0") + .env("TMUX_PANE", "%1") + .env( + "PATH", + format!( + "{}:{}:/usr/bin:/bin", + tmux_dir.display(), + launch.root.path().join("bin").display() + ), + ) + .args(["exec", "hello"]); + let argv = launch.argv(&mut command); + let config = settings(&argv); + let hook = hook_command(&config, "SessionStart"); + run_hook(&hook, None, TERMINAL_TWO); + let received = launch.event(); + assert_eq!(received["payload"]["native_event"], "SessionStart"); + assert!( + received["subjects"] + .as_array() + .unwrap() + .iter() + .any(|subject| { subject["kind"] == "terminal" && subject["id"] == TERMINAL_TWO }) + ); +} + #[test] fn codex_wrapper_passes_through_management_opt_out_and_missing_routes() { let launch = Launch::new(); diff --git a/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs b/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs index 593eb98cd6e3..735efdb80039 100644 --- a/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs +++ b/cmux-tui/crates/cmux-tui/tests/legacy_registry_maintenance.rs @@ -9,6 +9,11 @@ //! shape, the owner is started on it with a short maintenance interval and a //! small live-data bound (debug-build overrides), and the registry file is //! read after maintenance ticks. +//! +//! `server compact-registry` (cx-ossub) gives a large legacy registry's free +//! pages back offline: refused while the owner runs, and with the owner +//! stopped it rewrites the file smaller with the same rows and incremental +//! auto-vacuum, which the next owner opens. #![cfg(all(unix, debug_assertions))] @@ -204,3 +209,99 @@ fn a_small_legacy_registry_is_converted() { owner.stop(); assert_eq!(state, (2, true), "a small legacy registry converts to incremental auto-vacuum"); } + +/// Row count of every table, in name order: the content a compaction must keep. +fn table_rows(registry: &Path) -> Vec<(String, i64)> { + let db = + Connection::open_with_flags(registry, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap(); + let tables: Vec = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + tables + .into_iter() + .map(|table| { + let count = db + .query_row(&format!("SELECT COUNT(*) FROM \"{table}\""), [], |row| row.get(0)) + .unwrap(); + (table, count) + }) + .collect() +} + +fn compact(dir: &Path, session: &str) -> (bool, String) { + let output = command(dir) + .args(["--json", "server", "compact-registry", "--session", session]) + .output() + .unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + (output.status.success(), text) +} + +#[test] +fn compact_registry_is_refused_while_the_owner_runs_and_shrinks_it_offline() { + let dir = state_dir("compact"); + let session = "lrm-compact"; + let registry = first_start(&dir.0, session); + // Live data stays above the in-place bound (the owner must not convert + // it itself), and as much again is freed: a legacy file keeps those pages. + let kept_chunks = 2 * TEST_BOUND_BYTES / (1024 * 1024); + make_legacy(®istry, 4 * TEST_BOUND_BYTES); + { + let db = Connection::open(®istry).unwrap(); + db.execute("DELETE FROM legacy_filler WHERE rowid > ?1", [kept_chunks as i64]).unwrap(); + db.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").unwrap(); + } + + let owner = Owner::start(&dir.0, session); + let (ok, text) = compact(&dir.0, session); + assert!(!ok, "compaction must be refused while the owner runs: {text}"); + assert!(text.contains("server.compact_owner_running"), "refusal code: {text}"); + assert!(owner.answers(), "the owner keeps serving after a refused compaction"); + owner.stop(); + assert_eq!(conversion(®istry), (0, false), "the owner left the legacy registry as it was"); + let stopped_rows = table_rows(®istry); + let before_len = fs::metadata(®istry).unwrap().len(); + + let output = command(&dir.0).args(["--json", "server", "compact-registry"]).output().unwrap(); + assert!(!output.status.success(), "compaction without --session is refused"); + + let (ok, text) = compact(&dir.0, session); + assert!(ok, "compaction with the owner stopped: {text}"); + assert!(text.contains("\"compacted\""), "status: {text}"); + let after_len = fs::metadata(®istry).unwrap().len(); + assert!( + after_len + TEST_BOUND_BYTES as u64 <= before_len, + "the file gives its free pages back: {before_len} -> {after_len} bytes" + ); + assert_eq!(table_rows(®istry), stopped_rows, "every table keeps its rows"); + assert_eq!( + stopped_rows.iter().find(|(table, _)| table == "legacy_filler"), + Some(&("legacy_filler".to_string(), kept_chunks as i64)), + "the fixture kept its filler rows" + ); + { + let db = Connection::open_with_flags(®istry, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + assert_eq!(auto_vacuum(&db), 2, "the compacted registry is incremental auto-vacuum"); + } + + // The next owner opens the compacted registry and marks it migrated. + let owner = Owner::start(&dir.0, session); + let deadline = Instant::now() + Duration::from_secs(20); + let mut state = conversion(®istry); + while state != (2, true) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + state = conversion(®istry); + } + assert!(owner.answers(), "the owner serves the compacted registry"); + owner.stop(); + assert_eq!(state, (2, true), "the owner marks the compacted registry migrated"); +} diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs index 8a0fae30d647..1772bcacbcca 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/reopen_closed_kinds.rs @@ -565,6 +565,55 @@ fn a_closed_tab_group_reopens_as_its_group() { assert_eq!(groups[0]["tab_ids"], serde_json::Value::Array(back), "{groups:?}"); } +/// Close Group that also ends its terminals takes the batch close path. +#[test] +fn a_tab_group_closed_with_its_terminals_reopens_as_its_group() { + let _exclusive = exclusive_process_test(); + let (harness, mut app, pane) = start("reopen-group-end"); + for _ in 0..2 { + app.send(serde_json::json!({"cmd":"new-tab","pane":pane})); + } + let tabs = kinds_workspace(&mut app)["screens"][0]["panes"][0]["tabs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|tab| tab["tab_resource_id"].as_str().map(str::to_string)) + .collect::>(); + assert_eq!(tabs.len(), 3, "{tabs:?}"); + resource_request( + &harness.socket, + "group-end-create", + "tab_group.create", + serde_json::json!({"machine":"current","session":"current","tabs":tabs[1..], + "name":"Build","color":"blue"}), + Some("group-end-create"), + ); + let group_id = tab_groups(&harness, "group-end-list-1")[0]["id"] + .as_str() + .expect("the group has an id") + .to_string(); + + let before = closed_list(&harness, "group-end-closed-1").len(); + app.send(serde_json::json!({"cmd":"close-tab-group","group":group_id,"end_terminals":true})); + let closed = closed_list(&harness, "group-end-closed-2"); + assert_eq!(closed.len(), before + 1, "the close was not recorded: {closed:?}"); + let reopened = resource_request( + &harness.socket, + "group-end-reopen", + "closed.reopen", + serde_json::json!({"machine":"current","session":"current","closed":closed[0]["id"]}), + Some("group-end-reopen"), + )["value"] + .clone(); + let back = reopened["tab_ids"].as_array().cloned().unwrap_or_default(); + assert_eq!(back.len(), 2, "both tabs reopen once: {reopened}"); + let groups = tab_groups(&harness, "group-end-list-2"); + assert_eq!(groups.len(), 1, "the reopen formed no group: {groups:?}"); + assert_eq!(groups[0]["id"], group_id.as_str()); + assert_eq!(groups[0]["name"], "Build"); + assert_eq!(groups[0]["tab_ids"], serde_json::Value::Array(back), "{groups:?}"); +} + #[test] fn a_closed_tab_group_that_emptied_its_screen_reopens_as_its_group() { let _exclusive = exclusive_process_test(); @@ -621,6 +670,66 @@ fn a_closed_tab_group_that_emptied_its_screen_reopens_as_its_group() { assert_eq!(groups[0]["tab_ids"], serde_json::Value::Array(back), "{groups:?}"); } +#[test] +fn a_closed_screen_reopens_with_its_tab_group() { + let _exclusive = exclusive_process_test(); + let (harness, mut app, _) = start("reopen-screen-group"); + let workspace = kinds_workspace(&mut app); + let first = workspace["screens"][0]["resource_id"].clone(); + app.send(serde_json::json!({"cmd":"new-screen","workspace":workspace["id"], + "cols":80,"rows":24})); + let screen = kinds_workspace(&mut app)["screens"] + .as_array() + .into_iter() + .flatten() + .find(|screen| screen["resource_id"] != first) + .cloned() + .expect("the new screen is listed"); + let pane = screen["panes"][0]["id"].as_u64().expect("the new screen has a pane"); + for _ in 0..2 { + app.send(serde_json::json!({"cmd":"new-tab","pane":pane})); + } + let tabs = kinds_workspace(&mut app)["screens"] + .as_array() + .into_iter() + .flatten() + .find(|listed| listed["resource_id"] == screen["resource_id"]) + .map(|listed| listed["panes"][0]["tabs"].as_array().cloned().unwrap_or_default()) + .unwrap_or_default() + .iter() + .filter_map(|tab| tab["tab_resource_id"].as_str().map(str::to_string)) + .collect::>(); + assert_eq!(tabs.len(), 3, "{tabs:?}"); + resource_request( + &harness.socket, + "screen-group-create", + "tab_group.create", + serde_json::json!({"machine":"current","session":"current","tabs":tabs[1..], + "name":"Build","color":"blue"}), + Some("screen-group-create"), + ); + let group_id = tab_groups(&harness, "screen-group-list-1")[0]["id"] + .as_str() + .expect("the group has an id") + .to_string(); + + let (item, reopened) = close_records_and_reopens( + &harness, + "screen.close", + serde_json::json!({"screen":screen["resource_id"]}), + "screen-group", + ); + assert_eq!(item["kind"], "screen", "{item}"); + let back = reopened["tab_ids"].as_array().cloned().unwrap_or_default(); + assert_eq!(back.len(), 3, "every tab reopens once: {reopened}"); + let groups = tab_groups(&harness, "screen-group-list-2"); + assert_eq!(groups.len(), 1, "the reopen formed no group: {groups:?}"); + assert_eq!(groups[0]["id"], group_id.as_str()); + assert_eq!(groups[0]["name"], "Build"); + assert_eq!(groups[0]["color"], "blue"); + assert_eq!(groups[0]["tab_ids"], serde_json::json!(back[1..]), "{groups:?}"); +} + fn screen_groups(harness: &RecoveryHarness, request: &str) -> Vec { let reply = resource_request( &harness.socket, diff --git a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs index e7c4889b767a..2fb2fed7c28b 100644 --- a/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs +++ b/cmux-tui/crates/cmux-tui/tests/terminal_host_recovery/shared_endpoint_dir.rs @@ -1,13 +1,16 @@ -//! Every daemon of one uid binds its terminal hosts' sockets in the same +//! Every daemon of one uid binds its terminal hosts' sockets under the same //! `/tmp/cmux-th-` directory, whatever its HOME or state directory -//! (`sun_path` is about 100 bytes on macOS, so the endpoint stays short). +//! (`sun_path` is about 100 bytes on macOS, so the endpoint stays short), in +//! the flat directory today; a record may also name a per-install +//! subdirectory (one per session; each build has its own app session, +//! cx-t2sqm.13), where a later build binds (cx-t2sqm.18). //! Records and liveness proofs stay in each daemon's own state directory, and //! a daemon reaches the shared directory only through the endpoint of a //! record it owns. This test proves that one daemon's recovery and cleanup //! leave another daemon's host and socket untouched. use super::*; -use std::os::unix::fs::FileTypeExt; +use std::os::unix::fs::{FileTypeExt, PermissionsExt}; #[test] fn a_daemon_prunes_only_its_own_hosts_in_the_shared_endpoint_directory() { @@ -78,3 +81,73 @@ fn a_daemon_prunes_only_its_own_hosts_in_the_shared_endpoint_directory() { ) .expect("daemon A's host stopped accepting renderers"); } + +/// cx-t2sqm.13: daemons of two installs (two sessions: each build has its +/// own app session) bind their hosts' sockets in two per-install +/// directories under the shared `/tmp/cmux-th-`, so one build's socket +/// cleanup never reaches another build's sockets; both endpoints stay short. +/// This build accepts the per-install path but binds flat by default (two +/// phases, cx-t2sqm.18); the debug seam makes it bind per-install here, and +/// a restarted daemon adopts a host at that path. +#[test] +fn hosts_of_two_installs_bind_in_two_install_directories() { + let mut a = RecoveryHarness::start_unstarted("install-dir-a"); + a.extra_env.push(("CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT".into(), "1".into())); + a.restart(); + let mut b = RecoveryHarness::start_unstarted("install-dir-b"); + b.session = "host-recovery-other-install".into(); + b.extra_env.push(("CMUX_TUI_TEST_HOST_INSTALL_ENDPOINT".into(), "1".into())); + b.restart(); + assert_ne!(a.session, b.session); + + run_cat_workspace(&a.socket, 1, "install-a"); + run_cat_workspace(&b.socket, 1, "install-b"); + let (_, a_record) = wait_for_host_records(&a.host_root(), 1).remove(0); + let (_, b_record) = wait_for_host_records(&b.host_root(), 1).remove(0); + let a_endpoint = PathBuf::from(&a_record.endpoint); + let b_endpoint = PathBuf::from(&b_record.endpoint); + let a_dir = a_endpoint.parent().unwrap(); + let b_dir = b_endpoint.parent().unwrap(); + assert_ne!(a_dir, b_dir, "two installs share a socket directory"); + assert_eq!(a_dir.parent(), b_dir.parent(), "the install directories are not siblings"); + // SAFETY: getuid has no preconditions. + let shared = PathBuf::from(format!("/tmp/cmux-th-{}", unsafe { libc::getuid() })); + assert_eq!(a_dir.parent(), Some(shared.as_path())); + for endpoint in [&a_endpoint, &b_endpoint] { + assert!(endpoint.as_os_str().len() < 104, "endpoint too long for sun_path: {endpoint:?}"); + assert!( + fs::symlink_metadata(endpoint).is_ok_and(|metadata| metadata.file_type().is_socket()), + "no host socket at {endpoint:?}" + ); + } + let mode = fs::symlink_metadata(a_dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "the install directory is not private"); + + // A daemon that binds flat (the default) adopts the per-install host + // after a restart: the record validates and the host keeps serving. + let (a_terminal, a_host) = (a_record.terminal_id.clone(), a_record.host_pid); + a.extra_env.clear(); + a.sigkill(); + a.restart(); + let deadline = Instant::now() + test_timeout(Duration::from_secs(15)); + let surface = loop { + let resolved = request( + &a.socket, + serde_json::json!({"id": 5, "cmd": "resolve-terminal", "terminal_id": &a_terminal}), + ); + if resolved["lifecycle"] == "running" + && let Some(surface) = resolved["surface"].as_u64() + { + break surface; + } + assert!(Instant::now() < deadline, "the per-install host was not adopted: {resolved}"); + std::thread::sleep(Duration::from_millis(50)); + }; + let marker = format!("install-dir-adopted-{}", std::process::id()); + request( + &a.socket, + serde_json::json!({"id": 6, "cmd": "send", "surface": surface, "text": format!("{marker}\n")}), + ); + assert!(wait_for_screen(&a.socket, surface, &marker).contains(&marker)); + assert_eq!(wait_for_host_records(&a.host_root(), 1)[0].1.host_pid, a_host); +} diff --git a/cmux-tui/crates/ghostty-vt/src/kitty.rs b/cmux-tui/crates/ghostty-vt/src/kitty.rs index 74ca7ea5d770..56fe5ff18d7a 100644 --- a/cmux-tui/crates/ghostty-vt/src/kitty.rs +++ b/cmux-tui/crates/ghostty-vt/src/kitty.rs @@ -150,6 +150,31 @@ impl KittyInFlightTracker { && matches!(&self.scan, KittyStreamScan::Kitty(command) if !command.overflowed) } + pub(crate) fn has_unreplayable_command(&self) -> bool { + // A tracker-level overflow can also mean that a completed multipart + // chunk exceeded the retention budget. CAN cannot undo that command + // after the parser has returned to ground, so only an actively + // overflowed command is recoverable here. + !self.overflowed + && matches!(&self.scan, KittyStreamScan::Kitty(command) if command.overflowed) + } + + pub(crate) fn has_unreplayable_prefix(&self) -> bool { + self.overflowed + } + + pub(crate) fn discard_unreplayable(&mut self) { + self.scan = KittyStreamScan::Ground; + self.overflowed = false; + // CAN aborts only the command that is currently being parsed. Keep a + // completed m=1 prefix so checkpoint replay can still resume the + // native upload when a later m=0 chunk arrives. An overflowed first + // chunk has no prefix to preserve, so leave the tracker idle there. + if self.prefix.is_empty() { + self.loading = false; + } + } + /// The completed upload chunks and the partial command, separately, under /// the same budget as [`Self::replay_prefix_checked`]. pub(crate) fn replay_prefix_and_partial_checked( diff --git a/cmux-tui/crates/ghostty-vt/src/terminal.rs b/cmux-tui/crates/ghostty-vt/src/terminal.rs index 6ecda1beb2d5..edb23646c68e 100644 --- a/cmux-tui/crates/ghostty-vt/src/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/src/terminal.rs @@ -604,6 +604,31 @@ impl Terminal { self.pending_sequence_replay().is_some() } + /// Abandon a pending VT control sequence that exceeded its replay budget. + /// + /// Checkpoint capture can continue from the rendered terminal state when + /// a client leaves an unbounded control string (most often a Kitty upload) + /// open forever. CAN has no screen effect and returns the parser to ground; + /// callers must publish the returned bytes to every attached parser before + /// resuming the live stream. + pub fn discard_unreplayable_replay(&mut self) -> Option> { + // CAN cannot undo completed multipart chunks. Reject recovery even + // when a later control string also exceeds the generic replay budget. + if self.kitty_inflight.has_unreplayable_prefix() { + return None; + } + // The generic VT boundary can still retain a complete replay for an + // APC while the Kitty-specific tracker has already overflowed its + // smaller upload budget. In that case the generic result is not a + // usable replay, so CAN recovery is still required. + if self.vt_replay_resumes_stream() && !self.kitty_inflight.has_unreplayable_command() { + return None; + } + let normalized = self.vt_write_with_normalized(b"\x18").into_owned(); + self.kitty_inflight.discard_unreplayable(); + self.vt_stream_is_ground().then_some(normalized) + } + /// How the replay reproduces the parser's incomplete sequence, if it can. fn pending_sequence_replay(&self) -> Option> { if self.vt_stream_is_ground() { diff --git a/cmux-tui/crates/ghostty-vt/tests/terminal.rs b/cmux-tui/crates/ghostty-vt/tests/terminal.rs index 6ba8c4f685ab..eeaab7cf0d18 100644 --- a/cmux-tui/crates/ghostty-vt/tests/terminal.rs +++ b/cmux-tui/crates/ghostty-vt/tests/terminal.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Mutex}; use ghostty_vt::{ - Callbacks, Cell, ColorSpec, CursorShape, Dirty, RenderState, Rgb, Terminal, + Callbacks, Cell, ColorSpec, CursorShape, Dirty, Error, RenderState, Rgb, Terminal, TerminalColorOverrides, }; @@ -1252,3 +1252,52 @@ fn empty_kitty_graphics_probe_keeps_checkpoint_replay_readable() { term.vt_replay_bounded(1024 * 1024) .expect("an empty Kitty capability probe must not poison replay capture"); } + +#[test] +fn oversized_kitty_upload_is_not_resumable() { + let mut term = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + term.set_kitty_inflight_storage_limit(64); + let mut upload = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + upload.resize(1024, b'A'); + term.vt_write(&upload); + + assert!(term.discard_unreplayable_replay().is_some()); + assert!(term.vt_replay_resumes_stream()); +} + +#[test] +fn completed_oversized_kitty_chunk_is_not_can_recoverable() { + let mut term = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + term.set_kitty_inflight_storage_limit(64); + let mut chunk = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + chunk.resize(1024, b'A'); + chunk.extend_from_slice(b"\x1b\\"); + term.vt_write(&chunk); + + assert!(term.vt_replay_resumes_stream()); + assert!(term.discard_unreplayable_replay().is_none()); +} + +#[test] +fn completed_kitty_overflow_blocks_later_control_string_recovery() { + for size in [1024, 1_048_577] { + for header in [b"\x1b_Gm=0;".as_slice(), b"\x1b]2;"] { + let mut term = Terminal::new(20, 4, 100, Callbacks::default()).unwrap(); + term.set_kitty_inflight_storage_limit(64); + let mut first = b"\x1b_Ga=t,t=d,f=24,i=197,s=1,v=1,m=1,q=2;".to_vec(); + first.resize(1024, b'A'); + first.extend_from_slice(b"\x1b\\"); + term.vt_write(&first); + + let mut active = header.to_vec(); + active.resize(size, b'B'); + term.vt_write(&active); + + assert!( + term.discard_unreplayable_replay().is_none(), + "CAN cannot recover a lost completed prefix: header={header:?}, size={size}" + ); + assert!(matches!(term.vt_replay_bounded(2 * 1024 * 1024), Err(Error::OutOfSpace))); + } + } +} diff --git a/cmux-tui/scripts/check-spec-inventory.py b/cmux-tui/scripts/check-spec-inventory.py index e8221a2a9b8f..ef79c98e210f 100644 --- a/cmux-tui/scripts/check-spec-inventory.py +++ b/cmux-tui/scripts/check-spec-inventory.py @@ -253,7 +253,8 @@ def guarded_command_profiles(source: str) -> dict[str, str]: def command_profiles() -> dict[str, set[str]]: - source = (TUI / "crates/cmux-tui-core/src/server.rs").read_text() + # The Command router lives in server/command_dispatch.rs (cx-6so.3). + source = (TUI / "crates/cmux-tui-core/src/server/command_dispatch.rs").read_text() runtime_commands = command_names() sdk_commands = load_sdk_catalog().get("commands") if not isinstance(sdk_commands, dict): diff --git a/cmux-tui/scripts/smoke-tui.py b/cmux-tui/scripts/smoke-tui.py index 7ea552cf5fbd..93e9dd591bee 100644 --- a/cmux-tui/scripts/smoke-tui.py +++ b/cmux-tui/scripts/smoke-tui.py @@ -943,14 +943,60 @@ def assert_snapshot_parser_controls(): assert workspaces[1]["name"] == "workspace-2", workspaces print("prefix-W new workspace ok") -# Drag the original workspace below the new one. Layout: row 0 header, -# row 1 blank, rows 2-3 workspace 1, row 4 blank, rows 5-6 workspace 2 -# (SGR mouse coordinates are 1-based). +# The rail draws the daemon's sidebar document (sidebar-doc-v1), not the +# list-workspaces order: a new workspace goes where workspaces.newPlacement +# says (default: top), and a drop is one sidebar.move, which changes the +# document only. Layout: row 0 top pad, then per workspace two rows and a +# gap (rows 1-2, 4-5, ...; SGR mouse coordinates are 1-based). +def p2(operation, params=None): + reply = rpc({"protocol": "cmux.protocol/2", "type": "request", "id": "smoke-doc", + "operation": operation, + "params": {"machine": "current", "session": "current", **(params or {})}}) + assert reply.get("ok"), reply + return reply["result"] + +def doc_workspace_order(): + """Workspace ids in the document's display order (no groups here).""" + doc = p2("sidebar.get") + region = {"top": 0, "middle": 1, "bottom": 2} + sections = {s["id"]: (region.get(s.get("region"), 3), s.get("order", "")) for s in doc["sections"]} + items = [i for i in doc["items"] if i["body"].get("type") == "workspace"] + items.sort(key=lambda i: (sections.get(i["section"], (9, "")), i.get("order", ""), i["id"])) + by_resource = {w["resource_id"]: w["id"] for w in tree()} + return [by_resource.get(i["body"]["ref"]["workspace_id"]) for i in items] + +def wait_drawn_order(expected, seconds=15): + """The document and the drawn rail both show `expected` (workspace ids).""" + names = {w["id"]: w["name"] for w in tree()} + deadline = time.monotonic() + seconds + last = None + while time.monotonic() < deadline: + drain(0.2) + rows = render_text_snapshot(output).splitlines() + drawn = [rows[1 + 3 * index][:21].lstrip(" ▎").startswith(names[workspace]) + for index, workspace in enumerate(expected)] + last = (doc_workspace_order(), drawn) + if last[0] == expected and all(drawn): + return + raise AssertionError((expected, last, names)) + +def drag_first_row_below_second(): + write_all(fd, b"\x1b[<0;2;3M\x1b[<32;2;7M\x1b[<0;2;7m") + drain(1.0) + original_ws = ws_id -write_all(fd, b"\x1b[<0;2;3M\x1b[<32;2;7M\x1b[<0;2;7m") -drain(1.0) +new_ws = workspaces[1]["id"] +first, second = doc_workspace_order() +assert {first, second} == {original_ws, new_ws}, (first, second, workspaces) +wait_drawn_order([first, second]) +# Drag the top row below the second one, then the other way back, so the +# original workspace ends below the new one whatever the placement setting. +drag_first_row_below_second() +wait_drawn_order([second, first]) +if doc_workspace_order()[0] == original_ws: + drag_first_row_below_second() +wait_drawn_order([new_ws, original_ws]) workspaces = tree() -assert [w["id"] for w in workspaces] == [w["id"] for w in workspaces if w["id"] != original_ws] + [original_ws], workspaces print("sidebar workspace drag reorder ok") # Click the moved original workspace's sidebar entry. @@ -958,7 +1004,7 @@ def assert_snapshot_parser_controls(): write_all(fd, b"\x1b[<0;2;6M\x1b[<0;2;6m") drain(1.0) workspaces = tree() -assert workspaces[1]["id"] == original_ws, workspaces +assert doc_workspace_order()[1] == original_ws, workspaces assert next(workspace["id"] for workspace in workspaces if workspace["active"]) == shared_workspace original_workspace = next(workspace for workspace in workspaces if workspace["id"] == original_ws) original_surfaces = { diff --git a/cmux-tui/scripts/test_check_spec_inventory.py b/cmux-tui/scripts/test_check_spec_inventory.py index 2f49d7322080..327703a6ac3f 100644 --- a/cmux-tui/scripts/test_check_spec_inventory.py +++ b/cmux-tui/scripts/test_check_spec_inventory.py @@ -166,7 +166,12 @@ def test_command_profiles_combine_sdk_metadata_and_rust_guards(self) -> None: Ping, ShutdownDaemon { pid: u32 }, } - +""" + ) + # The router lives in server/command_dispatch.rs. + (server.parent / "server").mkdir() + (server.parent / "server/command_dispatch.rs").write_text( + """\ fn handle_command_with_cancellation(cmd: Command) { match cmd { Command::Ping => {} @@ -212,7 +217,12 @@ def test_command_profile_guard_drift_is_rejected(self) -> None: enum Command { ShutdownDaemon { pid: u32 }, } - +""" + ) + # The router lives in server/command_dispatch.rs. + (server.parent / "server").mkdir() + (server.parent / "server/command_dispatch.rs").write_text( + """\ fn handle_command_with_cancellation(cmd: Command) { match cmd { Command::ShutdownDaemon { .. } => { diff --git a/cmux-tui/spec/cli.md b/cmux-tui/spec/cli.md index 9e4535bf73e5..f7cc1b0a1865 100644 --- a/cmux-tui/spec/cli.md +++ b/cmux-tui/spec/cli.md @@ -715,7 +715,11 @@ person's Chiefs with the Chief registry (`chief.profile.list`, `chief.profile.cr Chief's display name; `` is `--name`, `--role`, `--personality`, `--instructions` or `--instructions-file`, `--avatar `, `--tools`, `--harnesses`, `--self-edit` (comma lists) and `--harness`, -`--model`, `--effort`. `--json` prints the record. It refuses a socket under +`--model`, `--effort`. `chief adopt []` (`chief.profile.adopt`) +makes an agent already in the person's conversations a Chief under its own +id; its conversations and history stay. Every write (new, adopt, edit, +delete) is refused for an agent caller (`origin.forbidden`, reason +`agent_caller`). `--json` prints the record. It refuses a socket under `~/.cmux/brains/` (a Chief brain's own session, where a client acts as the Chief) and `--machine`. Exit codes: 0, 1 refused, 2 usage, 3 transport, 124 timeout. diff --git a/cmux-tui/spec/commands.md b/cmux-tui/spec/commands.md index 6d2c60700221..6582c269e531 100644 --- a/cmux-tui/spec/commands.md +++ b/cmux-tui/spec/commands.md @@ -351,7 +351,7 @@ With `daemon-build-v1`, `build_id` names the exact build of the binary that serv `capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-provider-native-hosting-v1` advertises the optional `hosting` field of `register-browser-provider`: a `"native"` provider presents its pages itself, so the daemon never overrides their device metrics and screencasts them only while a frame viewer is attached. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `pane-browser-kind-v1` advertises `kind` and `url` on `split` and `new-pane-right`: `kind:"browser"` with a non-empty `url` puts a browser tab at that URL in the new pane, through the same `pane.split` commit and restart recovery as a terminal pane; public `pane.split` stays terminal-only. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. `terminal-idle-close-v1` advertises `set-terminal-idle-policy` and the owner-side reaper that closes a terminal after its policy elapses with no attached view. `terminal-pending-sequence-v1` advertises the separate `pending` field on byte-attach `vt-state` and `resized` events; a client that echoes it in `set-client-info` receives it (see `events.md`). `terminal-placement-env-v1` advertises a caller-chosen `terminal_id` on `new-tab`, `split`, `new-pane`, and `new-pane-right`, `cwd` and `env` on `new-pane` and `new-pane-right`, and `terminal_id`/`terminal_incarnation` in all four results. `split-client-keys-v1` advertises the client-minted `pane_id` and `tab_id` on `split`, `new-pane`, and `new-pane-right`, keyed retries that return the first result with `replayed`, and `pane_id`/`tab_id` in their results (see "Client-minted pane ids"). `terminal-resources-v1` advertises `terminal-resources`, which reads the CPU time and memory of each terminal's shell, descendants, and terminal host at request time. `batch-close-v1` advertises `close-tabs` and the optional `end_terminals` field on `close-pane`, `close-screen`, `close-workspace`, and `close-tab-group`: many placements and the terminals they end close in one durable commit. `close-workspace-if-empty-v1` advertises the optional `if_empty` field on `close-workspace`: the close commits only when the workspace has no tab at commit time, else it fails with `workspace_not_empty` and changes nothing. `end-terminals-keep-layout-v1` advertises `keep_layout` on `shutdown-daemon`: with `end_terminals`, placed terminals keep their tabs across the handoff. `terminal-reap-v1` advertises the owner-side reaper that ends a terminal after it has had no tab placement for the reap grace period (active only when the daemon was started with `--terminal-reap-grace-seconds`), `set-terminal-keep`, the `keep` field on `new-tab`, `split`, and `create-terminal`, the `terminal-reaped` event, and `end_terminals` on `shutdown-daemon`. `dock-columns-v1` advertises `set-column-dock` and the optional `Screen.columns[].dock` field for every edge; the resource API operation `column.update` (resource-operations-v2.json) sets the same flag and the column width. `terminal-env-v1` advertises the per-terminal `env` object on `new-tab`, `split`, and `create-terminal`, and `cwd` on `split`. `tab-groups-v1` advertises Chrome-style tab groups: the `*-tab-group` commands, `Pane.tab_groups`, and `Tab.group`. `saved-tab-groups-v1` advertises saved groups: `save-tab-group`, `unsave-tab-group`, `delete-saved-tab-group`, `list-saved-tab-groups`, and `reopen-saved-tab-group`. `notification-ack-v1` advertises `ack-tab-notifications`, `list-notifications`, durable notification acknowledgement, and `Workspace.unread_count`. `tab-drag-v1` advertises the single-command tab drag outcomes `move-tab-to-split`, `move-tab-to-column`, and `move-tab-to-new-workspace`, layout undo for same-screen tab drags and cross-pane `move-tab`, and the optional `transaction` field on every drag command, echoed in the resulting `tab-changed` delta. `tab-workspace-name-v1` advertises the optional `name` field on `move-tab-to-new-workspace`: the new workspace takes that name in the same commit. `frontend-browser-tabs-v1` advertises `new-frontend-browser-tab`, `update-frontend-browser-tab`, and the frontend browser tab fields. `tab-metadata-v1` advertises `set-tab-pinned`, pinned-first tab order, the `Tab.pinned`, `Tab.cwd`, `Tab.git_branch`, and `Tab.git_detached` fields, and the `tab-changed` delta. `workspace-metadata-v1` advertises `set-workspace-metadata`, the `Workspace.color`, `Workspace.icon`, and `Workspace.title` fields, and the `workspace-changed` delta. `workspace-pin-v1` advertises the `pinned` field on `set-workspace-metadata` and `Workspace.pinned`. `notification-mark-unread-v1` advertises the `marked_unread` field on `set-workspace-metadata` and `Workspace.marked_unread`. `workspace-groups-v1` advertises durable sidebar groups: the `*-workspace-group` commands, `move-workspace-to-group`, `Tree.groups`, and `Workspace.group`. `loopback-forward-v1` advertises multiplexed TCP streams to the daemon machine's own loopback services (see "Loopback forwarding"); a Unix client echoes it in `set-client-info` before its first `loopback-open`. `fs-v1` advertises the `fs.*` file ops and their byte streams (see "File system (fs-v1)"); only cmux Cloud hosts serve it. `session-identity-v1` advertises `identify.session_id` (the durable `registry_id`, stable across restarts and upgrades) and `identify.machine_name` (the host name, at most 255 bytes, no control characters). `remote-terminal-tabs-v1` advertises `new-remote-terminal-tab`, `update-remote-terminal-tab`, `remote-terminal-snapshot`, and the `kind:"remote-terminal"` tab with its `remote` object. `detached-terminals-v1` advertises `detached` on `create-terminal` and `terminal_resource_id` in its detached result. `profiles-v1` advertises the home session's personal state: `list-personal`, the `*-profile` room commands, `set-profile-follows`, `pin-workspace`, `unpin-workspace`, `put-session`, `forget-session`, `import-session-organization`, the `*-personal-group` commands, `set-personal-workspace`, and the `personal-changed` event. `personal-terminals-v1` advertises `set-personal-terminal` and `list-personal.terminals`. `screen-metadata-v1` advertises `set-screen-metadata`, `set-screen-pinned`, `move-screen`, the `screen_name`, `color`, `icon`, `pinned`, `index`, `group`, and `cwd` fields on `new-screen` (whose result then also carries `screen`), the `Screen.color`, `Screen.icon`, `Screen.pinned`, and `Screen.group` fields, and the `screen-changed` delta. `screen-groups-v1` advertises Chrome-style screen groups: the `*-screen-group` commands, saved screen groups, and `Workspace.screen_groups`. `browser-profiles-v1` advertises browser profile records in personal state: `browser_profiles` in `list-personal` and `create-browser-profile`, `update-browser-profile`, `move-browser-profile`, and `delete-browser-profile`. `notification-source-v1` advertises `source` on `notify`, the `notification` event, the tab `notification` marker, and `list-notifications` rows (`extra.source` in resource notification snapshots), and daemon-side desktop notifications from terminal output (OSC 9, OSC 777 `notify`, kitty OSC 99) with source `terminal`. `terminal-shell-args-v1` advertises `shell_args` on `new-tab`, `split`, `new-pane`, `new-pane-right`, and `create-terminal`: the terminal runs its `SHELL` from `env` (else the daemon's default shell) with those arguments, so a frontend can apply Ghostty's argv-based shell integration (bash `--posix` with `ENV`, nushell `--execute`). `terminal-frontend-shell-integration-v1` lets a frontend that resolves Ghostty's shell integration itself (from the user's `shell-integration` and `shell-integration-features`, into the terminal's `env` and `shell_args`) echo it in `set-client-info`; the terminals that connection creates with a `SHELL` in `env` then run that shell exactly as given (stored as `argv`), with no host-added integration, on `new-tab`, `split`, `new-pane`, `new-pane-right`, `new-row`, `create-terminal`, and a terminal `respawn`; without a `SHELL` the host integrates as before. `new-workspace`, `new-screen` and the reopen-saved-group commands take no `env` and keep the host integration. `screen-terminal-env-v1` advertises `env`, `terminal_id`, and `shell_args` on `new-screen` (the first terminal takes them as on `new-pane`, including `terminal-frontend-shell-integration-v1`) and `terminal_id` in its result. `launch-snapshot-v1` advertises `launch_snapshot_path` in `identify` and the launch snapshot file described there. `state-resources-v1` advertises the `cmux.protocol/2` state resources (workspace metadata, tab pins and tab groups, personal workspace groups, rooms and saved tab groups, screen metadata, order and screen groups, closed history, ephemeral workspaces, and workspace status, progress and log; see `resource-api-v2.md`), `extra.state` on session snapshots, and `state_upsert`/`state_delete` changes on `session.events`. `window-records-v1` advertises `window_record.list`, `window_record.put` and `window_record.delete` (see `resource-api-v2.md`). `sidebar-layout-v1` advertises `sidebar_layout.get`, `sidebar_layout.update` and `extra.state.sidebar_layout` on session snapshots (see `resource-api-v2.md`). `sidebar-doc-v1` advertises the sidebar document: `sidebar.get`, `sidebar.move`, `sidebar.pin`, `sidebar.unpin`, the `sidebar.item.*`, `sidebar.section.*` and `sidebar.group.*` mutations, `sidebar.adopt`, `sidebar.apply`, `extra.state.sidebar` on session snapshots, and `state_upsert`/`state_delete` changes of resources `sidebar_item`, `sidebar_section` and `sidebar_doc` on `session.events` (see `resource-api-v2.md`). `placement-shift-v1` advertises the placement shift: a client that echoes it in `set-client-info` or `client.metadata.update` gets no `workspace_placement` restatement on `session.events` for rows that a new or moved workspace only shifts; every other connection still gets them (see "Placement shift" in `resource-api-v2.md`). `project-list-v1` advertises the project list: `project.list`, `project.observe`, `project.add`, `project.update`, `project.remove` and `project.sync`, and `state_upsert`/`state_delete` changes of resource `project` on `session.events` (see `resource-api-v2.md` and plans/cmux-next/projects.md). `project-sources-v1` advertises `project.source.update` (a source turned off or on) and `sources` (each source with its on state and project count) in the `project.list` result. `browser-devices-v1` advertises `browser.devices.list`, `browser.devices.add_custom`, `browser.devices.remove_custom`, `browser.tab.emulate` and `browser.tab.emulate.clear`, and a browser tab's `extra.emulation` (the resolved device emulation every engine applies; see `resource-api-v2.md`). `browser-profile-agent-v1` advertises `browser.tab.set_profile` (moves a frontend browser tab to another browser profile in place, as `update-frontend-browser-tab {profile_id}` does; `frontend-browser-tab-profile-v1`) and a frontend browser tab's `extra.browser_profile_id` (see `resource-api-v2.md`). `palette-usage-v1` advertises `palette_usage.get`, `palette_usage.record`, `palette_usage.import`, `palette_usage.hide` and `palette_usage.forget`, the user's local palette usage history (see `resource-api-v2.md`). `frontend-browser-owner-v1` advertises `owner` on `new-frontend-browser-tab` and `update-frontend-browser-tab`, `Tab.browser_owner`, `tab.update {owner}` and the tab's `extra.owner`. `frontend-browser-tab-keys-v1` advertises `idempotency_key` on `new-frontend-browser-tab` and `replayed` in its result: a retry with the same key returns the tab the first request created. The raw screen commands of `screen-metadata-v1` and `screen-groups-v1` and the v2 `screen.*` and `screen_group.*` operations read and write one storage and publish the same changes. `workspace-kind-v1` advertises `workspace.ensure_home` and `extra.kind` on workspace snapshots (see `resource-api-v2.md`). `workspace-agent-folder-v1` advertises `workspace.agent_folder.set` and `extra.agent_folder` on workspace snapshots (see `resource-api-v2.md`). `personal-mixed-order-v1` advertises one personal order for groups and loose workspaces: `top_index` on `workspace_group.update`, on `WorkspaceGroupSnapshot` and on `list-personal` groups, `workspace.list {order}`, and the personal row of a new workspace in its creation commit (see `resource-api-v2.md`). `workspace-group-icon-v1` advertises a personal workspace group's icon: `icon` on `workspace_group.update`, on `WorkspaceGroupSnapshot` and on `list-personal` groups (see `resource-api-v2.md`). `screen-group-icon-v1` advertises a screen group's icon: `icon` on `update-screen-group`, on screen group runs (`Workspace.screen_groups`), on saved screen groups (`list-saved-screen-groups`, kept by `reopen-saved-screen-group`), on `screen_group.update` and on `ScreenGroupSnapshot` (see `resource-api-v2.md`). `tab-group-icon-v1` advertises a tab group's icon: `icon` on `update-tab-group`, on tab group runs (`Pane.tab_groups`), on saved tab groups (`list-saved-tab-groups`, kept by `reopen-saved-tab-group`), on `tab_group.update`, on `TabGroupSnapshot` and on `SavedTabGroupSnapshot` (see `resource-api-v2.md`). `workspace-group-pin-v1` advertises pinned (saved) personal workspace groups: `pinned` on `workspace_group.update`, on `WorkspaceGroupSnapshot` and on `list-personal` groups (see `resource-api-v2.md`). `chief-inspect-v1` advertises `chief-inspect`, the Chief memory inspector's read-only API for the owner's trusted connection. `conversation-tabs-v1` advertises `new-conversation-tab`, the canonical `conversation` tab kind, and `capabilities` on `client.metadata.update`; a client that echoes it in `set-client-info` or `client.metadata.update` reads conversation tabs in their canonical form. `agent-session-tabs-v1` advertises the `agent_session` source of `new-conversation-tab` (an acpmux agent session on the install `host`; its tabs are ordinary store tabs that move, split and close like any tab) and `bind-conversation-tab-session`; a client that echoes it as well reads agent session tabs in their canonical form, and any other connection reads them as `browser` tabs without `conversation`. `page-tabs-v1` advertises the `page` source of `new-conversation-tab` (one of the app's own pages, such as the App Store or Settings; its tabs are ordinary store tabs); a client that echoes it as well reads page tabs in their canonical form, and any other connection reads them as `browser` tabs without `conversation`. `page-tab-route-v1` advertises the optional `route` of a page tab on `new-conversation-tab` and in its `conversation` record. `close-reason-v1` advertises `reason` on `close-tabs` and, with `end_terminals`, on `close-workspace`: `session_end` closes without a closed-history record. `conversation-tab-transaction-v1` advertises `transaction` on `new-conversation-tab`: the created tab's `tab-added` delta and the result echo it. `browser-host-provider-v1` advertises `browser-host-provider`: the verified local app gets the provider credentials of the daemon's own browser host. `frontend-browser-activate-v1` advertises `activate` on `new-frontend-browser-tab`. `frontend-browser-insert-after-v1` advertises `after` on `new-frontend-browser-tab`. `bookmarks-v1` advertises one bookmark tree per browser profile: `list-bookmarks`, `create-bookmark`, `update-bookmark`, `move-bookmark`, `delete-bookmark`, `import-bookmarks`, the `bookmarks-changed` event, and `deleted_bookmarks` in the `delete-browser-profile` result. `bookmark-icon-v1` advertises a bookmark folder's `icon`: set or cleared by `update-bookmark` and shown in `list-bookmarks` (see "Icon values" under `put-blob`). `session-icon-v1` advertises a session's (machine's) `icon` in the home session registry: set or cleared by `set-session-icon`, kept by `put-session`, and shown on each `list-personal` session record. `icon-assets-v1` advertises the asset blob store (`put-blob`, `get-blob`) and the asset icon values `image:sha256-` and `svg:sha256-` on the workspace, screen, room, browser profile and workspace status icon fields (see "Icon values" under `put-blob`). `origin-claim-v1` advertises `client-hello` step 1, the optional `origin` claim on `cmux.protocol/2` requests, and `origin.confirmation.issue` (see "client-hello" and `resource-api-v2.md`, "Request origin") `page-router-v1` advertises the daemon page router (`page-router.md`): the optional `page` on a page relay's `client-hello` (the reply carries that page's `page_operations`), the page of a WebSocket connection whose handshake Origin is `cmux-page://cmux.`, the per-page allow list of `cmux.protocol/2` operations in `page-router.json`, and the routed v2 namespaces (`apps.catalog.list`, `apps.catalog.get`, `apps.installed.list`, `apps.grants.get`, `apps.logs`, `apps.asset.get`, `apps.set`, `apps.install`, `apps.uninstall`, `apps.hidden.set`, `apps.grant.set`, `apps.grant.revoke`) answered by their owner outside the operation catalog. `ws-listen-origins-v1` advertises `allow_origins` on `ws-listen` and `origins` in its reply. `page-events-v1` advertises `page_events` in a page relay's `client-hello` reply (the `events` of its page in `page-router.json`) and `subscribe` on a page relay whose page names events: the stream carries only those events (no `surface` output stream, no pairing requests). `terminal-clipboard-read-v1` advertises the OSC 52 clipboard-read broker: `terminal-clipboard-subscribe`, `terminal-clipboard-reply`, and the targeted `terminal-clipboard-read` and `terminal-clipboard-read-cancelled` control events (see "Terminal clipboard reads"). `launch-credential-v1` advertises the optional `credential` member of `cmux.protocol/2` requests and `credential.verify`, `credential.mint` and `credential.rotate` (see `resource-api-v2.md`, "Launch credentials"); a client sends a credential only to a daemon that advertises it. `feed-local-owner-v1` advertises the daemon's local feed owner (plans/cmux-next/feed.md section 9.1): every notification is also a local feed item committed in the same transaction, `select-tab`, `select-workspace`, `select-screen`, `focus-pane` and `focus-direction` no longer clear unread (a client acknowledges with `ack-tab-notifications`, which also reads the tab's local items and reports `refused` items), the `feed-local-list`, `feed-local-read`, `feed-local-handoff-begin`, `feed-local-handoff-abort` and `feed-local-handoff-done` commands, and the error codes `feed.moving`, `owner.unreachable` (both retryable), `feed.invalid_state`, `not_found` and `forbidden` (a handoff step from a connection that is not the verified cmux app). `workspace-open-url-v1` advertises `workspace.open_url`: a web link opens on the right side of a workspace (the home workspace when none is named), reusing its browser tab, never in the agent chat dock and without moving the keyboard focus (see `resource-api-v2.md`). . -`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-provider-native-hosting-v1` advertises the optional `hosting` field of `register-browser-provider`: a `"native"` provider presents its pages itself, so the daemon never overrides their device metrics and screencasts them only while a frame viewer is attached. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `pane-browser-kind-v1` advertises `kind` and `url` on `split` and `new-pane-right`: `kind:"browser"` with a non-empty `url` puts a browser tab at that URL in the new pane, through the same `pane.split` commit and restart recovery as a terminal pane; public `pane.split` stays terminal-only. `dock-columns-v1` advertises `set-column-dock` and the optional `Screen.columns[].dock` field for every edge. `edge-docks-v1` advertises the edges `top` and `bottom` on `set-column-dock` and on `dock` in `move-tab-to-column` (the `dock` field itself needs `dock-columns-v1`). `dock-column-role-v1` advertises `role` on `set-column-dock` and on `dock` in `move-tab-to-column`, and `Screen.columns[].dock.role`. `rows-v1` advertises `new-row`, `set-row-heights`, and the optional `Screen.columns[].rows` field. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. `terminal-idle-close-v1` advertises `set-terminal-idle-policy` and the owner-side reaper that closes a terminal after its policy elapses with no attached view. `terminal-pending-sequence-v1` advertises the separate `pending` field on byte-attach `vt-state` and `resized` events; a client that echoes it in `set-client-info` receives it (see `events.md`). `terminal-placement-env-v1` advertises a caller-chosen `terminal_id` on `new-tab`, `split`, `new-pane`, and `new-pane-right`, `cwd` and `env` on `new-pane` and `new-pane-right`, and `terminal_id`/`terminal_incarnation` in all four results. `split-client-keys-v1` advertises the client-minted `pane_id` and `tab_id` on `split`, `new-pane`, and `new-pane-right`, keyed retries that return the first result with `replayed`, and `pane_id`/`tab_id` in their results (see "Client-minted pane ids"). `terminal-resources-v1` advertises `terminal-resources`, which reads the CPU time and memory of each terminal's shell, descendants, and terminal host at request time. `batch-close-v1` advertises `close-tabs` and the optional `end_terminals` field on `close-pane`, `close-screen`, `close-workspace`, and `close-tab-group`: many placements and the terminals they end close in one durable commit. `close-workspace-if-empty-v1` advertises the optional `if_empty` field on `close-workspace`: the close commits only when the workspace has no tab at commit time, else it fails with `workspace_not_empty` and changes nothing. `end-terminals-keep-layout-v1` advertises `keep_layout` on `shutdown-daemon`: with `end_terminals`, placed terminals keep their tabs across the handoff. `terminal-reap-v1` advertises the owner-side reaper that ends a terminal after it has had no tab placement for the reap grace period (active only when the daemon was started with `--terminal-reap-grace-seconds`), `set-terminal-keep`, the `keep` field on `new-tab`, `split`, and `create-terminal`, the `terminal-reaped` event, and `end_terminals` on `shutdown-daemon`. `terminal-env-v1` advertises the per-terminal `env` object on `new-tab`, `split`, and `create-terminal`, and `cwd` on `split`. `tab-groups-v1` advertises tab groups: the `*-tab-group` commands, `Pane.tab_groups`, and `Tab.group`. `saved-tab-groups-v1` advertises saved groups: `save-tab-group`, `unsave-tab-group`, `delete-saved-tab-group`, `list-saved-tab-groups`, and `reopen-saved-tab-group`. `notification-ack-v1` advertises `ack-tab-notifications`, `list-notifications`, durable notification acknowledgement, and `Workspace.unread_count`. `tab-drag-v1` advertises the single-command tab drag outcomes `move-tab-to-split`, `move-tab-to-column`, and `move-tab-to-new-workspace`, layout undo for same-screen tab drags and cross-pane `move-tab`, and the optional `transaction` field on every drag command, echoed in the resulting `tab-changed` delta. `tab-workspace-name-v1` advertises the optional `name` field on `move-tab-to-new-workspace`: the new workspace takes that name in the same commit. `tab-split-respawn-v1` advertises `respawn` on `move-tab-to-split`: a pane's only tab dropped on its own pane's edge splits that pane and leaves a fresh tab of the same kind behind. `tab-split-keep-focus-v1` advertises `keep_focus` on `move-tab-to-split` and `move-tab-to-column`: the new pane does not take focus. `tab-column-respawn-v1` advertises `respawn` on `move-tab-to-column`: a pane's only tab moves into the new column and leaves a fresh tab of the same kind in its pane. `frontend-browser-tabs-v1` advertises `new-frontend-browser-tab`, `update-frontend-browser-tab`, and the frontend browser tab fields. `frontend-browser-history-v1` advertises `set-frontend-browser-history` and `get-frontend-browser-history`: an opaque per-tab session history object for frontend-rendered browsers, stored durably outside the tree. `tab-metadata-v1` advertises `set-tab-pinned`, pinned-first tab order, the `Tab.pinned`, `Tab.cwd`, `Tab.git_branch`, and `Tab.git_detached` fields, and the `tab-changed` delta. `workspace-metadata-v1` advertises `set-workspace-metadata`, the `Workspace.color`, `Workspace.icon`, and `Workspace.title` fields, and the `workspace-changed` delta. `workspace-pin-v1` advertises the `pinned` field on `set-workspace-metadata` and `Workspace.pinned`. `notification-mark-unread-v1` advertises the `marked_unread` field on `set-workspace-metadata` and `Workspace.marked_unread`. `workspace-groups-v1` advertises durable sidebar groups: the `*-workspace-group` commands, `move-workspace-to-group`, `Tree.groups`, and `Workspace.group`. `loopback-forward-v1` advertises multiplexed TCP streams to the daemon machine's own loopback services (see "Loopback forwarding"); a Unix client echoes it in `set-client-info` before its first `loopback-open`. `fs-v1` advertises the `fs.*` file ops and their byte streams (see "File system (fs-v1)"); only cmux Cloud hosts serve it. `session-identity-v1` advertises `identify.session_id` (the durable `registry_id`, stable across restarts and upgrades) and `identify.machine_name` (the host name, at most 255 bytes, no control characters). `profiles-v1` advertises the home session's personal state: `list-personal`, the `*-profile` room commands, `set-profile-follows`, `pin-workspace`, `unpin-workspace`, `put-session`, `forget-session`, `import-session-organization`, the `*-personal-group` commands, `set-personal-workspace`, and the `personal-changed` event. `personal-terminals-v1` advertises `set-personal-terminal` and `list-personal.terminals`. `screen-metadata-v1` advertises `set-screen-metadata`, `set-screen-pinned`, `move-screen`, the `screen_name`, `color`, `icon`, `pinned`, `index`, `group`, and `cwd` fields on `new-screen` (whose result then also carries `screen`), the `Screen.color`, `Screen.icon`, `Screen.pinned`, and `Screen.group` fields, and the `screen-changed` delta. `screen-groups-v1` advertises screen groups: the `*-screen-group` commands, saved screen groups, and `Workspace.screen_groups`. `browser-profiles-v1` advertises browser profile records in personal state: `browser_profiles` in `list-personal` and `create-browser-profile`, `update-browser-profile`, `move-browser-profile`, and `delete-browser-profile`. `conversation-search-v1` advertises `conversation-search` on the local conversation owner. `conversation-draft-v1` advertises `conversation-draft`, the v1 command of the `conversation.draft` operation for a bound agent connection. `conversation-draft-events-v1` advertises the `conversation-draft` event on `subscribe` (the `conversation.events` draft item, with its `conversation` and `participant`) for a trusted local connection that echoes the capability in `set-client-info`; other connections never get drafts on `subscribe`. `conversation-threads-v1` advertises `thread_root` on `message.send` and on `Message`, the `invalid_thread_root` reject, and that a `message.send` moves the sender's own read cursor to the new seq when the sender had read everything before it (see `conversation-op`). `conversation-thread-v1` advertises `conversation-thread`, the `threads` root stats on `conversation-snapshot` and `conversation-history` results, and, with `cloud-conversations-v1`, `cloud-conversation-thread`. `conversation-tree-v1` advertises threads that nest without a limit on the local conversation owner (`reply_to` is the parent, `thread_root` the top root; a `message.send` whose `reply_to` parent is in a thread joins that thread, and a named `thread_root` must be the parent's own; see `conversation-op`) and `conversation-link-resolve`, which opens a conversation or message permalink. `conversation-inbox-state-v1` advertises the person's own list state of a conversation, kept beside it (a pin with its position and a "marked unread" flag): `conversation-inbox-op`, the caller's `pinned`, `pin_position` and `marked_unread` on `conversation-list` items, the `conversation-inbox-changed` event on `subscribe` (only to connections whose principal is that participant), and with `cloud-conversations-v1` also `cloud-inbox-op`. `conversation-thread-objects-v1` advertises threads as objects: the `thread.update` op (a `title`, a `status` of `open` or `resolved`, and an `owner` participant; any participant may send it, an agent too) on `conversation-op` and through `cloud-conversation-op`, the `thread` object on a root `Message`, and the `status`, `title`, `owner`, `resolved_by` and `resolved_at` fields of `ThreadSummary` (see `conversation-op` and `conversation-thread`). `conversation-runs-v1` advertises agent runs on the local conversation owner: `conversation-run-append` for a bound agent participant, `conversation-runs` and `conversation-run` on trusted local connections (Unix, and authenticated WebSocket connections with `ws-conversations-v1`), and the `conversation-run-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info` (see "conversation-run-append, conversation-runs, conversation-run"). `cloud-conversation-runs-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-conversation-runs`, `cloud-conversation-run` and the `cloud-conversation-run-changed` event (see "cloud-conversation-runs, cloud-conversation-run"). `local-attachments-v1` advertises `attachment` message parts on the local conversation owner and the `conversation-attachment-upload` and `conversation-attachment-read` commands on trusted local connections. `cloud-attachments-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-attachment-upload`, `cloud-attachment-read` and `cloud-attachment-link`: the same steps and results for a cloud conversation's attachments, through the daemon with the person's lease. `ws-attachments-v1` advertises the same two commands and `conversation-attachment-link` on an authenticated WebSocket connection (the clients of `ws-conversations-v1`; never a relayed remote device), and the listener's `GET /attachments/` download links with byte ranges (transports.md). `link-preview-v1` advertises `link_preview` message parts on the local conversation owner (see `conversation-attachment-upload`). `conversation-person-credentials-v1` advertises that `conversation-agent-token` and `conversation-bind` also take a person other than the server's own user (a `user_` id that is not `user_local`; never a `remote_` device): the bound Unix connection then acts as that person (sends, thread replies, reactions, edits, typing) and is a human participant (it may answer questions, its messages reset the agent turn budget and wake a Chief the way an imported message of that person does), but never the owner: it cannot mint credentials, import history or use any command reserved to `user_local`. That person's typing ends when their last bound connection ends or their credential is replaced. `participant-avatars-v1` advertises `participant.avatar.set` and `participant.avatar.get` (one picture per participant, a person or a Chief, in one avatar store; resource-api-v2.md "Participant avatars") and the `participant-avatar-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info`. `ws-conversations-v1` advertises the local conversation owner on an authenticated WebSocket connection (the static credential or an approved pairing) as `user_local`, the same as on a Unix connection: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-thread`, `conversation-link-resolve` (with `conversation-tree-v1`), `conversation-search`, `conversation-op`, `conversation-typing`, the `conversation.*` operations, and the `conversation-changed`, `conversation-typing` and (with `conversation-draft-events-v1`) `conversation-draft` events on `subscribe`; `conversation-bind`, `conversation-agent-token` and `conversation-import` stay on Unix connections, and the attachment commands come to WebSocket connections only with `ws-attachments-v1` (cmux#13742). `local-conversations-v1` advertises the local conversation owner on trusted local connections: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-op`, `conversation-typing`, `conversation-bind`, `conversation-agent-token`, `conversation-import`, and the `conversation-changed` and `conversation-typing` events. `notification-source-v1` advertises `source` on `notify`, the `notification` event, the tab `notification` marker, and `list-notifications` rows (`extra.source` in resource notification snapshots), and daemon-side desktop notifications from terminal output (OSC 9, OSC 777 `notify`, kitty OSC 99) with source `terminal`. `terminal-shell-args-v1` advertises `shell_args` on `new-tab`, `split`, `new-pane`, `new-pane-right`, and `create-terminal`: the terminal runs its `SHELL` from `env` (else the daemon's default shell) with those arguments, so a frontend can apply Ghostty's argv-based shell integration (bash `--posix` with `ENV`, nushell `--execute`). `terminal-frontend-shell-integration-v1` lets a frontend that resolves Ghostty's shell integration itself (from the user's `shell-integration` and `shell-integration-features`, into the terminal's `env` and `shell_args`) echo it in `set-client-info`; the terminals that connection creates with a `SHELL` in `env` then run that shell exactly as given (stored as `argv`), with no host-added integration, on `new-tab`, `split`, `new-pane`, `new-pane-right`, `new-row`, `create-terminal`, and a terminal `respawn`; without a `SHELL` the host integrates as before. `new-workspace` and the reopen-saved-group commands take no `env` and keep the host integration; `new-screen` follows it with `screen-terminal-env-v1`. `screen-terminal-env-v1` advertises `env`, `terminal_id`, and `shell_args` on `new-screen` (the first terminal takes them as on `new-pane`, including `terminal-frontend-shell-integration-v1`) and `terminal_id` in its result. `launch-snapshot-v1` advertises `launch_snapshot_path` in `identify` and the launch snapshot file described there. `close-reason-v1` advertises `reason` on `close-tabs` and, with `end_terminals`, on `close-workspace`: `session_end` closes without a closed-history record. `conversation-tab-transaction-v1` advertises `transaction` on `new-conversation-tab`: the created tab's `tab-added` delta and the result echo it. `browser-host-provider-v1` advertises `browser-host-provider`: the verified local app gets the provider credentials of the daemon's own browser host. `frontend-browser-activate-v1` advertises `activate` on `new-frontend-browser-tab`. `history-search-v1` (advertised only when the daemon has a search index) advertises `history-search`. `frontend-browser-insert-after-v1` advertises `after` on `new-frontend-browser-tab`. `bookmarks-v1` advertises one bookmark tree per browser profile: `list-bookmarks`, `create-bookmark`, `update-bookmark`, `move-bookmark`, `delete-bookmark`, `import-bookmarks`, the `bookmarks-changed` event, and `deleted_bookmarks` in the `delete-browser-profile` result. `bookmark-icon-v1` advertises a bookmark folder's `icon`: set or cleared by `update-bookmark` and shown in `list-bookmarks` (see "Icon values" under `put-blob`). `session-icon-v1` advertises a session's (machine's) `icon` in the home session registry: set or cleared by `set-session-icon`, kept by `put-session`, and shown on each `list-personal` session record. `icon-assets-v1` advertises the asset blob store (`put-blob`, `get-blob`) and the asset icon values `image:sha256-` and `svg:sha256-` on the workspace, screen, room, browser profile and workspace status icon fields (see "Icon values" under `put-blob`). `ws-listen-v1` (advertised when the binary can open a page listener) advertises `ws-listen`: a running owner opens its loopback page listener and credential file on request. `cloud-conversations-v1` (advertised only when the daemon has a cloud transport) advertises the cloud conversations proxy on trusted local connections: the `cloud-session-*`, `cloud-inbox-*` and `cloud-conversation-*` commands and the `cloud-*` events (plans/cmux-next/home-cloud-proxy.md). `cloud-lease-app-only-v1` (advertised with `cloud-conversations-v1`) advertises that only the verified cmux app connection may replace, clear or use a lease the app set and send `cloud-conversation-op` with `origin: "user"`; other connections get `cloud_conversation_rejected` with `reason` `forbidden`. `page-credential-v1` advertises `page-credential`: the verified cmux app gets a credential for its own page. `page-credential-scoped-v1` advertises the optional `page` field of `page-credential`: any trusted local connection gets a single-use credential bound to that page of the page-router table, which admits only a WebSocket handshake whose Origin names that page and never carries the Cloud lease. `ws-cloud-conversations-v1` (advertised with `cloud-conversations-v1`) advertises that a WebSocket connection that authenticated with a pairing-store credential (an approved pairing, or a `page-credential`; never the static or file token, which any same-user process can read) acts as the person with the lease the verified app set: `cloud-session-status` (with `participant`), `cloud-inbox-list`, `cloud-conversation-snapshot`, `cloud-conversation-history`, `cloud-conversation-thread`, `cloud-conversation-op` (also with `origin: "user"`), the `cloud-*-subscribe` and `cloud-*-unsubscribe` commands, and the `cloud-*` events on `subscribe`; it never sets or clears a lease, and without the app's lease it is signed out (`cloud_signed_out`), never using a lease another connection set (cmux#13742). `origin-claim-v1` advertises `client-hello` step 1, the optional `origin` claim on `cmux.protocol/2` requests, and `origin.confirmation.issue` (see "client-hello" and `resource-api-v2.md`, "Request origin"). `terminal-clipboard-read-v1` advertises the OSC 52 clipboard-read broker: `terminal-clipboard-subscribe`, `terminal-clipboard-reply`, and the targeted `terminal-clipboard-read` and `terminal-clipboard-read-cancelled` control events (see "Terminal clipboard reads"). `agent-session-attach-v1` (advertised when the daemon resolved an acpmux socket path) advertises the `agent-session-*` commands and events on trusted local connections (see "Agent session attach"). `agent-session-start-v1` (advertised with `agent-session-attach-v1`) advertises `agent-session-start` (see "agent-session-start"). `agent-session-start-keyed-reason-v1` (advertised with `agent-session-start-v1`) advertises `require_person_key` and `person_instance` on `agent-session-start` and its typed `agent_session.needs_keyed_start` refusal. `agent-transfer-v1` (advertised with `agent-session-attach-v1` once the daemon knows its host) advertises the `agent-transfer-*` commands on trusted local connections (see "Agent chat transfer"). `access-request-v1` (advertised on a cmux Cloud host) advertises the `access-request*` commands on trusted local connections (see "Access requests"). `feed-local-owner-v1` advertises the daemon's local feed owner (plans/cmux-next/feed.md section 9.1): every notification is also a local feed item committed in the same transaction, `select-tab`, `select-workspace`, `select-screen`, `focus-pane` and `focus-direction` no longer clear unread (a client acknowledges with `ack-tab-notifications`, which also reads the tab's local items and reports `refused` items), the `feed-local-list`, `feed-local-read`, `feed-local-handoff-begin`, `feed-local-handoff-abort` and `feed-local-handoff-done` commands, and the error codes `feed.moving`, `owner.unreachable` (both retryable), `feed.invalid_state`, `not_found` and `forbidden` (a handoff step from a connection that is not the verified cmux app). `chief-profile-ws-v1` advertises that the person's page (page `home-channels` admitted with a page-bound credential, or an authenticated WebSocket with the daemon's own credential and no page Origin) may call `chief.profile.*`: list, read, and change display_name, role and the avatar; create, delete, instructions, personality and the grant fields need the person's gesture (resource-api-v2.md "Chief registry"). `chief-events-v1` advertises the `chief-changed` event on `subscribe` (a Chief's id and `created`, `updated` or `deleted`, after every committed `chief.profile.*` write) for the person's own connection (`user_local`, the Channels page included) that echoes the capability in `set-client-info`. +`capabilities` is additive build-level feature negotiation within a protocol version. Clients must treat a missing field as an empty list. `daemon-handoff-force-v1` advertises the optional `force` field on `shutdown-daemon`. `browser-provider-v1` advertises the trusted-local, connection-scoped native browser provider lease used by cmux-browser and local automation. `browser-provider-native-hosting-v1` advertises the optional `hosting` field of `register-browser-provider`: a `"native"` provider presents its pages itself, so the daemon never overrides their device metrics and screencasts them only while a frame viewer is attached. `browser-pointer-frame-guard-v1` advertises authoritative `pointer_frame_seq` and `pointer_frame_floor_seq` browser attach/frame state plus the additive `browser-frame-presented`, `browser-mouse-guarded`, and `browser-wheel-guarded` commands. Each admitted bitmap receives a new guard even when its document and dimensions match the previous bitmap. The reported floor through latest range proves route membership only. `browser-frame-presented` advances one exact acknowledged token for that connection, and only that token authorizes a new guarded pointer action. A guarded pointer command implicitly acknowledges its own token. Each connection retains one token, while the bounded browser input queue owns actions admitted before a later presentation. Navigation or geometry changes clear the range and all acknowledgements. An accepted press keeps its original guard for motion across ordinary repaints while document and geometry remain valid; invalidation suppresses further motion but retains its balancing release. A capable client echoes that value in `set-client-info`; browser attach requires the bilateral capability while PTY attach remains available without it. The legacy `browser-mouse` and `browser-wheel` schemas retain their optional guard, but guarded servers reject a missing guard before surface lookup. `viewport-splits-v1` advertises `new-pane-right` and the `Screen.viewport_splits` field. `viewport-column-resize-v1` advertises `set-viewport-pane-width` and `Screen.viewport_base_width`. `pane-browser-kind-v1` advertises `kind` and `url` on `split` and `new-pane-right`: `kind:"browser"` with a non-empty `url` puts a browser tab at that URL in the new pane, through the same `pane.split` commit and restart recovery as a terminal pane; public `pane.split` stays terminal-only. `dock-columns-v1` advertises `set-column-dock` and the optional `Screen.columns[].dock` field for every edge. `edge-docks-v1` advertises the edges `top` and `bottom` on `set-column-dock` and on `dock` in `move-tab-to-column` (the `dock` field itself needs `dock-columns-v1`). `dock-column-role-v1` advertises `role` on `set-column-dock` and on `dock` in `move-tab-to-column`, and `Screen.columns[].dock.role`. `rows-v1` advertises `new-row`, `set-row-heights`, and the optional `Screen.columns[].rows` field. `layout-undo-v1` advertises server-owned structural layout history and `undo-layout`. `view-attachment-lease-v1` returns a connection-owned lease for each attach and enables lease-fenced sizing. `view-attachment-detach-v1` enables targeted stream cleanup. `creation-receipts-v1` enables idempotent destination creation, `creation-attempt-keys-v1` separates a stable correlation from the same-key or new-key execution attempt selected by `session.creation.resolve`, and `creation-selector-fallbacks-v1` adds bounded ordered destination continuations. `provider-managed-workspace-authority-v2` advertises pre-provisioned provider ownership and authority-gated post-provider rename and close commits. `terminal-idle-close-v1` advertises `set-terminal-idle-policy` and the owner-side reaper that closes a terminal after its policy elapses with no attached view. `terminal-pending-sequence-v1` advertises the separate `pending` field on byte-attach `vt-state` and `resized` events; a client that echoes it in `set-client-info` receives it (see `events.md`). `terminal-placement-env-v1` advertises a caller-chosen `terminal_id` on `new-tab`, `split`, `new-pane`, and `new-pane-right`, `cwd` and `env` on `new-pane` and `new-pane-right`, and `terminal_id`/`terminal_incarnation` in all four results. `split-client-keys-v1` advertises the client-minted `pane_id` and `tab_id` on `split`, `new-pane`, and `new-pane-right`, keyed retries that return the first result with `replayed`, and `pane_id`/`tab_id` in their results (see "Client-minted pane ids"). `terminal-resources-v1` advertises `terminal-resources`, which reads the CPU time and memory of each terminal's shell, descendants, and terminal host at request time. `batch-close-v1` advertises `close-tabs` and the optional `end_terminals` field on `close-pane`, `close-screen`, `close-workspace`, and `close-tab-group`: many placements and the terminals they end close in one durable commit. `close-workspace-if-empty-v1` advertises the optional `if_empty` field on `close-workspace`: the close commits only when the workspace has no tab at commit time, else it fails with `workspace_not_empty` and changes nothing. `end-terminals-keep-layout-v1` advertises `keep_layout` on `shutdown-daemon`: with `end_terminals`, placed terminals keep their tabs across the handoff. `terminal-reap-v1` advertises the owner-side reaper that ends a terminal after it has had no tab placement for the reap grace period (active only when the daemon was started with `--terminal-reap-grace-seconds`), `set-terminal-keep`, the `keep` field on `new-tab`, `split`, and `create-terminal`, the `terminal-reaped` event, and `end_terminals` on `shutdown-daemon`. `terminal-env-v1` advertises the per-terminal `env` object on `new-tab`, `split`, and `create-terminal`, and `cwd` on `split`. `tab-groups-v1` advertises tab groups: the `*-tab-group` commands, `Pane.tab_groups`, and `Tab.group`. `saved-tab-groups-v1` advertises saved groups: `save-tab-group`, `unsave-tab-group`, `delete-saved-tab-group`, `list-saved-tab-groups`, and `reopen-saved-tab-group`. `notification-ack-v1` advertises `ack-tab-notifications`, `list-notifications`, durable notification acknowledgement, and `Workspace.unread_count`. `tab-drag-v1` advertises the single-command tab drag outcomes `move-tab-to-split`, `move-tab-to-column`, and `move-tab-to-new-workspace`, layout undo for same-screen tab drags and cross-pane `move-tab`, and the optional `transaction` field on every drag command, echoed in the resulting `tab-changed` delta. `tab-workspace-name-v1` advertises the optional `name` field on `move-tab-to-new-workspace`: the new workspace takes that name in the same commit. `tab-split-respawn-v1` advertises `respawn` on `move-tab-to-split`: a pane's only tab dropped on its own pane's edge splits that pane and leaves a fresh tab of the same kind behind. `tab-split-keep-focus-v1` advertises `keep_focus` on `move-tab-to-split` and `move-tab-to-column`: the new pane does not take focus. `tab-column-respawn-v1` advertises `respawn` on `move-tab-to-column`: a pane's only tab moves into the new column and leaves a fresh tab of the same kind in its pane. `frontend-browser-tabs-v1` advertises `new-frontend-browser-tab`, `update-frontend-browser-tab`, and the frontend browser tab fields. `frontend-browser-history-v1` advertises `set-frontend-browser-history` and `get-frontend-browser-history`: an opaque per-tab session history object for frontend-rendered browsers, stored durably outside the tree. `tab-metadata-v1` advertises `set-tab-pinned`, pinned-first tab order, the `Tab.pinned`, `Tab.cwd`, `Tab.git_branch`, and `Tab.git_detached` fields, and the `tab-changed` delta. `workspace-metadata-v1` advertises `set-workspace-metadata`, the `Workspace.color`, `Workspace.icon`, and `Workspace.title` fields, and the `workspace-changed` delta. `workspace-pin-v1` advertises the `pinned` field on `set-workspace-metadata` and `Workspace.pinned`. `notification-mark-unread-v1` advertises the `marked_unread` field on `set-workspace-metadata` and `Workspace.marked_unread`. `workspace-groups-v1` advertises durable sidebar groups: the `*-workspace-group` commands, `move-workspace-to-group`, `Tree.groups`, and `Workspace.group`. `loopback-forward-v1` advertises multiplexed TCP streams to the daemon machine's own loopback services (see "Loopback forwarding"); a Unix client echoes it in `set-client-info` before its first `loopback-open`. `fs-v1` advertises the `fs.*` file ops and their byte streams (see "File system (fs-v1)"); only cmux Cloud hosts serve it. `session-identity-v1` advertises `identify.session_id` (the durable `registry_id`, stable across restarts and upgrades) and `identify.machine_name` (the host name, at most 255 bytes, no control characters). `profiles-v1` advertises the home session's personal state: `list-personal`, the `*-profile` room commands, `set-profile-follows`, `pin-workspace`, `unpin-workspace`, `put-session`, `forget-session`, `import-session-organization`, the `*-personal-group` commands, `set-personal-workspace`, and the `personal-changed` event. `personal-terminals-v1` advertises `set-personal-terminal` and `list-personal.terminals`. `screen-metadata-v1` advertises `set-screen-metadata`, `set-screen-pinned`, `move-screen`, the `screen_name`, `color`, `icon`, `pinned`, `index`, `group`, and `cwd` fields on `new-screen` (whose result then also carries `screen`), the `Screen.color`, `Screen.icon`, `Screen.pinned`, and `Screen.group` fields, and the `screen-changed` delta. `screen-groups-v1` advertises screen groups: the `*-screen-group` commands, saved screen groups, and `Workspace.screen_groups`. `browser-profiles-v1` advertises browser profile records in personal state: `browser_profiles` in `list-personal` and `create-browser-profile`, `update-browser-profile`, `move-browser-profile`, and `delete-browser-profile`. `conversation-search-v1` advertises `conversation-search` on the local conversation owner. `conversation-draft-v1` advertises `conversation-draft`, the v1 command of the `conversation.draft` operation for a bound agent connection. `conversation-draft-events-v1` advertises the `conversation-draft` event on `subscribe` (the `conversation.events` draft item, with its `conversation` and `participant`) for a trusted local connection that echoes the capability in `set-client-info`; other connections never get drafts on `subscribe`. `conversation-threads-v1` advertises `thread_root` on `message.send` and on `Message`, the `invalid_thread_root` reject, and that a `message.send` moves the sender's own read cursor to the new seq when the sender had read everything before it (see `conversation-op`). `conversation-thread-v1` advertises `conversation-thread`, the `threads` root stats on `conversation-snapshot` and `conversation-history` results, and, with `cloud-conversations-v1`, `cloud-conversation-thread`. `conversation-tree-v1` advertises threads that nest without a limit on the local conversation owner (`reply_to` is the parent, `thread_root` the top root; a `message.send` whose `reply_to` parent is in a thread joins that thread, and a named `thread_root` must be the parent's own; see `conversation-op`) and `conversation-link-resolve`, which opens a conversation or message permalink. `conversation-inbox-state-v1` advertises the person's own list state of a conversation, kept beside it (a pin with its position and a "marked unread" flag): `conversation-inbox-op`, the caller's `pinned`, `pin_position` and `marked_unread` on `conversation-list` items, the `conversation-inbox-changed` event on `subscribe` (only to connections whose principal is that participant), and with `cloud-conversations-v1` also `cloud-inbox-op`. `conversation-thread-objects-v1` advertises threads as objects: the `thread.update` op (a `title`, a `status` of `open` or `resolved`, and an `owner` participant; any participant may send it, an agent too) on `conversation-op` and through `cloud-conversation-op`, the `thread` object on a root `Message`, and the `status`, `title`, `owner`, `resolved_by` and `resolved_at` fields of `ThreadSummary` (see `conversation-op` and `conversation-thread`). `conversation-runs-v1` advertises agent runs on the local conversation owner: `conversation-run-append` for a bound agent participant, `conversation-runs` and `conversation-run` on trusted local connections (Unix, and authenticated WebSocket connections with `ws-conversations-v1`), and the `conversation-run-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info` (see "conversation-run-append, conversation-runs, conversation-run"). `cloud-conversation-runs-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-conversation-runs`, `cloud-conversation-run` and the `cloud-conversation-run-changed` event (see "cloud-conversation-runs, cloud-conversation-run"). `local-attachments-v1` advertises `attachment` message parts on the local conversation owner and the `conversation-attachment-upload` and `conversation-attachment-read` commands on trusted local connections. `cloud-attachments-v1` (advertised with `cloud-conversations-v1`) advertises `cloud-attachment-upload`, `cloud-attachment-read` and `cloud-attachment-link`: the same steps and results for a cloud conversation's attachments, through the daemon with the person's lease. `ws-attachments-v1` advertises the same two commands and `conversation-attachment-link` on an authenticated WebSocket connection (the clients of `ws-conversations-v1`; never a relayed remote device), and the listener's `GET /attachments/` download links with byte ranges (transports.md). `link-preview-v1` advertises `link_preview` message parts on the local conversation owner (see `conversation-attachment-upload`). `conversation-person-credentials-v1` advertises that `conversation-agent-token` and `conversation-bind` also take a person other than the server's own user (a `user_` id that is not `user_local`; never a `remote_` device): the bound Unix connection then acts as that person (sends, thread replies, reactions, edits, typing) and is a human participant (it may answer questions, its messages reset the agent turn budget and wake a Chief the way an imported message of that person does), but never the owner: it cannot mint credentials, import history or use any command reserved to `user_local`. That person's typing ends when their last bound connection ends or their credential is replaced. `participant-avatars-v1` advertises `participant.avatar.set` and `participant.avatar.get` (one picture per participant, a person or a Chief, in one avatar store; resource-api-v2.md "Participant avatars") and the `participant-avatar-changed` event on `subscribe` for a connection that echoes the capability in `set-client-info`. `chief-profile-adopt-v1` advertises `chief.profile.adopt` (the owner makes an existing agent participant a Chief record under its own id; resource-api-v2.md "Chief registry"). `ws-conversations-v1` advertises the local conversation owner on an authenticated WebSocket connection (the static credential or an approved pairing) as `user_local`, the same as on a Unix connection: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-thread`, `conversation-link-resolve` (with `conversation-tree-v1`), `conversation-search`, `conversation-op`, `conversation-typing`, the `conversation.*` operations, and the `conversation-changed`, `conversation-typing` and (with `conversation-draft-events-v1`) `conversation-draft` events on `subscribe`; `conversation-bind`, `conversation-agent-token` and `conversation-import` stay on Unix connections, and the attachment commands come to WebSocket connections only with `ws-attachments-v1` (cmux#13742). `local-conversations-v1` advertises the local conversation owner on trusted local connections: `conversation-list`, `conversation-create`, `conversation-snapshot`, `conversation-history`, `conversation-op`, `conversation-typing`, `conversation-bind`, `conversation-agent-token`, `conversation-import`, and the `conversation-changed` and `conversation-typing` events. `notification-source-v1` advertises `source` on `notify`, the `notification` event, the tab `notification` marker, and `list-notifications` rows (`extra.source` in resource notification snapshots), and daemon-side desktop notifications from terminal output (OSC 9, OSC 777 `notify`, kitty OSC 99) with source `terminal`. `terminal-shell-args-v1` advertises `shell_args` on `new-tab`, `split`, `new-pane`, `new-pane-right`, and `create-terminal`: the terminal runs its `SHELL` from `env` (else the daemon's default shell) with those arguments, so a frontend can apply Ghostty's argv-based shell integration (bash `--posix` with `ENV`, nushell `--execute`). `terminal-frontend-shell-integration-v1` lets a frontend that resolves Ghostty's shell integration itself (from the user's `shell-integration` and `shell-integration-features`, into the terminal's `env` and `shell_args`) echo it in `set-client-info`; the terminals that connection creates with a `SHELL` in `env` then run that shell exactly as given (stored as `argv`), with no host-added integration, on `new-tab`, `split`, `new-pane`, `new-pane-right`, `new-row`, `create-terminal`, and a terminal `respawn`; without a `SHELL` the host integrates as before. `new-workspace` and the reopen-saved-group commands take no `env` and keep the host integration; `new-screen` follows it with `screen-terminal-env-v1`. `screen-terminal-env-v1` advertises `env`, `terminal_id`, and `shell_args` on `new-screen` (the first terminal takes them as on `new-pane`, including `terminal-frontend-shell-integration-v1`) and `terminal_id` in its result. `launch-snapshot-v1` advertises `launch_snapshot_path` in `identify` and the launch snapshot file described there. `close-reason-v1` advertises `reason` on `close-tabs` and, with `end_terminals`, on `close-workspace`: `session_end` closes without a closed-history record. `conversation-tab-transaction-v1` advertises `transaction` on `new-conversation-tab`: the created tab's `tab-added` delta and the result echo it. `browser-host-provider-v1` advertises `browser-host-provider`: the verified local app gets the provider credentials of the daemon's own browser host. `frontend-browser-activate-v1` advertises `activate` on `new-frontend-browser-tab`. `history-search-v1` (advertised only when the daemon has a search index) advertises `history-search`. `frontend-browser-insert-after-v1` advertises `after` on `new-frontend-browser-tab`. `bookmarks-v1` advertises one bookmark tree per browser profile: `list-bookmarks`, `create-bookmark`, `update-bookmark`, `move-bookmark`, `delete-bookmark`, `import-bookmarks`, the `bookmarks-changed` event, and `deleted_bookmarks` in the `delete-browser-profile` result. `bookmark-icon-v1` advertises a bookmark folder's `icon`: set or cleared by `update-bookmark` and shown in `list-bookmarks` (see "Icon values" under `put-blob`). `session-icon-v1` advertises a session's (machine's) `icon` in the home session registry: set or cleared by `set-session-icon`, kept by `put-session`, and shown on each `list-personal` session record. `icon-assets-v1` advertises the asset blob store (`put-blob`, `get-blob`) and the asset icon values `image:sha256-` and `svg:sha256-` on the workspace, screen, room, browser profile and workspace status icon fields (see "Icon values" under `put-blob`). `ws-listen-v1` (advertised when the binary can open a page listener) advertises `ws-listen`: a running owner opens its loopback page listener and credential file on request. `cloud-conversations-v1` (advertised only when the daemon has a cloud transport) advertises the cloud conversations proxy on trusted local connections: the `cloud-session-*`, `cloud-inbox-*` and `cloud-conversation-*` commands and the `cloud-*` events (plans/cmux-next/home-cloud-proxy.md). `cloud-lease-app-only-v1` (advertised with `cloud-conversations-v1`) advertises that only the verified cmux app connection may replace, clear or use a lease the app set and send `cloud-conversation-op` with `origin: "user"`; other connections get `cloud_conversation_rejected` with `reason` `forbidden`. `page-credential-v1` advertises `page-credential`: the verified cmux app gets a credential for its own page. `page-credential-scoped-v1` advertises the optional `page` field of `page-credential`: any trusted local connection gets a single-use credential bound to that page of the page-router table, which admits only a WebSocket handshake whose Origin names that page and never carries the Cloud lease. `ws-cloud-conversations-v1` (advertised with `cloud-conversations-v1`) advertises that a WebSocket connection that authenticated with a pairing-store credential (an approved pairing, or a `page-credential`; never the static or file token, which any same-user process can read) acts as the person with the lease the verified app set: `cloud-session-status` (with `participant`), `cloud-inbox-list`, `cloud-conversation-snapshot`, `cloud-conversation-history`, `cloud-conversation-thread`, `cloud-conversation-op` (also with `origin: "user"`), the `cloud-*-subscribe` and `cloud-*-unsubscribe` commands, and the `cloud-*` events on `subscribe`; it never sets or clears a lease, and without the app's lease it is signed out (`cloud_signed_out`), never using a lease another connection set (cmux#13742). `origin-claim-v1` advertises `client-hello` step 1, the optional `origin` claim on `cmux.protocol/2` requests, and `origin.confirmation.issue` (see "client-hello" and `resource-api-v2.md`, "Request origin"). `terminal-clipboard-read-v1` advertises the OSC 52 clipboard-read broker: `terminal-clipboard-subscribe`, `terminal-clipboard-reply`, and the targeted `terminal-clipboard-read` and `terminal-clipboard-read-cancelled` control events (see "Terminal clipboard reads"). `agent-session-attach-v1` (advertised when the daemon resolved an acpmux socket path) advertises the `agent-session-*` commands and events on trusted local connections (see "Agent session attach"). `agent-session-start-v1` (advertised with `agent-session-attach-v1`) advertises `agent-session-start` (see "agent-session-start"). `agent-session-start-keyed-reason-v1` (advertised with `agent-session-start-v1`) advertises `require_person_key` and `person_instance` on `agent-session-start` and its typed `agent_session.needs_keyed_start` refusal. `agent-transfer-v1` (advertised with `agent-session-attach-v1` once the daemon knows its host) advertises the `agent-transfer-*` commands on trusted local connections (see "Agent chat transfer"). `access-request-v1` (advertised on a cmux Cloud host) advertises the `access-request*` commands on trusted local connections (see "Access requests"). `feed-local-owner-v1` advertises the daemon's local feed owner (plans/cmux-next/feed.md section 9.1): every notification is also a local feed item committed in the same transaction, `select-tab`, `select-workspace`, `select-screen`, `focus-pane` and `focus-direction` no longer clear unread (a client acknowledges with `ack-tab-notifications`, which also reads the tab's local items and reports `refused` items), the `feed-local-list`, `feed-local-read`, `feed-local-handoff-begin`, `feed-local-handoff-abort` and `feed-local-handoff-done` commands, and the error codes `feed.moving`, `owner.unreachable` (both retryable), `feed.invalid_state`, `not_found` and `forbidden` (a handoff step from a connection that is not the verified cmux app). `chief-profile-ws-v1` advertises that the person's page (page `home-channels` admitted with a page-bound credential, or an authenticated WebSocket with the daemon's own credential and no page Origin) may call `chief.profile.*`: list, read, and change display_name, role and the avatar; create, delete, instructions, personality and the grant fields need the person's gesture (resource-api-v2.md "Chief registry"). `chief-events-v1` advertises the `chief-changed` event on `subscribe` (a Chief's id and `created`, `updated` or `deleted`, after every committed `chief.profile.*` write) for the person's own connection (`user_local`, the Channels page included) that echoes the capability in `set-client-info`. Errors: @@ -6490,6 +6490,15 @@ connection whose principal is `user_local` may call it. The owner stores the token's SHA-256; a new token replaces the old one and ends the bindings made with it. With `conversation-person-credentials-v1`, `participant` may also be a person other than `user_local` (a `user_` id), never a `remote_` device. +For a Chief record other than `agent_mux` (`chief.profile.create` or +`chief.profile.adopt`), only its brain holds the credential when the daemon +runs a Chief brain supervisor (the supervisor mints it in process; this +command is refused), and on a daemon with no supervisor only the person's own +terminal mints it: a page (WebSocket or the host's page relay), a paired or +remote device and an agent are refused. `chief.profile.adopt` revokes every +credential issued for the participant before the adopt, and `conversation-bind` +checks the credential and binds under one lock, so a revoked credential never +binds after it. Params: `participant` (required). @@ -8560,7 +8569,7 @@ flight per attachment), `agent_session.timeout` (5 s), `agent_session.refused` `{id, cmd:"agent-session-attach", surface, after_seq?, before_seq?, limit?, kinds?}` subscribes the connection to the tab's session and answers acpmux's attach page `{session, events, hasMore, lastSeq}` (`limit` 1 to 500, default 200; -`kinds` at most 8 acpmux record kinds or categories; at most 8 MiB of +`kinds` at most 32 acpmux record kinds or categories; at most 8 MiB of records, the rest left for the next page with `hasMore`). On an attached tab it only pages. Live records then arrive as `{event:"agent-session-record", surface, record}` (one acpmux record in its diff --git a/cmux-tui/spec/inventory.json b/cmux-tui/spec/inventory.json index a7d671c20a82..c854864b6165 100644 --- a/cmux-tui/spec/inventory.json +++ b/cmux-tui/spec/inventory.json @@ -32,6 +32,7 @@ "browser.viewer.resize", "chief.engine.get", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.get", "chief.profile.avatar.set", "chief.profile.create", diff --git a/cmux-tui/spec/resource-api-v2.json b/cmux-tui/spec/resource-api-v2.json index cbbb52ded50b..2ec69a923219 100644 --- a/cmux-tui/spec/resource-api-v2.json +++ b/cmux-tui/spec/resource-api-v2.json @@ -77,6 +77,7 @@ "browser.viewer.resize", "chief.engine.get", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.get", "chief.profile.avatar.set", "chief.profile.create", @@ -453,6 +454,7 @@ "browser.tab.emulate.clear", "browser.tab.set_profile", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.set", "chief.profile.create", "chief.profile.delete", @@ -688,6 +690,7 @@ "browser.tab.emulate.clear", "browser.tab.set_profile", "chief.engine.set", + "chief.profile.adopt", "chief.profile.avatar.set", "chief.profile.create", "chief.profile.delete", diff --git a/cmux-tui/spec/resource-api-v2.md b/cmux-tui/spec/resource-api-v2.md index 87e600ef3659..ac44d4cd5b32 100644 --- a/cmux-tui/spec/resource-api-v2.md +++ b/cmux-tui/spec/resource-api-v2.md @@ -968,7 +968,7 @@ operations after the server socket is bound. | Class | Operations | | --- | --- | | read | `agent.list`, `agent.message.list`, `browser.devices.list`, `browser.get`, `browser.list`, `chief.engine.get`, `chief.profile.avatar.get`, `chief.profile.get`, `chief.profile.list`, `client.get`, `client.list`, `closed.list`, `conversation.get`, `conversation.history`, `conversation.list`, `conversation.search`, `credential.mint`, `credential.verify`, `frontend_projection.get`, `git.branches`, `git.checkpoint.diff`, `git.checkpoint.get`, `git.checkpoint.list`, `git.diff`, `git.files.list`, `git.files.search`, `git.status`, `history.entries.list`, `history.visit.summaries`, `keybindings.get`, `machine.get`, `machine.list`, `notification.list`, `pairing_request.list`, `palette_usage.get`, `pane.get`, `pane.list`, `pane.neighbor.get`, `participant.avatar.get`, `project.list`, `room.list`, `saved_tab_group.list`, `screen.get`, `screen.layout.export`, `screen.list`, `screen_group.get`, `screen_group.list`, `session.creation.resolve`, `session.get`, `session.journal.checkpoint.list`, `session.journal.hook.list`, `session.journal.producer.list`, `session.journal.restore.preview`, `session.journal.segment.list`, `session.list`, `session.ping`, `session.snapshot`, `settings.get`, `settings.list`, `settings.schema`, `settings.snapshot`, `sidebar.get`, `sidebar_layout.get`, `sidebar_view.get`, `tab.get`, `tab.list`, `tab_group.get`, `tab_group.list`, `terminal.copy`, `terminal.get`, `terminal.history.read`, `terminal.list`, `terminal.output_read`, `terminal.process.get`, `terminal.screen.read`, `terminal.state.read`, `terminal.wait`, `terminal.wait_exit`, `window_record.list`, `workspace.agent_start.get`, `workspace.get`, `workspace.list`, `workspace.placement.list`, `workspace_group.list`, `workspace_log.list`, `workspace_status.list` | -| mutation | `agent.message.mark`, `agent.message.release`, `agent.message.send`, `agent.report`, `browser.activate`, `browser.back`, `browser.close`, `browser.devices.add_custom`, `browser.devices.remove_custom`, `browser.forward`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.navigate`, `browser.reload`, `browser.tab.emulate`, `browser.tab.emulate.clear`, `browser.tab.set_profile`, `chief.engine.set`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.update`, `chief.stop`, `closed.delete`, `closed.reopen`, `column.update`, `conversation.draft`, `conversation.send`, `conversation.typing`, `credential.rotate`, `frontend_projection.put`, `git.checkpoint.create`, `git.checkpoint.pin`, `git.checkpoint.unpin`, `git.commit`, `git.push`, `git.worktree.apply`, `git.worktree.create`, `git.worktree.remove`, `history.backups.purge`, `history.clear`, `history.entries.remove`, `history.restore`, `history.site.remove`, `history.trail.record`, `history.visit.import`, `history.visit.record`, `history.visit.remove`, `history.visit.title`, `keybindings.remove`, `keybindings.reset`, `keybindings.set`, `layout.bulk`, `notification.ack`, `notification.clear`, `notification.create`, `pairing_request.resolve`, `palette_usage.forget`, `palette_usage.hide`, `palette_usage.import`, `palette_usage.record`, `pane.close`, `pane.create`, `pane.focus`, `pane.focus_direction`, `pane.rename`, `pane.run`, `pane.split`, `pane.split_ratio.set`, `pane.swap`, `pane.viewport_width.set`, `pane.zoom`, `participant.avatar.set`, `project.add`, `project.observe`, `project.remove`, `project.source.update`, `project.sync`, `project.update`, `room.create`, `room.delete`, `room.follow`, `room.move`, `room.pin`, `room.unpin`, `room.update`, `saved_tab_group.delete`, `saved_tab_group.reopen`, `saved_tab_group.save`, `screen.close`, `screen.create`, `screen.focus`, `screen.layout.undo`, `screen.move`, `screen.rename`, `screen.update`, `screen_group.add_screens`, `screen_group.create`, `screen_group.remove_screens`, `screen_group.ungroup`, `screen_group.update`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.hook.put`, `session.journal.producer.put`, `session.journal.segment.seal`, `session.open`, `session.reload_config`, `session.shutdown`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set`, `settings.reset`, `settings.reset_all`, `settings.set`, `sidebar.adopt`, `sidebar.apply`, `sidebar.group.create`, `sidebar.group.ungroup`, `sidebar.group.update`, `sidebar.item.add`, `sidebar.item.remove`, `sidebar.item.remove_ref`, `sidebar.item.update`, `sidebar.move`, `sidebar.pin`, `sidebar.reset`, `sidebar.section.add`, `sidebar.section.move`, `sidebar.section.remove`, `sidebar.section.update`, `sidebar.unpin`, `sidebar_layout.update`, `sidebar_view.ensure`, `sidebar_view.input`, `sidebar_view.reload`, `sidebar_view.resize`, `tab.close`, `tab.create_browser`, `tab.create_terminal`, `tab.focus`, `tab.move`, `tab.pin`, `tab.rename`, `tab.unpin`, `tab.update`, `tab_group.add_tabs`, `tab_group.close`, `tab_group.create`, `tab_group.move`, `tab_group.remove_tabs`, `tab_group.ungroup`, `tab_group.update`, `terminal.close`, `terminal.history.clear`, `terminal.input.focus`, `terminal.input.keys`, `terminal.input.mouse`, `terminal.input.write`, `terminal.move`, `terminal.project`, `terminal.viewport.scroll`, `window_record.delete`, `window_record.put`, `workspace.agent_folder.set`, `workspace.close`, `workspace.create`, `workspace.ensure_app`, `workspace.ensure_home`, `workspace.focus`, `workspace.layout.apply`, `workspace.move`, `workspace.open_url`, `workspace.place`, `workspace.rename`, `workspace.run`, `workspace.update`, `workspace_group.create`, `workspace_group.delete`, `workspace_group.move`, `workspace_group.update`, `workspace_log.append`, `workspace_log.clear`, `workspace_progress.clear`, `workspace_progress.set`, `workspace_status.clear`, `workspace_status.set` | +| mutation | `agent.message.mark`, `agent.message.release`, `agent.message.send`, `agent.report`, `browser.activate`, `browser.back`, `browser.close`, `browser.devices.add_custom`, `browser.devices.remove_custom`, `browser.forward`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.navigate`, `browser.reload`, `browser.tab.emulate`, `browser.tab.emulate.clear`, `browser.tab.set_profile`, `chief.engine.set`, `chief.profile.adopt`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.update`, `chief.stop`, `closed.delete`, `closed.reopen`, `column.update`, `conversation.draft`, `conversation.send`, `conversation.typing`, `credential.rotate`, `frontend_projection.put`, `git.checkpoint.create`, `git.checkpoint.pin`, `git.checkpoint.unpin`, `git.commit`, `git.push`, `git.worktree.apply`, `git.worktree.create`, `git.worktree.remove`, `history.backups.purge`, `history.clear`, `history.entries.remove`, `history.restore`, `history.site.remove`, `history.trail.record`, `history.visit.import`, `history.visit.record`, `history.visit.remove`, `history.visit.title`, `keybindings.remove`, `keybindings.reset`, `keybindings.set`, `layout.bulk`, `notification.ack`, `notification.clear`, `notification.create`, `pairing_request.resolve`, `palette_usage.forget`, `palette_usage.hide`, `palette_usage.import`, `palette_usage.record`, `pane.close`, `pane.create`, `pane.focus`, `pane.focus_direction`, `pane.rename`, `pane.run`, `pane.split`, `pane.split_ratio.set`, `pane.swap`, `pane.viewport_width.set`, `pane.zoom`, `participant.avatar.set`, `project.add`, `project.observe`, `project.remove`, `project.source.update`, `project.sync`, `project.update`, `room.create`, `room.delete`, `room.follow`, `room.move`, `room.pin`, `room.unpin`, `room.update`, `saved_tab_group.delete`, `saved_tab_group.reopen`, `saved_tab_group.save`, `screen.close`, `screen.create`, `screen.focus`, `screen.layout.undo`, `screen.move`, `screen.rename`, `screen.update`, `screen_group.add_screens`, `screen_group.create`, `screen_group.remove_screens`, `screen_group.ungroup`, `screen_group.update`, `session.journal.append`, `session.journal.checkpoint.create`, `session.journal.hook.put`, `session.journal.producer.put`, `session.journal.segment.seal`, `session.open`, `session.reload_config`, `session.shutdown`, `session.terminal_defaults.update`, `session.window.title.clear`, `session.window.title.set`, `settings.reset`, `settings.reset_all`, `settings.set`, `sidebar.adopt`, `sidebar.apply`, `sidebar.group.create`, `sidebar.group.ungroup`, `sidebar.group.update`, `sidebar.item.add`, `sidebar.item.remove`, `sidebar.item.remove_ref`, `sidebar.item.update`, `sidebar.move`, `sidebar.pin`, `sidebar.reset`, `sidebar.section.add`, `sidebar.section.move`, `sidebar.section.remove`, `sidebar.section.update`, `sidebar.unpin`, `sidebar_layout.update`, `sidebar_view.ensure`, `sidebar_view.input`, `sidebar_view.reload`, `sidebar_view.resize`, `tab.close`, `tab.create_browser`, `tab.create_terminal`, `tab.focus`, `tab.move`, `tab.pin`, `tab.rename`, `tab.unpin`, `tab.update`, `tab_group.add_tabs`, `tab_group.close`, `tab_group.create`, `tab_group.move`, `tab_group.remove_tabs`, `tab_group.ungroup`, `tab_group.update`, `terminal.close`, `terminal.history.clear`, `terminal.input.focus`, `terminal.input.keys`, `terminal.input.mouse`, `terminal.input.write`, `terminal.move`, `terminal.project`, `terminal.viewport.scroll`, `window_record.delete`, `window_record.put`, `workspace.agent_folder.set`, `workspace.close`, `workspace.create`, `workspace.ensure_app`, `workspace.ensure_home`, `workspace.focus`, `workspace.layout.apply`, `workspace.move`, `workspace.open_url`, `workspace.place`, `workspace.rename`, `workspace.run`, `workspace.update`, `workspace_group.create`, `workspace_group.delete`, `workspace_group.move`, `workspace_group.update`, `workspace_log.append`, `workspace_log.clear`, `workspace_progress.clear`, `workspace_progress.set`, `workspace_status.clear`, `workspace_status.set` | | stream_open | `browser.attach`, `conversation.events`, `session.events`, `session.journal.subscribe`, `sidebar_view.attach`, `terminal.attach` | | connection_control | `browser.viewer.release`, `browser.viewer.resize`, `client.cell_pixels.set`, `client.detach`, `client.metadata.update`, `client.sizing.release`, `client.sizing.set`, `origin.confirmation.issue`, `request.cancel`, `stream.cancel`, `terminal.renderer_grant.create`, `terminal.viewer.release`, `terminal.viewer.resize` | | local | `sidebar_plugin.install`, `sidebar_plugin.list`, `sidebar_plugin.remove`, `sidebar_plugin.update`, `sidebar_plugin.use`, `sidebar_plugin.use_builtin` | @@ -1221,6 +1221,29 @@ directory. A Chief's id is its conversation participant id: the default Chief is `agent_mux` and exists from the first read; a new Chief is `agent_` and 26 Crockford base32 characters. A deleted id is never reused. +`chief.profile.adopt` (capability `chief-profile-adopt-v1`) makes an existing +agent participant of the owner's conversations a Chief record under its own +id, so its conversations, history and credentials stay its own. Only the +owner calls it, from a trusted local Unix connection: a page (WebSocket or +the host's page relay), a paired or remote device and a bound agent get +`origin.forbidden`, and no page route lists it. The record gets +`chief.profile.create`'s defaults and only the fields given; from the +participant it takes its id and, without `display_name`, its display name. +`agent_mux` is `default_chief`, a live record `already_chief`, a deleted +Chief `deleted_chief` (an adopt never revives a deleted Chief or its kept +memory), an id whose brain directory (its last 8 characters, lowercased) +another live or deleted Chief already has `brain_home_taken`, and a person +or an unknown id `not_an_agent_participant`. A participant with a bound +connection is `participant_bound` ("stop its current agent session first"): +two processes never speak as one participant. In the same write every +credential issued for the participant is revoked; afterwards only the +supervisor-started brain's credential binds, or, on a daemon with no brain +supervisor, one the person mints with `conversation-agent-token` from a local +terminal (never a page, a paired or remote device or an agent) +(`operation.failed` `details.reason`). It is idempotent per key like +`chief.profile.create`, reaches the brain supervisor as a create does and +publishes `chief-changed` with change `created`. + The callers are the owner (the rule of Chief control above) and the Chiefs: a Unix connection bound with `conversation-bind` to a live record's id. With `chief-profile-ws-v1` the person's page is a caller too: an authenticated diff --git a/cmux-tui/spec/resource-operations-v2.json b/cmux-tui/spec/resource-operations-v2.json index cc4eaad352b4..d3c0acbbfe7e 100644 --- a/cmux-tui/spec/resource-operations-v2.json +++ b/cmux-tui/spec/resource-operations-v2.json @@ -15172,6 +15172,159 @@ "Setting the same values again changes nothing, so a retry is safe; replayed is always false." ] }, + "chief.profile.adopt": { + "class": "mutation", + "idempotency": "required", + "target": "session", + "ancestors": [ + "machine" + ], + "params": { + "selectors": { + "machine": "required", + "session": "required" + }, + "fields": { + "participant_id": { + "required": true, + "type": { + "kind": "primitive", + "name": "string", + "min_length": 7, + "max_length": 70 + }, + "description": "An agent participant of the owner's local conversations (agent_ then 1 to 64 characters) that is not a Chief record and not a deleted Chief; never agent_mux and never a person." + }, + "display_name": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 100 + }, + "description": "1 to 100 characters, one line; default: the participant's display name." + }, + "role": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 200 + } + }, + "personality": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 4000 + } + }, + "instructions": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 32000 + }, + "description": "System prompt additions." + }, + "tools": { + "required": false, + "type": { + "kind": "array", + "items": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 64 + } + }, + "description": "Names from browser, chiefs, cloud_vms, memory_import, spawn, tell, workspaces; owner only. A new Chief without it gets spawn, tell, workspaces." + }, + "harnesses": { + "required": false, + "type": { + "kind": "array", + "items": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 64 + } + }, + "description": "Owner only; empty: every harness." + }, + "self_edit": { + "required": false, + "type": { + "kind": "array", + "items": { + "kind": "primitive", + "name": "string", + "min_length": 1, + "max_length": 64 + } + }, + "description": "Names from avatar, display_name, instructions, personality, role; owner only. A new Chief without it gets avatar." + }, + "harness": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 128 + }, + "description": "Owner only; empty or default clears it." + }, + "model": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 128 + }, + "description": "Owner only; empty or default clears it." + }, + "effort": { + "required": false, + "type": { + "kind": "primitive", + "name": "string", + "max_length": 128 + }, + "description": "Owner only; empty or default clears it." + } + }, + "extra": false + }, + "result": { + "kind": "apply", + "name": "MutationResult", + "arguments": [ + { + "kind": "ref", + "name": "ChiefRecord" + } + ] + }, + "errors": [ + "idempotency.conflict", + "operation.failed", + "origin.forbidden", + "selector.ambiguous", + "selector.invalid", + "selector.not_found", + "validation.invalid" + ], + "constraints": [ + "Owner only: a trusted local Unix caller acting as the person (a registered Unix client with no link peer record whose conversation principal is user_local). A page WebSocket, a paired or remote device and a bound agent (a Chief) are refused with origin.forbidden; no page route allows it.", + "The participant must be an agent participant (kind agent) of a local conversation that is not a Chief record: a live record is already_chief, a deleted Chief (its tombstone, and its memory while it is kept) is deleted_chief, agent_mux is default_chief, an id whose brain directory (its last 8 characters, lowercased) another Chief, live or deleted, already has is brain_home_taken, a participant with a bound connection (its current agent session) is participant_bound with the hint to stop that session first, anything else is not_an_agent_participant (operation.failed details.reason). At most 64 live Chiefs: too_many_chiefs. In the same write the daemon revokes every credential issued for the participant before the adopt; after it, only the Chief brain's credential binds (with a brain supervisor), or on a daemon without one a credential the person mints from a local terminal (conversation-agent-token) after the adopt.", + "The record gets create's defaults (tools spawn, tell, workspaces; self_edit avatar; no engine) and only the fields given; from the participant it takes only its id and, without display_name, its display name. Its conversations, history and credentials stay as they are.", + "The idempotency key is per caller, as for chief.profile.create: the same key with the same params answers the committed record with replayed true; other params under the key are idempotency.conflict. The committed write reaches the Chief brain supervisor and publishes chief-profile-changed as a create does." + ] + }, "chief.profile.avatar.get": { "class": "read", "idempotency": "forbidden", diff --git a/cmux-tui/spec/resource-operations-v2.md b/cmux-tui/spec/resource-operations-v2.md index eac6a4514bb5..4ff8864429ac 100644 --- a/cmux-tui/spec/resource-operations-v2.md +++ b/cmux-tui/spec/resource-operations-v2.md @@ -13,7 +13,7 @@ broker protocol. | Class | Count | Semantics | | --- | ---: | --- | | `read` | 86 | Reads state and forbids an idempotency key | -| `mutation` | 189 | Requires an idempotency key and returns a mutation result | +| `mutation` | 190 | Requires an idempotency key and returns a mutation result | | `stream_open` | 6 | Opens a connection-owned typed stream | | `connection_control` | 13 | Changes only connection-local state | @@ -31,7 +31,7 @@ correlation, and idempotency metadata. | `agent` | 6 | `agent.list`, `agent.message.list`, `agent.message.mark`, `agent.message.release`, `agent.message.send`, `agent.report` | | `browser` | 15 | `browser.activate`, `browser.attach`, `browser.back`, `browser.close`, `browser.forward`, `browser.get`, `browser.input.key`, `browser.input.mouse`, `browser.input.text`, `browser.input.wheel`, `browser.list`, `browser.navigate`, `browser.reload`, `browser.viewer.release`, `browser.viewer.resize` | | `browser_device` | 3 | `browser.devices.add_custom`, `browser.devices.list`, `browser.devices.remove_custom` | -| `chief` | 10 | `chief.engine.get`, `chief.engine.set`, `chief.profile.avatar.get`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.get`, `chief.profile.list`, `chief.profile.update`, `chief.stop` | +| `chief` | 11 | `chief.engine.get`, `chief.engine.set`, `chief.profile.adopt`, `chief.profile.avatar.get`, `chief.profile.avatar.set`, `chief.profile.create`, `chief.profile.delete`, `chief.profile.get`, `chief.profile.list`, `chief.profile.update`, `chief.stop` | | `client` | 8 | `client.cell_pixels.set`, `client.detach`, `client.get`, `client.list`, `client.metadata.update`, `client.sizing.release`, `client.sizing.set`, `origin.confirmation.issue` | | `closed` | 3 | `closed.delete`, `closed.list`, `closed.reopen` | | `conversation` | 8 | `conversation.draft`, `conversation.events`, `conversation.get`, `conversation.history`, `conversation.list`, `conversation.search`, `conversation.send`, `conversation.typing` | diff --git a/daemon/remote/cmd/cmuxd-remote/main.go b/daemon/remote/cmd/cmuxd-remote/main.go index a0b14b2a6109..0fcc69b065b4 100644 --- a/daemon/remote/cmd/cmuxd-remote/main.go +++ b/daemon/remote/cmd/cmuxd-remote/main.go @@ -170,7 +170,7 @@ func (d *rpcRequestDispatcher) dispatch(req rpcRequest) error { select { case <-d.ctx.Done(): - return d.ctx.Err() + return d.connectionError() case d.ptyAttachSlots <- struct{}{}: default: return d.server.frameWriter.writeResponse(rpcResponse{ @@ -217,10 +217,20 @@ func (d *rpcRequestDispatcher) dispatch(req rpcRequest) error { case <-reservationReady: return nil case <-d.ctx.Done(): - return d.ctx.Err() + return d.connectionError() } } +// connectionError reports the async failure that cancelled the connection +// (queued on asyncErrors before cancelConnection) rather than the bare +// cancellation, which a select can observe first. +func (d *rpcRequestDispatcher) connectionError() error { + if err := d.takeAsyncError(); err != nil { + return err + } + return d.ctx.Err() +} + func (d *rpcRequestDispatcher) registerPTYAttach(req rpcRequest) (context.Context, *rpcPTYAttachRequest, bool) { ctx, cancel := context.WithCancel(d.ctx) request := &rpcPTYAttachRequest{cancel: cancel} diff --git a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go index a705b0f52bf9..1494b713f93a 100644 --- a/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go +++ b/daemon/remote/cmd/cmuxd-remote/ws_pty_test.go @@ -1963,7 +1963,8 @@ func TestWebSocketPTYReplacedAttachmentCannotWriteInput(t *testing.T) { waitForHubSessionSize(t, hub, "sess-replace", 1, 90, 30, 5*time.Second) waitForHubPTYSize(t, hub, "sess-replace", 90, 30, 5*time.Second) - if err := newConn.Write(ctx, websocket.MessageBinary, []byte("printf 'SIZE:'; stty size; printf '%b\\n' '\\106\\122\\105\\123\\110\\137\\111\\116\\120\\125\\124'; exit\r")); err != nil { + // One builtin write: shell job-control warnings on stderr cannot split the marker. + if err := newConn.Write(ctx, websocket.MessageBinary, []byte("s=$(stty size); printf 'SIZE:%s\\n' \"$s\"; printf '%b\\n' '\\106\\122\\105\\123\\110\\137\\111\\116\\120\\125\\124'; exit\r")); err != nil { t.Fatalf("write fresh command: %v", err) } output := waitForBinaryContains(t, ctx, newConn, "FRESH_INPUT", 5*time.Second) diff --git a/docs/billing/ios-in-app-purchases.md b/docs/billing/ios-in-app-purchases.md index 7ee3da0f4eb4..7d908e5deb7d 100644 --- a/docs/billing/ios-in-app-purchases.md +++ b/docs/billing/ios-in-app-purchases.md @@ -19,6 +19,7 @@ All products are auto-renewable, monthly, in one subscription group so upgrades Product IDs are prefixed with the bundle ID, so the App Store app and the TestFlight beta app each own their products: - `.go.monthly`, `.pro.monthly`, `.max.monthly` +- Yearly, like the web prices (Pro $480/yr, Max $1,920/yr; Go is monthly only): `.pro.yearly`, `.max.yearly`. The account-token route lists them only to an app that sends `x-cmux-billing-periods: monthly,yearly`; every product entry carries `billingPeriod` (`monthly` | `yearly`). Older apps get monthly products only. Transactions and notifications map either ID to its plan; the grant runs until `expires_at`. Until the App Store yearly prices are set, a non-USD yearly charge is estimated at the monthly list price x 9.6. - App Store app: bundle `com.cmux.app` (Apple ID 6783338052). Beta: `dev.cmux.app.beta`. Dev builds use a local StoreKit configuration file. Go is listed only when the server reports it enabled (the Go PostHog flag), same as web. @@ -59,19 +60,29 @@ An Apple subscription in `active`, `grace_period`, or `billing_retry` (Apple kee Mechanism: the `cmuxPlan` mirror in Stack `clientReadOnlyMetadata` is the single entitlement. Every write of it (`syncProPlanMetadata` in Stripe fulfillment, read-time reconciliation, and the Apple flows) computes the highest of the active Stripe plan and the granting Apple plan, so neither source can clear or downgrade the other. Grants are time-checked on read (`expires_at`, `grace_period_expires_at`), so a missed notification cannot extend access. An operator `cmuxVmPlan` override still wins and is never rewritten. -Sandbox purchases are free, so in production deployments a Sandbox subscription grants a plan only for the App Store bundle `com.cmux.app` (App Review buys in the Sandbox against production). TestFlight purchases of the beta bundle are recorded but grant nothing in production, so the account-token route answers `purchases_unavailable` and lists no products to those builds. Other deployments grant every Sandbox purchase. `APPLE_IAP_SANDBOX_ENTITLEMENTS` (`all`, `none`, or a bundle list) overrides both. +Sandbox purchases are free, so in production deployments a Sandbox subscription grants a plan only for the App Store bundle `com.cmux.app` and only when the signed-in cmux account's primary email is verified and in `APPLE_IAP_SANDBOX_ACCOUNT_EMAILS` (comma-separated, case-insensitive). Every other TestFlight tester, and every TestFlight purchase of the beta bundle, is recorded but grants nothing, so the account-token route answers `purchases_unavailable` and lists no products to those builds. The grant check reads the email from the account when a Sandbox row would otherwise grant, so removing an email from the list ends that account's Sandbox grant on the next plan read. Other deployments grant every Sandbox purchase. `APPLE_IAP_SANDBOX_ENTITLEMENTS` (`all`, `none`, or a bundle list) overrides both, and then the email list is not read. + +The production list must contain the App Review demo account `cmux-rev-0e6ba145@mailinator.com` (App Review buys in the Sandbox against production; without it App Review sees no products and rejects the submission) and `lawrence@manaflow.ai`. Production value: `APPLE_IAP_SANDBOX_ACCOUNT_EMAILS=cmux-rev-0e6ba145@mailinator.com,lawrence@manaflow.ai`. Both accounts must exist in production with a verified email; verify the demo account through its public Mailinator inbox. ## Analytics (RevenueCat parity) -Server-side PostHog events, one per App Store Server Notification that changes subscriber-visible state (deduplicated by `notificationUUID`, timestamped at the notification's `signedDate`), keyed by the cmux user id: `subscription_started`, `subscription_renewed`, `subscription_plan_changed`, `subscription_cancel_scheduled` (auto-renew off), `subscription_resubscribed`, `subscription_billing_issue`, `subscription_grace_period_entered`, `subscription_expired`, `subscription_refunded`, `subscription_revoked`. Properties: `source` (`apple` | `stripe`), `plan_id`, `product_id`, `storefront`, `currency`, `price` (gross, local currency), `price_usd_estimate`, `net_usd_estimate` (commission 30%, or 15% after one year of paid service), `environment`, `original_transaction_id`. Apple events also carry `notification_type`, `notification_subtype`, and `auto_renew_product_id`. Mapping: `SUBSCRIBED` (`INITIAL_BUY` started, `RESUBSCRIBE` resubscribed), `DID_RENEW` renewed, `DID_CHANGE_RENEWAL_PREF` plan_changed (`UPGRADE` takes effect now, `DOWNGRADE` at renewal), `DID_CHANGE_RENEWAL_STATUS` `AUTO_RENEW_DISABLED` cancel_scheduled, `DID_FAIL_TO_RENEW` (`GRACE_PERIOD` grace_period_entered, otherwise billing_issue), `EXPIRED` expired, `REFUND` refunded, `REVOKE` revoked. `GRACE_PERIOD_EXPIRED`, `REFUND_REVERSED`, `PRICE_INCREASE`, `RENEWAL_EXTENDED`, auto-renew re-enabled, `CONSUMPTION_REQUEST`, and `TEST` update state (or are only recorded) without an event. The `price_usd_estimate` is exact for USD storefronts and the plan's US list price elsewhere. Stripe does not emit these events yet; it keeps its existing `cmux_billing_*` events. +Server-side PostHog events, one per App Store Server Notification that changes subscriber-visible state (deduplicated by `notificationUUID`, timestamped at the notification's `signedDate`), keyed by the cmux user id: `subscription_started`, `subscription_renewed`, `subscription_plan_changed`, `subscription_cancel_scheduled` (auto-renew off), `subscription_resubscribed`, `subscription_billing_issue`, `subscription_grace_period_entered`, `subscription_expired`, `subscription_refunded`, `subscription_revoked`. Properties: `source` (`apple` | `stripe`), `plan_id`, `product_id`, `billing_period` (`monthly` | `yearly`, Apple events), `storefront`, `currency`, `price` (gross, local currency), `price_usd_estimate`, `net_usd_estimate` (commission 30%, or 15% after one year of paid service), `environment`, `original_transaction_id`. Apple events also carry `notification_type`, `notification_subtype`, and `auto_renew_product_id`. Mapping: `SUBSCRIBED` (`INITIAL_BUY` started, `RESUBSCRIBE` resubscribed), `DID_RENEW` renewed, `DID_CHANGE_RENEWAL_PREF` plan_changed (`UPGRADE` takes effect now, `DOWNGRADE` at renewal), `DID_CHANGE_RENEWAL_STATUS` `AUTO_RENEW_DISABLED` cancel_scheduled, `DID_FAIL_TO_RENEW` (`GRACE_PERIOD` grace_period_entered, otherwise billing_issue), `EXPIRED` expired, `REFUND` refunded, `REVOKE` revoked. `GRACE_PERIOD_EXPIRED`, `REFUND_REVERSED`, `PRICE_INCREASE`, `RENEWAL_EXTENDED`, auto-renew re-enabled, `CONSUMPTION_REQUEST`, and `TEST` update state (or are only recorded) without an event. + +`subscription_entitlement_granted` is sent after a transaction is recorded and applied, from both the app-posted route and the notification, when that subscription is the plan the user now holds through Apple (so a Sandbox purchase that grants nothing sends none). Properties: `source` (`apple`), `plan_id`, `product_id`, `billing_period`, `environment`, `original_transaction_id`, `transaction_id`, `status`, `expires_at`; `distinct_id` is the cmux user id. The insert ID is `apple_entitlement:`, so the app post and the notification for one transaction count once, and each renewal counts again. The lifecycle events above come only from notifications, so a deployment whose App Store Server Notifications URL is missing records purchases with no lifecycle events; the grant event does not depend on notifications. The `price_usd_estimate` is exact for USD storefronts and the plan's US list price elsewhere. Stripe does not emit these events yet; it keeps its existing `cmux_billing_*` events. The cmux-admin app page `/subscriptions` (data from `GET /api/admin/subscriptions/analytics`) shows, filterable by source and plan: active subscriptions, MRR gross and net, new subscriptions, churn, refunds, billing-issue and grace counts, revenue by storefront, and monthly cohort retention. Sandbox data is excluded unless toggled. The iOS app sends client events (`ios_paywall_viewed`, `ios_purchase_started`, `ios_purchase_cancelled`, `ios_purchase_failed`, `ios_purchase_pending`, `ios_restore_started`, `ios_restore_completed`) through its existing analytics path (`/api/analytics/events`, which accepts only allowlisted `ios_`-prefixed names in `web/services/analytics/iosEventPolicy.ts`) so the paywall funnel joins the server lifecycle events. Properties: `entry_point` (`settings` | `cloud_upgrade`), `product_id`, `plan_id`, and `reason` on failures; `success` and `restored_count` on `ios_restore_completed`. +## Plan allowances + +App Store and web subscribers get the same allowances, each reset by its own subscription period (App Store: newest transaction's purchase date to expiry, extended by a grace period; a yearly subscription resets monthly from its start date). Go: 40 VM-hours, one running machine, two saved; always enforced. + +The Pro and Max allowances below are **off by default**: https://cmux.com/pricing promises no hour limits, so without `CMUX_VM_PLAN_BUDGETS=on` no Pro or Max user gets a compute-hour or disk limit and the 402 refusals never fire. With the switch on: Pro: 375 compute-hours and 160 GB of disk across saved machines. Max: 1,500 compute-hours and 640 GB. A compute-hour is 1 vCPU with 2 GB of memory running for one hour (a machine draws `max(vCPUs, memory GB / 2)` units). Create, resume, fork, and restore refuse with 402 `vm_compute_budget_reached` (with the reset date) when the compute-hours are used up, and create or disk growth refuses with 402 `vm_storage_budget_reached`; the minute job pauses running Pro and Max machines at the limit. Founder's Edition, operator plan grants, and Team have no allowance (an operator grant to a user who also holds a Pro or Max subscription is metered by that subscription). A machine is metered at its current recorded shape, so resizing a machine also re-rates its earlier runtime in the period. `CMUX_VM_PLAN_BUDGETS=on` turns them on. + ## Configuration -Server env: `APPLE_IAP_KEY_ID`, `APPLE_IAP_ISSUER_ID`, `APPLE_IAP_PRIVATE_KEY` (In-App Purchase `.p8` contents, for the App Store Server API), `APPLE_IAP_BUNDLE_IDS` (`com.cmux.app,dev.cmux.app.beta`), `APPLE_IAP_APP_APPLE_ID` (`6783338052`, required to verify Production payloads). The bundle IDs and app Apple ID default to these values. Optional: `APPLE_IAP_SANDBOX_ENTITLEMENTS` (see Entitlement), `APPLE_IAP_ONLINE_CHECKS` (`1`/`0`; OCSP revocation checks of Apple's certificates, on by default only when `VERCEL_ENV=production`). Apple's root certificates (G3 and G2) are bundled in `web/services/billing/apple/certs` and inlined in `rootCertificates.ts`; a test checks both against Apple's published SHA-256 fingerprints. +Server env: `APPLE_IAP_KEY_ID`, `APPLE_IAP_ISSUER_ID`, `APPLE_IAP_PRIVATE_KEY` (In-App Purchase `.p8` contents, for the App Store Server API), `APPLE_IAP_BUNDLE_IDS` (`com.cmux.app,dev.cmux.app.beta`), `APPLE_IAP_APP_APPLE_ID` (`6783338052`, required to verify Production payloads). The bundle IDs and app Apple ID default to these values. Optional: `APPLE_IAP_SANDBOX_ACCOUNT_EMAILS` and `APPLE_IAP_SANDBOX_ENTITLEMENTS` (see Entitlement), `APPLE_IAP_ONLINE_CHECKS` (`1`/`0`; OCSP revocation checks of Apple's certificates, on by default only when `VERCEL_ENV=production`). Apple's root certificates (G3 and G2) are bundled in `web/services/billing/apple/certs` and inlined in `rootCertificates.ts`; a test checks both against Apple's published SHA-256 fingerprints. Server env values: `APPLE_IAP_KEY_ID=976787J9XZ` (In-App Purchase key "cmux server IAP"), `APPLE_IAP_ISSUER_ID=94d76068-119d-45cd-8700-86eb7a1a6235`. The `.p8` is kept outside the repo; never commit it. diff --git a/docs/cloud-guest-upgrades.md b/docs/cloud-guest-upgrades.md index 06f4ce1721e3..82f45beb271b 100644 --- a/docs/cloud-guest-upgrades.md +++ b/docs/cloud-guest-upgrades.md @@ -109,6 +109,9 @@ else is image-only: design it so old machines keep working without it. The target is the cmux-tui build the default image bakes (the manifest's `cmuxTuiCommit`), so an upgraded machine runs what a new machine runs. +Machines created by fork, restore or a template boot the source's snapshot, +not the default image, so they keep its older cmux-tui and belong in the +inventory like any other running machine. 1. **Inventory.** List running machines and their owners (read-only): `select provider_vm_id, provider_metadata->>'cmuxTuiContract', image_version from cloud_vms where destroyed_at is null and provider = 'freestyle' and status = 'running'`. diff --git a/docs/mdm/com.manaflow.cmux.json b/docs/mdm/com.manaflow.cmux.json index 4c1b02656ad4..063bfcec7217 100644 --- a/docs/mdm/com.manaflow.cmux.json +++ b/docs/mdm/com.manaflow.cmux.json @@ -1461,6 +1461,12 @@ "title" : "Keep Location in Photos and Videos", "type" : "boolean" }, + "labs.classicHome" : { + "default" : false, + "description" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts.", + "title" : "Classic Home", + "type" : "boolean" + }, "labs.previewFeatures" : { "default" : false, "description" : "Unfinished surfaces, such as the agent session's coverage label and Pull requests view.", @@ -2843,6 +2849,28 @@ "title" : "Font Size", "type" : "number" }, + "ui.animations.bounce" : { + "default" : "none", + "description" : "None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce.", + "enum" : [ + "none", + "subtle" + ], + "title" : "Animation Bounce", + "type" : "string" + }, + "ui.animations.newTab" : { + "default" : true, + "description" : "A new tab grows into its place in the tab bar. Off shows it there at once.", + "title" : "Animate New Tabs", + "type" : "boolean" + }, + "ui.animations.newWorkspace" : { + "default" : false, + "description" : "A new workspace row opens in the sidebar. Off shows it there at once.", + "title" : "Animate New Workspaces", + "type" : "boolean" + }, "ui.animationSpeed" : { "default" : "fast", "description" : "ui.animationSpeed", diff --git a/docs/mdm/com.manaflow.cmux.plist b/docs/mdm/com.manaflow.cmux.plist index c1c6e9f50079..eaee73890684 100644 --- a/docs/mdm/com.manaflow.cmux.plist +++ b/docs/mdm/com.manaflow.cmux.plist @@ -1854,6 +1854,47 @@ pfm_type boolean + + pfm_default + + pfm_description + A new tab grows into its place in the tab bar. Off shows it there at once. + pfm_name + ui.animations.newTab + pfm_title + Animate New Tabs + pfm_type + boolean + + + pfm_default + + pfm_description + A new workspace row opens in the sidebar. Off shows it there at once. + pfm_name + ui.animations.newWorkspace + pfm_title + Animate New Workspaces + pfm_type + boolean + + + pfm_default + none + pfm_description + None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce. + pfm_name + ui.animations.bounce + pfm_range_list + + none + subtle + + pfm_title + Animation Bounce + pfm_type + string + pfm_description How panes are told apart. None draws no border or divider at all; dragging between panes still resizes them. Default: dividers inside the curved main pane, borders otherwise. @@ -4472,6 +4513,18 @@ pfm_type boolean + + pfm_default + + pfm_description + Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts. + pfm_name + labs.classicHome + pfm_title + Classic Home + pfm_type + boolean + pfm_default diff --git a/docs/mdm/managed-preferences.md b/docs/mdm/managed-preferences.md index ed4b6e555afd..21806994591e 100644 --- a/docs/mdm/managed-preferences.md +++ b/docs/mdm/managed-preferences.md @@ -105,6 +105,9 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `ui.animationSpeed` | string | `"fast"` | `fast`, `normal`, `off` | Animations | | `appearance.chromeStyle` | string | `"default"` | `default`, `card`, `card-outlined`, `flat`, `flat-sidebar-line`, `bare`, `panes`, `two-tone`, `floating` | Window Style. The look of the window: whether panes sit in one rounded card, and where lines are drawn. Separation, Sidebar Background and the sidebar border still win when you set them. | | `ui.paneAnimations` | boolean | `false` | | Animate Panes. Splits, closes, moves, zooms, column scrolls and screen switches slide into place. Off: they change at once. | +| `ui.animations.newTab` | boolean | `true` | | Animate New Tabs. A new tab grows into its place in the tab bar. Off shows it there at once. | +| `ui.animations.newWorkspace` | boolean | `false` | | Animate New Workspaces. A new workspace row opens in the sidebar. Off shows it there at once. | +| `ui.animations.bounce` | string | `"none"` | `none`, `subtle` | Animation Bounce. None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce. | | `layout.paneSeparation` | string | | `none`, `dividers`, `borders`, `cards` | Separation. How panes are told apart. None draws no border or divider at all; dragging between panes still resizes them. Default: dividers inside the curved main pane, borders otherwise. | | `layout.panePadding` | real | | 0 to 16 | Padding | | `layout.paneCornerRadius` | real | | 0 to 20 | Corner Radius | @@ -316,6 +319,7 @@ Files: `com.manaflow.cmux.plist` (ProfileManifests: iMazing Profile Editor, Prof | `notifications.attention.moveToTop` | boolean | `true` | | Move the Workspace to the Top. A notification moves its workspace to the top of the unpinned rows, unless you are looking at its pane. | | `notifications.mutedWorkspaces` | array | `[]` | | Muted Workspaces. Workspace ids whose notifications are silent. Mute a workspace from its sidebar row menu. | | `labs.previewFeatures` | boolean | `false` | | Show Preview Features. Unfinished surfaces, such as the agent session's coverage label and Pull requests view. | +| `labs.classicHome` | boolean | `false` | | Classic Home. Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts. | | `feed.github.enabled` | boolean | `false` | | Connect GitHub. Uses your gh login to read notifications and review requests on this Mac. Sign in with gh auth login first. | | `feed.github.pollIntervalSeconds` | real | `120` | 60 to 900 | Refresh Interval. Seconds between GitHub refreshes. Refresh in the Inbox runs immediately. | | `agentPane.links.outsideRoots` | string | `"open"` | `confirm`, `text`, `open` | Files Outside the Project. What a file link in a reply does when the file is outside the chat's folders. Keys and .env files never open. | diff --git a/docs/posthog/billing-attribution.md b/docs/posthog/billing-attribution.md index 1a8fe863c064..047351c94082 100644 --- a/docs/posthog/billing-attribution.md +++ b/docs/posthog/billing-attribution.md @@ -55,7 +55,7 @@ All billing events use the Stack user id as `distinct_id` (teams: `stack-team:` but is +person-less (`$process_person_profile: false`), so it creates no person. +PostHog does not link person-less events to groups: break those down by the +`stack_team_id` property. Funnels built on the old `stack-team:` person for +team checkouts must switch to the admin person or the group. diff --git a/docs/posthog/cloud-product-analytics.md b/docs/posthog/cloud-product-analytics.md index 031d86a0ce52..a758b1d448fb 100644 --- a/docs/posthog/cloud-product-analytics.md +++ b/docs/posthog/cloud-product-analytics.md @@ -30,8 +30,10 @@ insert id because each row is an action. There are two machine-id forms and they must not be joined blindly: * `cloud_vm_*` lifecycle events use the internal `cloud_vms.id` UUID. -* `cloud_vm_request` uses the provider VM id from the URL (`/api/vm//...`). - It is a client-facing name and is not the internal UUID. +* `cloud_vm_request` and `cloud_vm_provision` use the provider VM id from the + URL (`/api/vm//...`), `vm_id_kind: provider`. A collection route + (create, base open) has no URL id; once the request wrote a ledger row, + `vm_id` is that internal UUID and `vm_id_kind: ledger`. * CodeRouter uses `coderouter_vm_id`, the internal UUID, when a route token is bound to a Cloud VM. @@ -59,9 +61,40 @@ Common properties: `product: cloud_vm`, `ledger_event`, internal `vm_id`, | `cloud_vm_port_opened` | `vm.open_port` | `port` | | `cloud_vm_base_opened` | `vm.base.opened` | `generation` | | `cloud_vm_base_reset` | `vm.base.reset` | `generation` | - -Request telemetry (`cloud_vm_request`, schema 2, and -`cloud_vm_provision`, schema 3) carries the request machine name, billing +| `cloud_vm_ready` | `vm.created` | `time_to_ready_ms` (machine row insert to `running`), `origin`; insert id `cloud_vm_ready:` | + +`cloud_vm_usage_hourly` is the cost signal. The `vm-usage-analytics` cron +(minute 5 of every hour) sends one event per machine that ran during the +previous whole UTC hour, from `cloud_vm_runtime_intervals`: `running_seconds`, +`vcpu`, `memory_gib`, `disk_gib` (the reserved shape; +`resources_estimated: true` for legacy rows without a reservation marker), +`plan_id`, `hour_start`. Insert id `cloud_vm_usage_hourly::` +makes a rerun of the same hour idempotent. At most 5,000 machines per run. + +## Synthetic traffic + +The Cloud VM canary (`.github/workflows/cloud-vm-canary.yml`, every five +minutes) and the smoke, dogfood, stress and startup-bench scripts in +`web/scripts/cloud-vm/` create a throwaway account +`cmux--+...@manaflow.dev` per run, create a +machine and destroy it within a minute. In October 2026 that was about 95% of +`cloud_vm_created` users. Every Cloud VM event, `cloud_vm_ready`, +`cloud_vm_usage_hourly`, `cloud_vm_request`, `cloud_vm_provision` and +`cmux_account_signed_up` of such an account carries `is_synthetic: true`. +Exclude it with `properties.is_synthetic != true` (the property is absent on +real traffic). + +## Accounts + +`cmux_account_signed_up` is sent when the Stack `user.created` webhook is +accepted (insert id `cmux_account_signed_up:`): `signup_source` +(`oauth_`, `password`, `email_code`, `passkey`, `anonymous`, +`unknown`), `signup_client` (`server` for probe accounts, `anonymous`, else +`unknown`: Stack does not record which app started the sign-up), and +`is_anonymous`. Person `$set_once`: `cmux_signed_up_at`. + +Request telemetry (`cloud_vm_request`, schema 3, and +`cloud_vm_provision`, schema 4) carries the request machine name, billing scope, client fields and the `stack_team` group. Paywall and limit failures (`vm_requires_pro`, `vm_active_limit_exceeded`, `vm_access_requires_pro`, `vm_create_credits_insufficient`) can therefore be broken down by plan and diff --git a/docs/posthog/mac-product-analytics.md b/docs/posthog/mac-product-analytics.md new file mode 100644 index 000000000000..838c2ad2d896 --- /dev/null +++ b/docs/posthog/mac-product-analytics.md @@ -0,0 +1,190 @@ +# Mac product analytics (PostHog) + +The macOS app's activation, Cloud conversion and retention events. They go to +the main cmux project (`244066`), alongside `cmux_daily_active`, the web and +the server's `cmux_billing_*` and `cloud_vm_*` events. + +Source of truth: `ProductAnalyticsEvent` in +`Packages/macOS/CmuxProductAnalytics`. `ProductAnalyticsEventCatalogTests` pins +every name and property key. When you rename one, change this page and the +saved insights in the same PR. + +## Pipeline and privacy + +- `ProductAnalytics` enqueues each call and returns immediately. An off-main + actor deduplicates, merges the shared properties and hands events to the + PostHog SDK the app already embeds. The SDK owns batching, retries and the + on-disk queue that survives offline launches. No call site waits, and none + sits on the typing, terminal output or drag paths. +- Nothing is sent when **Settings > Send anonymous telemetry** is off, when the + managed `DisableTelemetry` policy is set, under XCTest, or in debug builds + without `CMUX_POSTHOG_ENABLE=1`. In those launches the emitter is disabled + and enqueues nothing. +- Properties are enums, counts, booleans, version strings and duration + buckets. Free-text values collapse to `other`. Events never carry terminal + content, paths, commands, prompts, hostnames, repository names, emails or + error messages. Agent session ids are used only for dedupe, in memory. + +## Identity + +| Concept | Value | +| --- | --- | +| `distinct_id` | Signed out: the install's anonymous PostHog id. Signed in: the Stack user id, the same id the server's billing and `cloud_vm_*` events use. | +| Identity owner | `PostHogIdentityObserver` (#20565) identifies on sign-in or restore and resets on sign-out. Product analytics does not call `identify` itself. After a reset, `PostHogAnalytics` restores the product context super properties. | +| Anonymous to signed in | The first `identify` from an anonymous state sends `$anon_distinct_id`, which merges pre-sign-in activity (first launch, first split, upgrade clicks) into the account. | +| Sign-out | `cmux_signed_out` is captured when sign-out starts, before the reset, so it joins the account. | +| `$groups.stack_team` | Not set by the Mac yet. A PostHog group lasts until `reset()`, so it belongs with the identity observer that owns reset (suggested on #20565). Server billing events already carry it. | + +Feature flags evaluate against the same distinct id, so a signed-in user's +rollout bucket follows their account across devices. + +## Shared properties + +These are sent on every event below. They are also registered as super +properties, so `cmux_daily_active`, `cmux_hourly_active` and +`cmux_cloud_vm_request` carry them too. + +| Property | Values | +| --- | --- | +| `app_version`, `app_build` | bundle version and build | +| `channel` | `stable`, `nightly`, `rc`, `dev` | +| `os_version` | macOS `major.minor` | +| `account_plan` | `free`, `paid`, `unknown` (signed out or not loaded) | +| `is_signed_in` | bool | +| `cloud_enabled` | bool | +| `workspaces_open` | `0`, `1`, `2_3`, `4_7`, `8_15`, `16_plus` (refreshed on app activation and when a workspace opens or closes) | +| `platform` | `cmuxterm` (existing super property) | + +Duration buckets: `lt_1s`, `1s_3s`, `3s_10s`, `10s_30s`, `30s_60s`, `1m_3m`, +`3m_10m`, `10m_60m`, `1h_24h`, `1d_7d`, `gt_7d`. + +## Events + +### Upgrade funnel + +| Event | Fires when | Properties | Answers | +| --- | --- | --- | --- | +| `cmux_upgrade_prompt_shown` | An Upgrade entrypoint becomes visible. Covers the sidebar footer badge, the Cloud welcome card on the Upgrade step, the Cloud panel's nudge, the free-access banner and the create-at-limit screen. At most once per source per launch. | `source` (`ProUpgradeSource` token), `plan` (offered plan) | Click-through rate per surface | +| `cmux_upgrade_entrypoint_opened` | Any Upgrade click (every `ProUpgradeSource`). This is the existing event name; the keys are unchanged plus `destination`. | `source`, `client: mac`, `plan`, `destination` (`pricing_page`, `checkout`) | Upgrade clicks by surface; the first funnel step | +| `cmux_upgrade_badge_dismissed` | The sidebar badge's X | `source` | Badge fatigue | +| `cmux_billing_checkout_started` / `_completed` / `_expired` | Server, see [billing-attribution.md](billing-attribution.md) | `checkout_source` = the same `source` token | Checkout opened, paid, or abandoned | +| `cmux_plan_activated` | The app first sees the signed-in account as paid after it was free, or within 7 days of an upgrade click | `upgrade_source` (last click's source or `none`), `since_upgrade_click` (bucket or `none`) | Plan active in the app; time from click to activation | +| `cmux_plan_deactivated` | Same account went from paid to free | none | Downgrade (churn) | + +### Sidebar Cloud button + +The cloud icon in the sidebar footer, between Help and Upgrade. + +| Event | Fires when | Properties | Answers | +| --- | --- | --- | --- | +| `cmux_cloud_button_shown` | The button becomes visible (Cloud available and the sidebar icon setting on). At most once per launch. | none | Reach, so clicks become a rate | +| `cmux_cloud_button_clicked` | A press | `action` (`open_cloud` when Cloud is on, `introduce_cloud` when it is off or signed out) | How often the button is the way into Cloud | + +An `introduce_cloud` press is followed by `cmux_cloud_welcome_shown` with +`trigger = sidebar_cloud_button`, then the card's button and closed events. +That connects the button to Upgrade, checkout and enable. + +### Cloud welcome card + +| Event | Fires when | Properties | Answers | +| --- | --- | --- | --- | +| `cmux_cloud_welcome_shown` | The "Introducing cmux Cloud" card opens | `trigger` (`launch_announcement`, `new_cloud_workspace`, `new_cloud_machine`, `sidebar_cloud_button`, `debug_menu`), `next_step` (`upgrade`, `enable`, `sign_in`, `open_cloud`) | Reach of the announcement and of the Cloud-off/signed-out gate | +| `cmux_cloud_welcome_button_clicked` | Upgrade, Enable, Sign in, Open Cloud, or Not now (including the card's × and Escape) | `trigger`, `next_step`, `button` | Which choice each trigger leads to | +| `cmux_cloud_welcome_closed` | Exactly once per shown card | `trigger`, `next_step`, `outcome` (a button or `window_closed`), `visible_for` | Outcome and dwell time | + +Enable's result is `cmux_cloud_enable_completed`. Upgrade continues as +`cmux_upgrade_entrypoint_opened` with `source = mac_cloud_welcome`. + +### Account + +| Event | Fires when | Properties | +| --- | --- | --- | +| `cmux_sign_in_started` | The user starts a sign-in (Sign In, pane sign-in, Switch Account) | none | +| `cmux_sign_in_succeeded` | That sign-in completes. A session restore does not count. | none | +| `cmux_sign_in_failed` | That sign-in fails, once per attempt | `reason` (`offline`, `network`, `timed_out`, `server`, `invalid_link`, `browser_unavailable`, `unauthorized`, `rejected`, `cancelled`, `rate_limited`) | +| `cmux_signed_out` | The account signs out (captured before the identity reset) | none | + +### Cloud + +| Event | Fires when | Properties | +| --- | --- | --- | +| `cmux_cloud_enable_completed` | An Enable attempt settles | `result` (`success`, `requires_paid_plan`, `sign_in_required`, `service_unavailable`, `unavailable`, `cancelled`) | +| `cmux_cloud_disabled` | Cloud turned off | none | +| `cmux_cloud_machine_create_started` | The New Machine sheet submits | `memory_gb` (absent for Base setup and forks, which send no size) | +| `cmux_cloud_machine_create_succeeded` | A create finishes, from any launcher | `duration` | +| `cmux_cloud_machine_create_failed` | A create fails | `error_class` (`plan_limit`, `auth`, `network`, `server`, `client`, `cancelled`), `error_code` (server token or `unknown`), `consecutive_failures` | +| `cmux_cloud_machine_deleted` | A machine delete request succeeds | none | +| `cmux_cloud_machine_attached` | A machine's attach endpoint request finishes | `success`, `latency`, `error_class` on failure | +| `cmux_cloud_workspace_created` | New Cloud Workspace finishes | `machine_choice` (`resolved`, `explicit`), `duration` | +| `cmux_cloud_plan_limit_hit` | A Cloud request is refused by a plan limit | `limit` (`requires_paid_plan`, `active_machines`, `machine_memory`, `resource_pool`, `resize_size`, `credits`, `team_billing`), `operation` (`create`, `resize`, `restore`, `fork`, `machine_action`, `other`) | + +The server's `cloud_vm_created` stays the authoritative machine count. The +client events add what the user saw: failures, latency, and the path taken. + +### Activation and engagement + +| Event | Fires when | Properties | +| --- | --- | --- | +| `cmux_activation_milestone_reached` | First time per install | `milestone` (`first_launch`, `first_workspace`, `first_split`, `first_browser_pane`, `first_agent_session`, `first_sign_in`, `first_upgrade_click`, `first_cloud_enable`, `first_cloud_machine`, `first_cloud_workspace`, `first_paid_plan`), `is_new_install`, `days_since_install` | +| `cmux_agent_session_started` | An agent's SessionStart hook. A session that outlived a relaunch is not started again. | `agent` (`claude_code`, `codex`, `opencode`, `gemini`, `cursor`, `pi`, `other`) | +| `cmux_agent_session_ended` | The SessionEnd hook of a session counted as started | `agent` | +| `cmux_feature_used` | First use per UTC day | `feature` (`command_palette`, `files_explorer`, `find`, `vault`, `feed`, `dock`, `cloud_sidebar`, `custom_sidebar`, `devices`, `cmux_ssh`, `browser_pane`, `split`) | +| `cmux_app_updated` | A launch runs a different version than the previous one | `direction` (`upgrade`, `downgrade`), `from_version`, `to_version` | +| `cmux_daily_active` | Existing: once per UTC day | existing properties plus the shared properties above | + +Installs that ran before this catalog shipped get `is_new_install = false` and +never report `first_launch`. Filter activation funnels to +`is_new_install = true`. Every launch opens a terminal, so there is no "first +terminal" milestone; `first_launch` covers it. Uninstall is not observable. +Churn signals are `cmux_cloud_disabled`, `cmux_signed_out`, +`cmux_plan_deactivated`, `consecutive_failures` on +`cmux_cloud_machine_create_failed`, `cmux_app_updated` with `direction = +downgrade`, and absence from `cmux_daily_active`. + +Existing events that are unchanged: `cmux_hourly_active`, +`cmux_cloud_vm_request`, `cmux_vm_size_upgrade_prompted`, `$exception`. + +## Insights to build + +1. **Activation funnel (new installs).** Steps are `cmux_activation_milestone_reached` + filtered to `milestone` in this order: `first_launch`, then + `first_split` or `first_agent_session`, then `first_sign_in`, then + `first_cloud_enable`, then `first_cloud_machine`. Filter + `is_new_install = true`, use a 14-day window, and break down by + `channel`. +2. **Cloud upgrade funnel by source.** Steps: + - `cmux_upgrade_prompt_shown` + - `cmux_upgrade_entrypoint_opened` + - `cmux_billing_checkout_started` + - `cmux_billing_checkout_completed` + - `cmux_plan_activated` + + Break down by `source` on the client steps, `checkout_source` on the + server steps and `upgrade_source` on `cmux_plan_activated`; all three carry + the same `ProUpgradeSource` token. The client and server steps join on the + Stack user id once the app is signed in. +3. **Sidebar Cloud button.** `cmux_cloud_button_clicked` per `cmux_cloud_button_shown`, by + `action`; then the funnel `cmux_cloud_button_clicked` (`action = introduce_cloud`), + `cmux_cloud_welcome_closed` (`trigger = sidebar_cloud_button`), `cmux_upgrade_entrypoint_opened` + (`source = mac_cloud_welcome`), `cmux_billing_checkout_completed`. +4. **Welcome card outcomes.** Count `cmux_cloud_welcome_closed` broken down by + `trigger` × `outcome`, plus the funnel `cmux_cloud_welcome_shown`, then + `cmux_cloud_welcome_button_clicked` (`button = upgrade`), then + `cmux_billing_checkout_completed`. +5. **Week-1 / week-4 retention by activation event.** Retention insight. The + start event is `cmux_activation_milestone_reached` for each `milestone` + (one series per milestone); the return event is `cmux_daily_active`. Use + weekly periods with week 1 and week 4 highlighted, and filter + `is_new_install = true`. +6. **Retention by feature.** Use `cmux_feature_used` broken down by `feature` + as the start event and `cmux_daily_active` as the return event. +7. **Cloud reliability.** `cmux_cloud_machine_create_failed` by `error_class`, + `cmux_cloud_plan_limit_hit` by `limit`, and + `cmux_cloud_machine_attached` by `latency`. +8. **Churn signals.** Weekly counts of `cmux_cloud_disabled`, + `cmux_signed_out`, `cmux_plan_deactivated`, and `cmux_app_updated` + (`direction = downgrade`). + +Known gap: someone who checks out signed out gets an anonymous Stack user at +checkout, so that checkout joins the app's events only through `source` +until they sign in to the app with that account. diff --git a/docs/presence-service.md b/docs/presence-service.md index ee8d6d05bef2..da189e5cf90a 100644 --- a/docs/presence-service.md +++ b/docs/presence-service.md @@ -128,6 +128,36 @@ pins** are never pruned, so a change to their shape is the one case that genuinely requires the versioned-record plus lazy-upgrade treatment. Most presence deploys can ship freely; only owner-pin schema changes need care. +## Cloud machine list sync (`vms`) + +The Mac machines list no longer needs to poll `GET /api/vm`. The web backend +publishes list-relevant `cloud_vms` writes to `POST /v1/sync/vms` on the +presence Worker (`workers/presence/src/syncVms.ts`), and the per-team +`TeamPresence` DO broadcasts them as `vms` sync deltas over the existing +`sync.hello` channel on `/v1/presence/subscribe`. Postgres stays the source of +truth: each op carries the row's `updated_at` as `sourceUpdatedAtMs`, the DO +rejects older ops, and every list read publishes a full `replace` (with its +observation time) that tombstones ids missing from the list, so the DO +converges without a backfill job and a machine created after the observation +survives. A republish of an unchanged row mints no rev. Until the first +`replace` lands, a DO holds only rows written since the collection shipped; +its `sync.snapshot` frames for `vms` carry `backfilled: false` (true after), +so the client applies such a snapshot upsert-only and keeps its REST list as +the initial truth instead of dropping machines the snapshot lacks. Other +collections never carry the field. The publisher on the web side is +`web/services/vms/presencePublisher.ts`: a decorator around the VM repository +re-reads a row after each list-relevant write and publishes it best-effort +(750 ms timeout, never failing the mutation). Request-scoped writers publish +after the response with `after()`; cron and reconcile writers, which run with +no request scope where Vercel would freeze detached work, await the bounded +publish inline before the write resolves. +The route is service-to-service only: Worker secret `VMS_PUBLISHER_SECRET` +must equal web `CMUX_PRESENCE_VMS_PUBLISHER_SECRET` (set once per Worker with +`wrangler secret put VMS_PUBLISHER_SECRET` on production and on the dev +instance); the body's `teamId` is the list scope (`ownerTeamId`) and is trusted +only behind that secret. No new Durable Object migration: the collection adds +`synced:vms:*` keys next to the existing sync key space. + ## Workspace viewing presence Workspace viewing is a separate protocol from device reachability. The source @@ -204,6 +234,24 @@ the first production deploy and dogfood. tag, best-effort, never disturbs the Mac. Every beat carries the full current attach-route set, a route change triggers one immediate out-of-cadence beat, and a clean quit sends a goodbye. +- **Mac Cloud machines list** (`Sources/Cloud/CloudVMSyncSubscriber.swift`, + owned by `MachinesPanelViewModel`): a second subscribe socket per visible + machines panel whose `sync.hello` asks only for `vms`, from the cursor and + epoch it last applied (0/0 on the first connect and after a team switch). + It sends the same `X-Cmux-Team-Id` header as `VMClient` + (`auth.resolvedTeamID`; omitted for a personal account without teams) and + fails closed on an account or team change, after which the panel + resubscribes under the new scope. Frames decode records with the REST list + parser (`VMClient.decodeListItem`) and merge into the rows in place: + upserts replace by id or insert in createdAt-descending order, tombstones + remove, a `backfilled: true` snapshot is the complete fleet, and an + unbackfilled snapshot is upsert-only. The stream never carries `limits`, so + a REST `GET /api/vm` still runs on panel show, wake, app activation, + network online and on every stream (re)connect; the routine poll stretches + from 45 s to 5 minutes while the stream is connected. Reconnect backoff is + `DeviceDirectory`'s (`1, 2, 5, 10, 30` s; at once after the 15-minute clean + close). The socket exists exactly while the panel polls (Cloud enabled, + signed in, visible, online). - **iOS** (`Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/PresenceClient.swift`): typed WebSocket subscribe client. `MobileShellComposite` owns the subscription (starts on sign-in, blanks and stops on sign-out, backoff diff --git a/docs/program-status.md b/docs/program-status.md index 9fcbc11ad2c5..d9b49ab7cc24 100644 --- a/docs/program-status.md +++ b/docs/program-status.md @@ -32,3 +32,10 @@ label such as "Needs permission". Program status rows are terminal state, not agent state: they do not change agent hibernation, agent detection, or desktop notifications. Use `cmux notify` or OSC 9/99/777 for notifications. + +One exception keeps agent rows honest. Claude Code reports its turns as `app=claude-code` +records. When such a `working` or `blocked` record ends (a `done`, `idle`, `error` or `clear` +report, a prompt start, or the process exiting) while that pane's Claude Code row still says +Running, and no Claude Code hook reports anything for 3 seconds, cmux marks the turn finished and +shows Idle. This covers turns that end without a Stop hook, such as an Esc interrupt or a Stop hook +that timed out. It never sends a notification, and it only moves a pane from Running to Idle. diff --git a/plans/cmux-next/action-surfaces.json b/plans/cmux-next/action-surfaces.json index a03e67058270..21d6b908c693 100644 --- a/plans/cmux-next/action-surfaces.json +++ b/plans/cmux-next/action-surfaces.json @@ -14165,6 +14165,34 @@ "title_key" : "action.browserDevice", "title_table" : "ActionCatalog" }, + { + "cli" : "offered", + "cli_name" : "browser fit-desktop", + "context_menu" : "offered", + "context_menus" : [ + "browserPage" + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "browserFitDesktop", + "main_menu" : null, + "mcp" : "offered", + "palette" : "offered", + "palette_section" : { + "id" : "category.browser", + "order" : 106, + "title" : "Browser and Viewers", + "title_key" : "category.browser", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Fit as 16:9 Desktop", + "title_key" : "action.browserFitDesktop", + "title_table" : "ActionCatalog" + }, { "cli" : "guiOnly", "cli_name" : "browser stop-vs-code-inline-server", @@ -22738,6 +22766,118 @@ "title_key" : "action.appearance.animationSpeed.off", "title_table" : "ActionCatalog" }, + { + "cli" : "offered", + "cli_name" : "settings toggle-new-tab-animations", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.newTab.toggle", + "main_menu" : null, + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Toggle New Tab Animations", + "title_key" : "action.appearance.animations.newTab.toggle", + "title_table" : "ActionCatalog" + }, + { + "cli" : "offered", + "cli_name" : "settings toggle-new-workspace-animations", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.newWorkspace.toggle", + "main_menu" : null, + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Toggle New Workspace Animations", + "title_key" : "action.appearance.animations.newWorkspace.toggle", + "title_table" : "ActionCatalog" + }, + { + "cli" : "offered", + "cli_name" : "settings use-bouncy-animations", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.bounce.subtle", + "main_menu" : null, + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Use Bouncy Animations", + "title_key" : "action.appearance.animations.bounce.subtle", + "title_table" : "ActionCatalog" + }, + { + "cli" : "offered", + "cli_name" : "settings use-animations-without-bounce", + "context_menu" : "noObject", + "context_menus" : [ + + ], + "default_aliases" : [ + + ], + "default_chord" : null, + "default_shortcut" : null, + "id" : "appearance.animations.bounce.none", + "main_menu" : null, + "mcp" : "systemChange", + "palette" : "offered", + "palette_section" : { + "id" : "category.settings", + "order" : 112, + "title" : "Settings and Help", + "title_key" : "category.settings", + "title_table" : "Localizable" + }, + "palette_suggested" : null, + "title" : "Use Animations Without Bounce", + "title_key" : "action.appearance.animations.bounce.none", + "title_table" : "ActionCatalog" + }, { "cli" : "offered", "cli_name" : "settings use-chromium-by-default", @@ -29752,9 +29892,22 @@ ] } }, + { + "enabled_when" : "can_perform", + "id" : "browserFitDesktop", + "kind" : "action", + "order" : 4, + "visible_when" : { + "debug_only" : false, + "feature" : null, + "requires" : [ + "browserFocused" + ] + } + }, { "kind" : "separator", - "order" : 4 + "order" : 5 }, { "children" : [ @@ -29916,7 +30069,7 @@ ], "id" : "browser.pageInfo", "kind" : "submenu", - "order" : 5, + "order" : 6, "title_from" : "action", "visible_when" : { "debug_only" : false, @@ -29930,7 +30083,7 @@ "enabled_when" : "can_perform", "id" : "showBrowserJavaScriptConsole", "kind" : "action", - "order" : 6, + "order" : 7, "visible_when" : { "debug_only" : false, "feature" : null, @@ -29943,7 +30096,7 @@ "enabled_when" : "can_perform", "id" : "inspectBrowserElement", "kind" : "action", - "order" : 7, + "order" : 8, "visible_when" : { "debug_only" : false, "feature" : null, @@ -29954,13 +30107,13 @@ }, { "kind" : "separator", - "order" : 8 + "order" : 9 }, { "enabled_when" : "can_perform", "id" : "browser.extensions.menu", "kind" : "action", - "order" : 9, + "order" : 10, "visible_when" : { "debug_only" : false, "feature" : null, @@ -29973,7 +30126,7 @@ "enabled_when" : "can_perform", "id" : "browser.extensions.manage", "kind" : "action", - "order" : 10, + "order" : 11, "visible_when" : { "debug_only" : false, "feature" : null, @@ -29986,7 +30139,7 @@ "enabled_when" : "can_perform", "id" : "browser.extensions.webStore", "kind" : "action", - "order" : 11, + "order" : 12, "visible_when" : { "debug_only" : false, "feature" : null, @@ -29999,7 +30152,7 @@ "enabled_when" : "can_perform", "id" : "browser.extensions.loadUnpacked", "kind" : "action", - "order" : 12, + "order" : 13, "visible_when" : { "debug_only" : false, "feature" : null, diff --git a/plans/cmux-next/actions.md b/plans/cmux-next/actions.md index 6c48e9963b8f..7ed063b664de 100644 --- a/plans/cmux-next/actions.md +++ b/plans/cmux-next/actions.md @@ -21,7 +21,7 @@ Every exemption names a `SurfaceExemption` reason. A descriptor may declare `sur `scripts/cmux-next/check-action-surfaces.sh` (about 30 s once built) runs `ActionSurfaceParityTests`, `ActionContractTests` and `CLISurfaceParityTests`. Over the whole finite catalog they check: every surface declared; placements and exemptions consistent (no placed-and-exempt, `devOnly` only on debug-only actions, MCP exemptions only on CLI verbs, choices only with menu choices, submenu parents exist); each target kind's menu shows every action on that kind (directly, through containment: a tab menu reaches its pane, or in an object-less menu); each generated menu shows exactly its placements (inside submenus too) and keeps at most 12 top-level rows, every folder holding two or more; unique CLI names and default shortcuts; every menu item runs its own action through the registry with the item's target and the user origin; every CLI verb runs its own handler by CLI name with the caller's origin; `action.list` reports every surface; `plans/cmux-next/action-surfaces.json` (the export the Rust CLI and MCP parity tests read) and the generated block below (counts, every menu in order, every exemption) are fresh; every table id names a catalog action. Rewrite the export with `CMUX_UPDATE_ACTION_SURFACES=1 swift test --filter ActionSurfaceParityTests`. -`action-surfaces.json` is the one catalog other clients (the Rust CLI, MCP, cmux-browser, GPUI) read. Each action row has: `id`, `cli_name`, the surface fields (`palette`, `cli`, `context_menu`, `context_menus`, `mcp`), `title` (English), `title_key` and `title_table` (the localization key and its `.xcstrings` table in CmuxNextActions; null when the key cannot be named: the key is `action.` by convention, else the one string whose English text is the title), `default_shortcut` (`{"key": "i", "modifiers": ["cmd"]}`, modifiers in the order ctrl, opt, shift, cmd, `"family": "digits"` for a 1 to 9 family; null when none) `default_chord` (two such objects, the leader layer; null when none), `default_aliases` (the binding table's extra default keys for the action, `KeyBindingDefaults`: `[{"keys": [shortcut...], "when": "" or null}]`, empty when none) and `palette_section` (`id`, `title_key`, `title_table`, English `title` and `order`: the command palette section the action lists under, from its category) and `main_menu` (the menu bar menu the action is in: `app`, `file`, `edit`, `view`, `server`, `window`, `help` or `debug`; null when none). `context_menus` names the Space chip's menu `space` (the `profile` menu context; `action.list` keeps `profile`). All fields are generated; `exportIsFresh` fails on any changed title, key or shortcut until the export is rewritten. +`action-surfaces.json` is the one catalog other clients (the Rust CLI, MCP, cmux-browser, GPUI) read. Each action row has: `id`, `cli_name`, the surface fields (`palette`, `cli`, `context_menu`, `context_menus`, `mcp`), `title` (English), `title_key` and `title_table` (the localization key and its `.xcstrings` table in CmuxNextActions; null when the key cannot be named: the key is `action.` by convention, else the one string whose English text is the title), `default_shortcut` (`{"key": "i", "modifiers": ["cmd"]}`, modifiers in the order ctrl, opt, shift, cmd, `"family": "digits"` for a 1 to 9 family; null when none) `default_chord` (two such objects, the leader layer; null when none), `default_aliases` (the binding table's extra default keys for the action, `KeyBindingDefaults`: `[{"keys": [shortcut...], "when": "" or null}]`, empty when none) and `palette_section` (`id`, `title_key`, `title_table`, English `title` and `order`: the command palette section the action lists under, from its category). All fields are generated; `exportIsFresh` fails on any changed title, key or shortcut until the export is rewritten. ## Known hand-composed menus @@ -41,17 +41,17 @@ Declare its placements (menu, group, rank) and whether the CLI offers it, or nam - `screen`: 65 - `tab`: 90 - `terminal`: 36 -- `browser`: 168 +- `browser`: 169 - `sidebar`: 75 - `notifications`: 21 - `agents`: 60 - `cloud`: 52 - `remote`: 11 -- `settings`: 80 +- `settings`: 84 -## Counts (962 actions) +## Counts (967 actions) -Palette 926, CLI verbs 520, right-click 499, MCP tools 449. +Palette 931, CLI verbs 525, right-click 500, MCP tools 450. ## Menus @@ -64,7 +64,7 @@ Palette 926, CLI verbs 520, right-click 499, MCP tools 449. - **workspaceGroup**: workspaceGroup.newWorkspace workspaceGroup.duplicate | workspaceGroup.rename workspaceGroup.togglePin [appearance] > (workspaceGroup.setColor > (workspaceGroup.color.grey workspaceGroup.color.blue workspaceGroup.color.red workspaceGroup.color.yellow workspaceGroup.color.green workspaceGroup.color.pink workspaceGroup.color.purple workspaceGroup.color.cyan workspaceGroup.color.orange) workspaceGroup.setIcon workspaceGroup.clearIcon) [options] > (workspaceGroup.markRead workspaceGroup.markUnread workspaceGroup.clearNotifications) | toggleFocusedWorkspaceGroupCollapsed [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll) | [move] > (workspaceGroup.moveUp workspaceGroup.moveDown workspaceGroup.moveToNewWindow workspaceGroup.moveToWindow workspaceGroup.moveToSpace workspaceGroup.addToSection) | workspaceGroup.editConfig workspaceGroup.copyID | [close] > (workspaceGroup.ungroup workspaceGroup.closeWorkspaces workspaceGroup.delete workspaceGroup.closeOthers) - **sidebarBackground**: newTab newBrowserWorkspace openFolder newWorkspaceGroup [new] > (workspace.newAtTop workspace.newAtBottom workspace.newOnMachine space.new reopenClosedWorkspace reopenPreviousSession | newCloudWorkspace remote.connect | sidebar.home.add sidebar.item.add sidebar.section.add) | [options] > (sidebar.workspaceTabs.toggle palette.enableMinimalMode palette.disableMinimalMode sidebar.density.compact sidebar.density.comfortable sidebar.density.expanded | sidebar.layout.reset) | [group] > (workspaceGroup.collapseAll workspaceGroup.expandAll sidebar.groupBy.none sidebar.groupBy.folder) | [sort] > (workspace.sortByName workspace.sortByLastUsed workspace.sortByDirectory) | sidebar.projects.hide sidebar.sections.showHidden | toggleSidebar appStore.show - **terminalSelection**: terminalCopy terminalPaste terminal.selectAll useSelectionForFind terminal.sendText | [split] > (splitRight splitDown splitLeft splitUp) [agent] > (palette.forkAgentConversationRight palette.forkAgentConversationNewTab palette.forkAgentConversationLeft palette.forkAgentConversationTop palette.forkAgentConversationBottom palette.forkAgentConversationNewWorkspace) | [options] > (clearScreenKeepScrollback resetTerminal terminal.clear) | toggleSplitZoom | palette.terminalOpenDirectory -- **browserPage**: browserBack browserForward browserReload browserHardReload browser.copyURL browser.openLocally | browserTheme[choices] [options] > (toggleBrowserFocusMode toggleBrowserDesignMode) | browserPrint | bookmark.addPage | toggleBrowserDeveloperTools [tools] > (palette.browserOpenDefault browserScreenshotPage browserScreenshotSection browserDevice | browser.pageInfo > (browser.pageInfo browser.pageInfo.connection browser.pageInfo.certificate browser.pageInfo.reenableCertificateWarnings | browser.pageInfo.setPermission browser.pageInfo.resetPermissions | browser.pageInfo.cookies browser.pageInfo.manageSiteData browser.pageInfo.deleteSiteData | browser.pageInfo.siteSettings browser.pageInfo.aboutThisPage) showBrowserJavaScriptConsole inspectBrowserElement | browser.extensions.menu browser.extensions.manage browser.extensions.webStore browser.extensions.loadUnpacked) +- **browserPage**: browserBack browserForward browserReload browserHardReload browser.copyURL browser.openLocally | browserTheme[choices] [options] > (toggleBrowserFocusMode toggleBrowserDesignMode) | browserPrint | bookmark.addPage | toggleBrowserDeveloperTools [tools] > (palette.browserOpenDefault browserScreenshotPage browserScreenshotSection browserDevice browserFitDesktop | browser.pageInfo > (browser.pageInfo browser.pageInfo.connection browser.pageInfo.certificate browser.pageInfo.reenableCertificateWarnings | browser.pageInfo.setPermission browser.pageInfo.resetPermissions | browser.pageInfo.cookies browser.pageInfo.manageSiteData browser.pageInfo.deleteSiteData | browser.pageInfo.siteSettings browser.pageInfo.aboutThisPage) showBrowserJavaScriptConsole inspectBrowserElement | browser.extensions.menu browser.extensions.manage browser.extensions.webStore browser.extensions.loadUnpacked) - **browserLink**: browser.link.openInNewTab browser.link.openInNewWindow browser.link.openInNewSpace browser.link.openInNewWorkspace browser.link.openInSplit openLinkInDefaultBrowser | browser.link.openInIncognitoWindow | browser.link.saveAs browser.link.copy browser.link.copyText - **browserImage**: browser.image.openInNewTab | browser.image.saveAs browser.image.copy browser.image.copyAddress - **browserSelection**: browser.selection.copy | browser.selection.search browser.selection.lookUp @@ -138,7 +138,7 @@ Palette 926, CLI verbs 520, right-click 499, MCP tools 449. **guiOnly** (20): `browser.media.show`, `browser.downloads.show`, `browser.profile.choose`, `browser.overflow.menu`, `browser.download.openBlockedSiteSettings`, `agentPane.continueIn`, `agentPane.switchModel`, `agentPane.createCheckpoint`, `agentPane.searchFiles`, `agentPane.copyLastCodeBlock`, `agentPane.archiveChat`, `agentPane.previousTurn`, `agentPane.nextTurn`, `agentPane.permission.allowOnce`, `agentPane.permission.allowChat`, `agentPane.permission.deny`, `agentPane.permission.expand`, `agentPane.permission.retry`, `agentPane.permission.revoke`, `agentPane.permission.refresh` -**noObject** (191): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `stats.show`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `music.open`, `music.stop`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` +**noObject** (195): `openSettings`, `newWindow`, `newIncognitoWindow`, `closeWindow`, `window.closeOthers`, `closeAllWindows`, `zoomWindow`, `selectNextWindow`, `selectPreviousWindow`, `minimizeWindow`, `toggleFullScreen`, `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything`, `globalSearch`, `palette.open`, `commandPalette`, `palette.openTaskManager`, `palette.sleepyMode`, `keepMacAwake`, `about`, `workspace.closeAllInAllWindows`, `manageLayouts`, `palette.findWork`, `workspace.importClassic`, `reopenClosedBrowserPanel`, `home.show`, `home.tab`, `home.channels`, `home.attachFiles`, `home.newMessage`, `home.invite`, `home.newChief`, `home.saveFlightRecording`, `palette.browserClearHistory`, `importFromBrowser`, `password.importCSV`, `passwords.open`, `palette.enableBrowser`, `palette.disableBrowser`, `browser.allowAgentWithExtensions`, `browser.importFromFiles`, `sidebar.profileMenu`, `browser.downloads.showFolder`, `toggleRightSidebar`, `switchRightSidebarToFiles`, `switchRightSidebarToFind`, `switchRightSidebarToSessions`, `switchRightSidebarToFeed`, `switchRightSidebarToDock`, `switchRightSidebarToMachines`, `palette.toggleMatchTerminalBackground`, `feed.show`, `showNotifications`, `markAllNotificationsRead`, `clearAllNotifications`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `palette.quickAgentChat`, `palette.startAgentFromAnyApp`, `palette.addHarness`, `stats.show`, `palette.openTerminalChatView`, `palette.launchClaudeTeams`, `palette.launchCodexTeams`, `palette.computerUse.setup`, `palette.computerUse.accessibility`, `palette.computerUse.screenRecording`, `agent.openSessionWorkspace`, `home.toggleChiefSettings`, `home.stopChief`, `chief.openMemoryInspector`, `agentActivity.open`, `computerUseStop`, `agent.harness.add`, `agent.harness.addFromRegistry`, `agent.harness.remove`, `agent.harness.restore`, `agent.harness.doctor`, `agent.harness.updateAll`, `newCloudMachine`, `cloudDiagnostics`, `openTeamPicker`, `palette.auth.signIn`, `palette.auth.signOut`, `palette.mobileConnect`, `accounts.show`, `accounts.refresh`, `accounts.reauthenticate`, `accounts.connect`, `accounts.remove`, `remote.openBrowserTab`, `remote.openLocalBrowserTab`, `reloadConfiguration`, `palette.openCmuxSettingsFile`, `palette.openGhosttySettings`, `ghostty.showDiagnostics`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.searchShortcuts`, `keybindings.open`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.checkForUpdates`, `updates.whatsNew`, `announcements.show`, `announcements.hide`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `palette.pro.upgrade`, `sendFeedback`, `help.showCrashLogs`, `help.featureFlags`, `help.documentation`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.customize`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar`, `recentlyFocused`, `recentlyClosed`, `history.commands`, `history.show`, `agentHistory.show`, `browserShowHistory`, `history.search`, `history.resumeAgentSession`, `history.reopen`, `history.open`, `history.clear`, `layout.undo`, `music.open`, `music.stop`, `bookmark.add`, `bookmark.import`, `bookmark.importFromBrowser`, `bookmark.export`, `appStore.showInstalled`, `app.hide`, `app.unhide`, `app.open`, `app.command.run`, `tasks.show`, `tasks.new`, `tasks.showMine`, `link.open`, `app.shareCmux`, `server.makeThisMacAServer`, `server.stopServing`, `server.addServer`, `server.showPanel`, `server.showHealth` **noTargetSurface** (81): `taskManager.killProcess`, `toggleChecklistItemComplete`, `canvasOverview`, `canvasTidy`, `canvasAlignLeft`, `canvasAlignRight`, `canvasAlignTop`, `canvasAlignBottom`, `canvasEqualizeWidths`, `canvasEqualizeHeights`, `canvasDistributeHorizontally`, `canvasDistributeVertically`, `simulatorHome`, `simulatorRotateLeft`, `simulatorRotateRight`, `simulatorToggleAppearance`, `simulatorToggleSoftwareKeyboard`, `file.open`, `home.archiveChief`, `tabGroup.deleteSaved`, `tabGroup.reopenSaved`, `screenGroup.reopenSaved`, `screenGroup.deleteSaved`, `resumeCommandSet`, `resumeCommandEdit`, `resumeCommandClear`, `saveFilePreview`, `toggleFileEditorWordWrap`, `filePreviewOpenWith`, `filePreviewOpenExternally`, `filePreviewRevealInFinder`, `diffViewerNextLine`, `diffViewerPreviousLine`, `diffViewerHalfPageDown`, `diffViewerHalfPageUp`, `diffViewerNextHunk`, `diffViewerPreviousHunk`, `diffViewerGoToBottom`, `diffViewerGoToTop`, `diffViewerSearch`, `diffViewerNextFile`, `diffViewerPreviousFile`, `palette.vscodeServeWebStop`, `palette.vscodeServeWebRestart`, `browser.openOnMachine`, `openDiffViewer`, `palette.openDirectoryDiffViewer`, `openMarkdownFile`, `fileEditorGotoLine`, `fileEditorReplace`, `fileEditorAction`, `browser.certificateWarning.proceed`, `browser.certificateWarning.goBack`, `browserProfile.openLink`, `fileExplorerOpenSelection`, `fileExplorerOpenSelectionFinderAlias`, `fileExplorerOpenInCmux`, `fileExplorerReveal`, `fileExplorerCopyPath`, `fileExplorerCopyRelativePath`, `fileExplorerOpenWith`, `vaultOpenSession`, `vaultResumeInNewWorkspace`, `vaultCopyResumeCommand`, `vaultOpenPullRequest`, `checklistEditItem`, `checklistMarkInProgress`, `checklistCompleteItem`, `checklistRemoveItem`, `checklistOpenAsPane`, `checklistAttachImages`, `agentPane.git.commit`, `agentPane.git.push`, `agentPane.searchChats`, `cloudExec`, `cloudDomainList`, `cloudPublicationList`, `markdownSave`, `markdownLink`, `markdownBack`, `markdownForward` @@ -160,7 +160,7 @@ Palette 926, CLI verbs 520, right-click 499, MCP tools 449. **endsApp** (4): `quit`, `quitKeepSessions`, `quitEndSessions`, `quitEndEverything` -**systemChange** (60): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `workspace.setRemoteLocalhost`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` +**systemChange** (64): `keepMacAwake`, `workspace.importClassic`, `palette.toggleMatchTerminalBackground`, `palette.enableMinimalMode`, `palette.disableMinimalMode`, `notifications.toggleBanners`, `notifications.dismissal.keystroke`, `notifications.dismissal.focus`, `notifications.dismissal.click`, `notifications.dismissal.explicit`, `notifications.dismissal.timeout`, `notifications.dismissal.never`, `agentPaneZoomIn`, `agentPaneZoomOut`, `agentPaneZoomReset`, `workspace.setRemoteLocalhost`, `palette.makeDefaultBrowser`, `palette.makeDefaultTerminal`, `palette.toggleSetting`, `palette.shortcutKeymap`, `palette.installCLI`, `palette.uninstallCLI`, `palette.restartSocketListener`, `palette.applyUpdateIfAvailable`, `palette.switchAppChannel`, `help.showCrashLogs`, `appearance.density.compact`, `appearance.density.comfortable`, `appearance.animationSpeed.fast`, `appearance.animationSpeed.normal`, `appearance.animationSpeed.off`, `appearance.animations.newTab.toggle`, `appearance.animations.newWorkspace.toggle`, `appearance.animations.bounce.subtle`, `appearance.animations.bounce.none`, `browser.defaultEngine.chromium`, `browser.defaultEngine.webkit`, `appearance.paneBorder.toggle`, `appearance.panePadding.toggle`, `appearance.paneCorners.toggle`, `layout.centerFocusedColumn.never`, `layout.centerFocusedColumn.always`, `layout.centerFocusedColumn.onOverflow`, `focusRing.toggle`, `focusRing.style.ring`, `focusRing.style.glow`, `focusRing.singlePane.toggle`, `appearance.paneBorderWidth.toggle`, `appearance.paneBorderColor.reset`, `appearance.titlebar.minimal`, `appearance.titlebar.standard`, `appearance.chromeStyle.default`, `appearance.chromeStyle.card`, `appearance.chromeStyle.cardOutlined`, `appearance.chromeStyle.flat`, `appearance.chromeStyle.flatSidebarLine`, `appearance.chromeStyle.bare`, `appearance.chromeStyle.panes`, `appearance.chromeStyle.twoTone`, `appearance.chromeStyle.floating`, `browser.hibernation.off`, `browser.hibernation.moderate`, `browser.hibernation.aggressive`, `layout.toggleStripScrollbar` Every action without a CLI verb is also no MCP tool, for the CLI's reason. diff --git a/plans/cmux-next/holder-protocol-v1.md b/plans/cmux-next/holder-protocol-v1.md new file mode 100644 index 000000000000..07fe3fa56975 --- /dev/null +++ b/plans/cmux-next/holder-protocol-v1.md @@ -0,0 +1,251 @@ +# Holder wire protocol v1 (frozen) + +Epic cx-t2sqm, bead cx-t2sqm.12. Design: `plans/cmux-next/pty-holder.md` +section 2. Status: spec. The recorded compatibility corpus and its runner +land with the `cmux-holder` crate (cx-t2sqm.15); this file is their source. + +A holder is a small process that owns one PTY (`--pty`) or one agent +harness's pipes (`--pipes`) and an output log. Daemons of every later +version speak this protocol to every v1 holder, forever. A holder is never +updated. + +## 1. Spawn + +The daemon execs `cmux-holder --pty|--pipes --bootstrap-fd 3` with an empty +environment. Fd 3 is a pipe that carries one bootstrap struct (same framing +as section 3, kind 0 BOOTSTRAP, daemon to holder only): +`token raw[32], registry_dir string, socket_dir string, install string, +session string, argv string-list, env string-list ("K=V"), cwd string, +cols u16, rows u16, px_w u16, px_h u16, ring_bytes u32, log_bytes u64, +orphan_grace_secs u32 (0 = none)`. + +The holder then, in this order: +1. calls `setsid()` and leaves the daemon's cgroup / launchd job / Job Object + (a stop of the daemon's unit must not stop it); on Linux calls + `prctl(PR_SET_DUMPABLE, 0)`; +2. takes the exclusive lease (section 8); +3. binds its socket; +4. spawns the child (pty: on a new PTY of the given size, as session leader + with the PTY as controlling terminal; pipes: three pipes, own process + group), with exactly `env` and `cwd`; +5. atomically renames its identity record into place (section 7); +6. writes one byte `0x01` to fd 3 and closes it. If step 4 fails it writes + `0x02`, then `errno i32`, writes no record and exits. + +## 2. Transport + +- One Unix domain stream socket per holder (Windows: one named pipe), path in + the holder's identity record (section 7). Socket mode 0600, directory 0700. +- Before reading a frame the holder checks the peer uid + (`getpeereid` / `SO_PEERCRED`; Windows: the pipe client's user SID) equals + its own. A different uid: close without a reply. +- At most 8 links that have not sent HELLO; each must send HELLO within 5 s + or is closed. A frame other than HELLO before HELLO, or a second HELLO on + one link, closes that link. +- At most one controller at a time (section 5.2); observers (HELLO with the + observer flag) may read WELCOME and close. + +## 3. Framing + +Every frame, both directions: + +| Offset | Size | Field | Notes | +| --- | --- | --- | --- | +| 0 | 4 | magic | ASCII `CMHL` | +| 4 | 2 | kind | u16 little endian (section 4) | +| 6 | 2 | flags | u16 LE; senders write 0 except the bits defined in section 4; receivers ignore unknown bits | +| 8 | 4 | len | u32 LE payload length, at most 1 MiB (1 048 576) | +| 12 | len | payload | kind-specific, little endian, no padding | + +A bad magic or a `len` above 1 MiB closes the link (the stream is lost). An +unknown `kind` from the daemon gets ERROR `UNKNOWN_KIND` and the link stays +up. A daemon ignores an unknown kind from a holder. Strings and byte strings +never exceed the frame; a holder truncates argv/env in WELCOME's record to +fit (the full record stays on disk). + +Strings are `u32 len + UTF-8 bytes`; byte strings are `u32 len + bytes`; +`u64` offsets count output bytes from the start of the holder's life. + +## 4. Kinds + +| Kind | Value | Dir | Payload | +| --- | --- | --- | --- | +| HELLO | 1 | d->h | `min u16, max u16, caps_wanted u64, token raw[32]`; flags bit 0 = observer (never becomes controller) | +| WELCOME | 2 | h->d | `version u16, caps_granted u64, state u8 (0 running, 1 exited), oldest u64, end u64, cols u16, rows u16, identity bytes` (the identity record, section 7) | +| ATTACH | 3 | d->h | `from_offset u64` (above `end`: ERROR `BAD_PAYLOAD`) | +| DATA | 4 | h->d | `offset u64, bytes` (at most 1 MiB - 16); flags bit 0 = stderr (pipes mode) | +| GAP | 5 | h->d | `requested u64, oldest u64` | +| DETACH | 6 | d->h | empty | +| WRITE | 7 | d->h | `bytes` (at most 1 MiB - 4) | +| RESIZE | 8 | d->h | `cols u16, rows u16, px_w u16, px_h u16`; 0 cols or rows: `BAD_PAYLOAD`; pipes mode: `NOT_SUPPORTED` | +| SIGNAL | 9 | d->h | `signal i32, target u8` (0 group, 1 leader) | +| ACK | 10 | d->h | `offset u64` | +| EXIT | 11 | h->d | `status_kind u8` (0 exited, 1 signaled), `code i64` (Unix exit status or signal; Windows u32 exit code), `end_offset u64, at_ms u64` | +| CLOSE | 12 | d->h | `grace_ms u32` | +| PING | 13 | both | `nonce u64` | +| PONG | 14 | both | `nonce u64` | +| ERROR | 15 | h->d | `request_kind u16, code u16, message string` | +| STATS | 16 | both | d->h empty; h->d `cpu_user_us u64, cpu_sys_us u64, max_rss_bytes u64` of the child's group as the kernel reports it to the holder without a scan (getrusage of reaped children, Linux cgroup, Windows job accounting); 0 when unknown | + +Error codes: 1 `UNKNOWN_KIND`, 2 `BAD_TOKEN`, 3 `NO_COMMON_VERSION`, +4 `NOT_CONTROLLER`, 5 `NOT_SUPPORTED`, 6 `BAD_PAYLOAD`, 7 `EXITED`. + +## 5. Session + +### 5.1 Handshake + +1. The daemon connects and sends HELLO first. The holder answers nothing + before HELLO. +2. Token mismatch (constant-time compare): ERROR `BAD_TOKEN`, then close. + (Only ERROR `BAD_TOKEN` and `NO_COMMON_VERSION` close the link; every + other ERROR keeps it.) +3. No common version in `[min, max]` and `[1, 1]`: ERROR + `NO_COMMON_VERSION` with the holder's range in the message, then close. +4. Otherwise WELCOME with `version = 1`, `caps_granted = caps_wanted & + holder_caps`. v1 holders grant 0. Bits are assigned in this file only and + never reused. + +### 5.2 Controller + +- The WELCOMEd non-observer link is the controller. A new valid HELLO on + another link replaces it: the old link gets ERROR `NOT_CONTROLLER` and is + closed; frames still queued on the old link are dropped. A daemon that + restarts therefore always wins. A daemon that receives `NOT_CONTROLLER` + does not reconnect by itself (another daemon owns the holder). +- Only the controller may send WRITE, RESIZE, SIGNAL, ACK and CLOSE. + +### 5.3 Output + +- The holder always reads the PTY (or both pipes) and appends to its log, + with or without a controller (decided 2026-10-14: drop oldest, never + block). It never blocks the child on the socket or + on ACK. The log is an in-memory ring (`ring_bytes`, default 8 MiB) + spilling to `.log` beside its record (`log_bytes`, default 64 MiB); + at the cap the oldest bytes are dropped and `oldest` moves up. ACK only + allows earlier trimming (bytes below an acked offset may go first). +- ATTACH `from`: if `from < oldest`, GAP `{from, oldest}` then DATA from + `oldest`; else DATA from `from`. Then live DATA in order until DETACH, a + new controller or the end. A controller that falls behind `oldest` while + streaming gets GAP in the stream, then DATA from the new `oldest`. +- pipes mode: stdout and stderr share one offset space in arrival order; + DATA flags bit 0 marks stderr bytes (decided 2026-10-14: one offset space, + not two offsets per frame). + +### 5.4 Input and control + +- WRITE bytes go to the PTY master (pty) or the harness stdin (pipes) in + order. A full PTY blocks the holder's writer, not its reader. +- SIGNAL target 0: `killpg(child_pgid, signal)`; 1: `kill(child_pid, + signal)`. The holder never signals any other process. +- CLOSE at t0: SIGHUP to the group (pty) or close stdin (pipes), and + SIGTERM to the group; at t0 + `grace_ms`, SIGKILL to the group. CLOSE + after the child exited acts as the final ACK (section 5.5). + +### 5.5 Exit + +When the child leader is reaped, the holder reads the PTY master until EIO +(pty) or both pipes until EOF or 2 s (pipes); the last byte read is +`end_offset`. It writes `.exit` (atomic, fsync, 0600, JSON: +`{"schema":1,"id":...,"status_kind":...,"code":...,"end_offset":...,"at_ms":...}`) +and sends EXIT to the controller, if any. After every ATTACH to an exited +holder, EXIT follows the last DATA. The holder stays (no timer) until an ACK +of `end_offset` or a CLOSE, then removes its socket, record and log and +exits; with `orphan_grace_secs` set it also leaves after that long with no +controller. The daemon deletes `.exit` after it has recorded the exit. + +### 5.6 Survival rules + +- The holder ignores SIGHUP, SIGINT, SIGQUIT, SIGTERM, SIGUSR1/2 and SIGPIPE, + except a service-manager stop, which acts like CLOSE with grace 2 s: on + macOS SIGTERM from PID 1 (launchd); on Linux SIGTERM from PID 1 or from + the `systemd --user` manager of the holder's own uid, identified by the + sender pid named in `$MANAGERPID` at spawn (no process read); on Windows + `CTRL_SHUTDOWN_EVENT` / `WM_ENDSESSION`. In a container whose PID 1 is not + a service manager, the daemon spawns with `--no-pid1-stop`. +- No orphan timer in normal installs. A DEV build may pass + `--orphan-grace-secs N`, written in the identity record. +- The holder never reads another process's state and never lists processes. + +## 6. Capabilities + +v1 defines no capability bits. Rules for later bits: +- A daemon must work with `caps_granted = 0` forever. +- A bit adds frames or payload fields only on links that granted it. +- Payload growth: new fields only at the end of a payload, only under a + granted bit; a v1 reader never sees them. + +## 7. Identity record + +`/.json`, written by the holder after it holds its lease and +has bound its socket (atomic rename, 0600, O_NOFOLLOW). JSON, fields only +grow, readers ignore unknown fields. `` is +`~/Library/Application Support/cmux/holders//` on macOS (the daily +`/tmp` cleaner removes files idle for 3 days), `$XDG_STATE_HOME/cmux/holders//` +on Linux. Sockets go to `/tmp/cmux-/holders//` (macOS) or +`$XDG_RUNTIME_DIR/cmux/holders//` (Linux; `/tmp/cmux-/...` +when that path would exceed 103 bytes). + +```json +{ + "schema": 1, + "id": "<32 hex>", + "mode": "pty", + "install": "<12 hex hash of bundle id + tag>", + "session": "", + "cwd": "/path", + "argv": ["/bin/zsh", "-l"], + "started_at_ms": 0, + "protocol": {"min": 1, "max": 1}, + "caps": 0, + "socket": "/tmp/cmux-501/holders//.sock", + "lease": "..live", + "orphan_grace_secs": null, + "holder_build": "" +} +``` + +The owner token is not in this record. The daemon keeps tokens in its own +store: macOS Keychain item `cmux holder tokens `; Linux a 0600 file +`$XDG_STATE_HOME/cmux/holders//tokens` (protects against other +users only, and against same-uid processes only when Yama +`ptrace_scope >= 1`). A holder whose token is lost cannot be controlled +again; the daemon lists it as "running, not reachable" and the user may end +it with SIGKILL from the UI (the only pid use, from the user's action). +`pid` fields are absent on purpose: no daemon finds or signals a holder by +pid. + +Threat model (decision D6, Lawrence 2026-10-14): the holder protects +against other users only. It checks the socket peer uid (section 2) and the +owner token, and nothing else: no code-identity, code-signature or audit +token check. A process of the same user is trusted the same way a shell of +that user is. + +## 8. Liveness and discovery + +- The holder holds an exclusive `flock` on `` (a path relative to the + record's directory) for its life, from before its record exists. A daemon + that gets the lock knows the holder is dead. Only a daemon that holds the + lease deletes the record, socket, log and stale `.live` files. +- Discovery = list `/*.json`, probe each lease, connect to each + live socket, HELLO. Never ps, `proc_listpids`, sysctl, `/proc` walks or pid + probes (`scripts/cmux-next/check-no-process-scan.py`). + +## 9. Compatibility corpus (with cx-t2sqm.15) + +`cmux-tui/crates/cmux-holder/corpus/v1/*.json`, one file per exchange: +`{name, steps:[{dir:"d"|"h", frame_hex}], notes}`. Required exchanges: + +1. handshake ok, caps 0; 2. bad token; 3. no common version (daemon offers +2..3); 4. unknown kind 999 -> ERROR, link kept; 5. attach from 0 then live +data; 6. attach below `oldest` -> GAP + data; 7. write + echo; 8. resize; +9. signal group SIGINT; 10. ack trims, re-attach below acked -> GAP; 11. close +-> EXIT; 12. controller replaced -> NOT_CONTROLLER; 13. pipes mode stderr +flag; 14. RESIZE in pipes mode -> NOT_SUPPORTED; 15. ping/pong; 16. observer HELLO +keeps the controller; 17. STATS; 18. attach to an exited holder -> DATA then +EXIT; 19. bootstrap with a failing exec -> 0x02 + errno, no record; 20. a +link silent past the 5 s HELLO deadline is closed. + +The runner replays each file twice in CI: the current daemon client against +a holder built from the newest commit, and against a holder built from the +oldest supported holder commit (pinned in `corpus/v1/HOLDER_PINS`). A +daemon-side change that breaks any file is refused. diff --git a/plans/cmux-next/motion.md b/plans/cmux-next/motion.md index a98dd744e15b..40dcc6b96b45 100644 --- a/plans/cmux-next/motion.md +++ b/plans/cmux-next/motion.md @@ -9,6 +9,71 @@ Cmd-Shift-D splits applied in one frame until 2026-10-10, when Leo asked for panes that grow from their edge (cx-f6i7); the sidebar is only shown or hidden; tab animations must be fast and smooth. +## Redesign (hq-1d, 2026-10-12) + +Lawrence: "new tab animation and new workspace animation needs to be redone +from first principles", "ensure there's option to toggle animations of +splits/new tabs", "better curves for animations of splits, think how apple +would do it, spring curves", "proper physics". Design: +`.cmux-scratch/motion-redesign/DESIGN.md` in hq (principles P1 to P7). + +- Physics: `SpringValue.step` is the closed-form damped harmonic + oscillator (under-, critically and over-damped), exact at any frame rate, + retargetable with its velocity. `SpringParameters(duration:bounce:)` is + SwiftUI's `Spring(duration:bounce:)`. +- One source (hq-1d D2, Lawrence: "decouple from swift app as much as + possible"): the tokens live in the spec's design-tokens.json `motion` + (springs, fades, loops, speed, settle thresholds, the structural set, the + bounce and the per-kind defaults). cmux-tui/crates/cmux-motion reads them + at build time (build.rs) and owns the closed-form step + (`SpringValue::solve`, `advance_spring`), the structural bounce rule + (`MotionPolicy::spring`) and the switches (`AnimationSwitches`, + `set_animation_switches`); cmux-app-ffi exposes them to macOS as + `cmux_motion_*` (include/cmux_motion_ffi.h). Until the app's FFI pin + carries that ABI, the Swift `SpringValue` keeps the same solution and + `scripts/cmux-next/motion-parity.py` compares its paths with + cmux-motion's (`debug.motion {action: "trajectory"}` vs the + `motion_trajectories` example). +- One clock: `MotionClock` is the one chrome animation client of a window's + `FrameScheduler`. The tab strips, the layout (panes, dividers, columns, + screens, drop zones) step on it with the same `dt` and it goes idle with + the last spring (`debug.motion` `display_links` is 0 at rest). +- Bounce: structural tokens (`move`, `appear`, `disappear`) are critically + damped by default (never past the target); `ui.animations.bounce` + "subtle" gives them bounce `motion.spring.subtleBounce` (0.15). +- Per kind, under `ui.animationSpeed`: `ui.paneAnimations` (pane layout, + rule 9) and `ui.animations.newWorkspace` off by default (Lawrence + 2026-10-14: no big animations by default), `ui.animations.newTab` on + (Lawrence 2026-10-14 after the instant build: keep the tab-open motion; a + new tab grows in at its slot on the `tabOpen` spring, response 0.1 s, + critically damped, visible end ~118 ms, the tabs right of it on the same spring). + Off applies that change in one step; true opts into its spring. cmux-motion + `MotionKind` / `AnimationSwitches` carry exactly these three keys + (`MotionKind::config_path`). Palette: Toggle New Tab / New Workspace + Animations, Use Bouncy Animations, Use Animations Without Bounce. +- Reduce Motion: no movement and no crossfade standing in for it; chrome + takes its new places in one step (the tab strip and sidebar crossfades are + gone). +- Splits (only with `ui.paneAnimations` on): every pane edge and divider of a split, a close or a released + divider springs together from rest (or from the release velocity, in + fractions of the travel), so the change is one spring on the divider + fraction. A pane that changes size keeps one host frame for the motion + (`SplitMotion.heldFrame`) and is clipped to the presented rect: content + lays out once. A released divider flung onto a snap within + `motion.divider.releaseProjection` of its velocity lands there. +- New tab (only with `ui.animations.newTab` on; off, every entry point shows + the tab in its final slot at once, and a closed tab's room closes at once + too; the store's tab takes its provisional stand-in's cell in place, so a + new tab is one element): Cmd-T, +, New Terminal Tab and the socket all grow the tab in its + slot with the strip's spring; what a New Tab page turns into takes the + page's cell in place (`TabStripView.expectReplacement(of:)`). +- New workspace: the row opens at its index and the rows below slide; the + whole-sidebar snap for Cmd-N is gone, and rows open only on animated + applies. +- Check (DESIGN P1): `debug.new_tab` frame_strip asserts content in one step + (`content_intermediate`, `blank`) and chrome continuous (`moving`, + `reversals`, `jumps`, `max_step`). Proof: `scripts/cmux-next/motion-proof.py`. + ## Rules 1. One module. Every animation asks `Motion` for a token. No module @@ -40,9 +105,10 @@ shown or hidden; tab animations must be fast and smooth. `Motion.animateTimed`, the token's visible-end duration with ease-out; AppKit starts a new one from the value on screen. 7. Reduce Motion (`NSWorkspace.accessibilityDisplayShouldReduceMotion`): - movement (position, size, scale, scroll) is instant; opacity changes stay - as a crossfade of at most 0.1 s, and hover and pressed fills are - instant; spinners and pulses stop and show a static glyph. + movement (position, size, scale, scroll) is instant, and a structural + change (tab, row, pane) is not replaced by a crossfade; other opacity + changes stay as a crossfade of at most 0.1 s, and hover and pressed fills + are instant; spinners and pulses stop and show a static glyph. 8. `ui.animationSpeed` in cmux.json: `"fast"` (default), `"normal"` (every time constant x1.5, about Apple's system pacing), `"off"` (every change in one frame, no crossfade, no loops). Palette actions: Use Fast Animations, @@ -79,11 +145,11 @@ nxmot (MacBook Pro, 120 Hz). | Token | Response / damping | Visible end | Rest | Used by | | --- | --- | --- | --- | --- | -| `move` | 0.20 / 0.90 | 192 ms | 250 ms | tab reflow and reorder, sidebar row moves, inserts (a row grows in its own slot) and removals (it shrinks in place), all on this one spring (cx-bqm6), pane and divider frames (ratio, equalize, unzoom), sidebar drag gap | +| `move` | 0.15 / 1.00 | 183 ms | 258 ms | tab reflow and reorder, sidebar row moves, inserts (a row grows in its own slot) and removals (it shrinks in place), all on this one spring (cx-bqm6), pane and divider frames (ratio, equalize, unzoom), sidebar drag gap | | `zoom` | 0.32 / 0.90 | 292 ms | 375 ms | a pane zooming to fill its screen: its terminal takes the full size once and the spring rect reveals it (Leo 2026-10-13) | | `exit` | 0.12 / 1.00 | 158 ms | 233 ms | the panes a zoom leaves fading out; `Motion.exit(nearness:)` gives the farthest (the 2x2 opposite corner) this and neighbours up to 25% longer, so the opposite corner is gone first (under 0.05 opacity by 100 ms) | -| `appear` | 0.18 / 0.90 | 175 ms | 225 ms | palette scale-in (from `Motion.panelOpenScale` 0.97 about the panel center), tab grow-in, tab group expand, sidebar show (timed equivalent), browser toolbar show, ghost card/inline morph | -| `disappear` | 0.15 / 0.90 | 142 ms | 200 ms | tab close (width to 0), group collapse, sidebar hide (timed equivalent), browser toolbar hide | +| `appear` | 0.135 / 1.00 | 167 ms | 233 ms | palette scale-in (from `Motion.panelOpenScale` 0.97 about the panel center), tab grow-in, tab group expand, sidebar show (timed equivalent), browser toolbar show, ghost card/inline morph | +| `disappear` | 0.11 / 1.00 | 133 ms | 200 ms | tab close (width to 0), group collapse, sidebar hide (timed equivalent), browser toolbar hide | | `settle` | 0.22 / 0.85 | 175 ms | 342 ms | release after drag: tab drop, drag ghost landing, sidebar row drop (carries pointer velocity; 0.8 pt overshoot on 200 pt) | | `scroll` | 0.22 / 0.90 | 208 ms | 267 ms | tab strip reveal, strip column reveal, wheel notch, trackpad fling snap | | `screen` | 0.22 / 0.90 | 208 ms | 267 ms | screen switch slide | @@ -129,16 +195,18 @@ not change. Loops keep their period (they show state, not transitions). ## Why these values -- Damping 0.9, not 1. A critically damped spring spends its last third - crawling through sub-pixel distances: at response 0.22 it covers 95% in - 167 ms but reaches its last visible pixel at 269 ms. Damping 0.9 - overshoots 0.05% (0.1 pt on 200 pt, invisible after pixel snapping) and - ends visibly at 200 ms. Apple's own `.snappy` and UIKit's default - interactive spring are under-damped for the same reason. +- Structural springs (`move`, `appear`, `disappear`) are critically damped + (hq-1d D1, 2026-10-13): a tab, row or pane never passes its target, as + Apple's default `Spring(duration:bounce: 0)`. The long sub-pixel tail of + critical damping is paid with a shorter response instead of a lower + damping: `move` 0.15 s ends visibly at 183 ms (120 Hz), inside the 200 ms + budget the GPUI motion tests hold (225 ms tolerance). `ui.animations.bounce` + "subtle" (0.15, damping 0.85) is opt-in. Release, scroll and overlay + springs keep 0.85 to 0.9. - Response 0.15 to 0.22 s. Apple's system defaults (`.smooth` / `.snappy` at 0.5 s perceptual duration, NSAnimationContext 0.25 s) suit consumer apps; a tab strip is used hundreds of times an hour. Our `move` ends - visibly at 192 ms, with a spring's continuity: an interrupted move + visibly at 183 ms, with a spring's continuity: an interrupted move retargets instead of restarting. - `scroll` is a spring with stiffness 800 (response 0.222 s) and 0.9 damping; damping 1 leaves a slow sub-pixel tail. diff --git a/plans/cmux-next/pty-holder.md b/plans/cmux-next/pty-holder.md index 527f49f36b79..996dca068162 100644 --- a/plans/cmux-next/pty-holder.md +++ b/plans/cmux-next/pty-holder.md @@ -103,6 +103,9 @@ and old holders keep running their own copy, pinned by its lock. ### 2.1 Holder wire protocol v1 (frozen) +Full spec (frame layouts, kind values, error codes, corpus): +`plans/cmux-next/holder-protocol-v1.md`. Summary: + Framing: `magic "CMHL" u32 | kind u16 | flags u16 | len u32 | payload`. Little endian. Max payload 1 MiB. Payloads are fixed binary structs plus byte strings; no JSON in the holder. @@ -122,7 +125,7 @@ strings; no JSON in the holder. | EXIT | h->d | wait status, end offset, time; also written to `.exit` | | CLOSE | d->h | end the child: SIGHUP/SIGTERM, then SIGKILL after `grace_ms` | | PING/PONG | both | liveness of the link only | -| ERROR | h->d | code (UNKNOWN_KIND, BAD_TOKEN, ...) for a request; link stays up | +| ERROR | h->d | code (UNKNOWN_KIND, BAD_TOKEN, ...) for a request; link stays up except after BAD_TOKEN / NO_COMMON_VERSION | Rules that keep it frozen: - A holder answers an unknown kind with ERROR UNKNOWN_KIND and keeps the link. @@ -147,9 +150,10 @@ replica). Agent hosts move the same way: new agents start under ## 3. Discovery and restore without scanning - At spawn the holder writes an identity record (atomic, 0600, O_NOFOLLOW): - schema, id, mode, owner install (`:`), session, cwd, argv, + schema, id, mode, install (12-hex hash of bundle id + tag), session, cwd, argv, start time, protocol min/max, capability bits, socket path, lease nonce. - pid fields are informational only and are never used to find or signal. + The record has no pid fields: nothing finds or signals a holder by pid. +- Today (cx-t2sqm.13 landed): host records live in `/terminal-hosts-/` (one per build, since each build has its own app session). Records may name a socket in the per-install directory `/tmp/cmux-th-//` or the legacy flat one; new hosts still bind flat so a rollback to an older build never strands a terminal, and a later build switches the bind (cx-t2sqm.18). Target for `cmux-holder`: - Records and sockets live in a per-install runtime directory (0700): `/cmux/holders//`, where the install id is a short hash (12 hex) of BuildIdentity's stable part (bundle id + tag), NOT the @@ -236,15 +240,12 @@ never infers an agent from a process name, argv, foreground group or tree. - The holder checks the peer uid (`getpeereid` / `SO_PEERCRED`) equals its own before HELLO, then the owner token (constant time). A wrong token gets ERROR BAD_TOKEN and the link is closed. -- Threat model: any process of the same uid, including an agent running in - another terminal, can read a 0600 file and connect to a 0600 socket. A - token stored in the readable registry therefore stops only other users. - To stop a same-uid process from typing into another terminal, the owner - token is NOT stored in the registry: the daemon keeps it in its own store - (Keychain on macOS, a 0600 file only the daemon reads on Linux), and the - holder also checks the peer's code identity on the connected socket - (`LOCAL_PEERTOKEN` audit token + code signature on macOS). That is - authentication of one connected peer, not a scan. +- Threat model (D6, Lawrence 2026-10-14): the holder protects against other + users only (socket peer uid + owner token). Any process of the same uid, + including an agent in another terminal, is trusted like a shell of that + user; there is no code-identity check. The owner token stays out of the + registry (the daemon keeps it in its own store) so a stale record never + hands it out. - Per-install directories keep DEV tags, NIGHTLY and Release apart; an install only lists its own directory. Cross-install adoption is an explicit user action (cx-14hn5), never automatic. @@ -280,3 +281,7 @@ never infers an agent from a process name, argv, foreground group or tree. - D5 (chief): peer checks that walk parent processes (control socket, computer-use helper) become a capability token the app gives its children. +- D6 (Lawrence): no code-identity check in the holder; it protects only + against other users (socket peer uid + owner token). +- Pipe mode uses one shared offset space; the holder drops the oldest output + and never blocks the child (chief, confirmed 2026-10-14). diff --git a/plans/cmux-next/spec-proposals/visuals/design-tokens.json b/plans/cmux-next/spec-proposals/visuals/design-tokens.json index 56077b5130ce..ee310e7e1b5f 100644 --- a/plans/cmux-next/spec-proposals/visuals/design-tokens.json +++ b/plans/cmux-next/spec-proposals/visuals/design-tokens.json @@ -2080,28 +2080,29 @@ "springs": { "move": { "value": { - "response": 0.2, - "dampingFraction": 0.9 + "response": 0.15, + "dampingFraction": 1 }, - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:11 (MotionTunables.springDefaults)", + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", "unit": "s", - "use": "Tab reflow and reorder, sidebar row moves, pane and divider frames" + "use": "Tab reflow and reorder, sidebar row moves, pane and divider frames", + "note": "Structural (motion.structural): critically damped unless motion.bounce is subtle. Response 0.15 s ends visibly (within 0.5%) at 0.177 s, under the 0.2 s budget (hq-1d D1)." }, "appear": { "value": { - "response": 0.18, - "dampingFraction": 0.9 + "response": 0.135, + "dampingFraction": 1 }, - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:12 (MotionTunables.springDefaults)", + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", "unit": "s", "use": "Palette scale-in, tab grow-in, group expand, row insert, sidebar show" }, "disappear": { "value": { - "response": 0.15, - "dampingFraction": 0.9 + "response": 0.11, + "dampingFraction": 1 }, - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:13 (MotionTunables.springDefaults)", + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", "unit": "s", "use": "Tab close, group collapse, row removal, sidebar hide" }, @@ -2158,11 +2159,150 @@ "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:19 (MotionTunables.springDefaults)", "unit": "s", "use": "Hover card slide" + }, + "pinDrag": { + "value": { + "response": 0.3512407365520363, + "dampingFraction": 0.8385254915624211 + }, + "source": "cmux-tui/crates/cmux-motion (MotionSpring::PinDrag): MessagesLab SidebarPinDragging.spring, CASpringAnimation mass 1, stiffness 320, damping 30", + "unit": "s", + "use": "Home list pin drag: tiles making room, rows below the grid, a dropped tile landing" + }, + "zoom": { + "value": { + "response": 0.32, + "dampingFraction": 0.9 + }, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", + "unit": "s", + "use": "A pane zooming to fill its screen: its content takes the full size once and the spring rect reveals it (Leo 2026-10-13)" + }, + "exit": { + "value": { + "response": 0.12, + "dampingFraction": 1 + }, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", + "unit": "s", + "use": "The panes a zoom leaves fading out; Motion.exit(nearness:) lengthens it up to 25% for nearer panes, so the farthest (the 2x2 opposite corner) is gone first" + }, + "tabOpen": { + "value": { + "response": 0.1, + "dampingFraction": 1 + }, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.springDefaults); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", + "unit": "s", + "use": "A new tab growing in at its final slot and the tabs right of it making room, on one spring (ui.animations.newTab, on by default; Lawrence 2026-10-14: the tab-open motion of 10-11, about 7 frames / 115 ms). Visible end 118 ms." } }, + "tweens": { + "description": "Tab strip tweens (Chrome's model): a value goes from where it is on screen to its target over duration with the curve; a new target restarts the duration from the current value (no velocity carry), an unchanged target does not restart. easeOutQuad = 1-(1-t)^2 = cubic-bezier(1/3, 2/3, 2/3, 1) (gfx::Tween::EASE_OUT, Chromium ui/gfx/animation/tween.cc L72-73). Reduce Motion and speed off: 0 s; speed normal x1.5.", + "tabOpen": { + "value": { + "duration": 0.2, + "curve": "easeOutQuad" + }, + "source": "Chromium main 6d3c98f4fe5 tab_container_impl.cc L1229-1256 (StartInsertTabAnimation), bounds_animator.h L205-207 (200 ms EASE_OUT); cmux-tui/crates/cmux-motion reads this file at build time (build.rs)", + "unit": "s", + "use": "A new tab starts at the previous tab's right edge minus the overlap (18 pt) with width 18 and tweens to its slot; the tabs right of it and the + button move on the same tween (ui.animations.newTab; Lawrence 2026-10-14: match Chrome)." + }, + "tabClose": { + "value": { + "duration": 0.2, + "curve": "easeOutQuad" + }, + "source": "Chromium main 6d3c98f4fe5 tab_container_impl.cc L1258-1306, L1348-1377 (StartRemoveTabAnimation)", + "unit": "s", + "use": "A closing tab shrinks to the overlap width (18 pt) with its left edge pinned while the others move to their slots (ui.animations.newTab); also the resize when mouse closing mode ends." + }, + "tabMove": { + "value": { + "duration": 0.2, + "curve": "easeOutQuad" + }, + "source": "Chromium main 6d3c98f4fe5 tab_container_impl.cc L1170-1175 (retarget), dragging_tabs_session.cc (reorder neighbours, release)", + "unit": "s", + "use": "Tabs moving to a new slot in a reorder, and a dragged tab settling into its slot on release. A gesture: not under ui.animations.newTab." + } + }, + "tabStrip": { + "value": { + "compact": { + "maxWidth": 200, + "openStartWidth": 18, + "closeEndWidth": 18, + "minActiveWidth": 56, + "minInactiveWidth": 32, + "pinnedWidth": 64, + "reorderThreshold": 16, + "closingMode": { + "belowPad": 40, + "trailingPad": 60, + "exitDelay": 0.3 + } + }, + "comfortable": { + "maxWidth": 200, + "openStartWidth": 18, + "closeEndWidth": 18, + "minActiveWidth": 56, + "minInactiveWidth": 32, + "pinnedWidth": 64, + "reorderThreshold": 16, + "closingMode": { + "belowPad": 40, + "trailingPad": 60, + "exitDelay": 0.3 + } + } + }, + "unit": "pt (exitDelay s)", + "source": "Chromium main 6d3c98f4fe5 (hq-4e spec .cmux-scratch/chrome-tab-motion/SPEC.md): tab_container_impl.cc L1229-1306 (open from the previous right edge minus the 18 DIP overlap, close to the overlap width), L716-741 + L1418-1474 + mouse_watcher.cc L26 (closing mode), dragging_tabs_session.cc L89-94 (reorder threshold 16 DIP x width/256), tab_style.cc (min 56 active, 32 inactive, pinned 64). Lawrence 2026-10-14 \"keep 200\": max width stays 200 (threshold 16 x width/200). cmux-tui/crates/cmux-motion reads this at build time (TabStripGeometry); macOS reads it through cmux-app-ffi.", + "use": "Tab strip geometry for the Chrome motion. maxWidth is the default of appearance tabMaxWidth (metrics.tabMaxWidth); a new tab starts openStartWidth wide at the previous tab's right edge minus openStartWidth; a closing tab ends closeEndWidth wide with its left edge pinned; crowding shrinks every tab evenly from maxWidth to minActiveWidth, then the inactive ones to minInactiveWidth (then the strip scrolls); a dragged tab reorders past a neighbour after reorderThreshold x width / maxWidth; mouse closing mode ends belowPad under / trailingPad after the strip plus exitDelay. comfortable = compact (hq-1d 2026-10-15: keep 200 has no density exception; open question for Lawrence whether the taller comfortable row wants other numbers)." + }, "springModel": { - "value": "omega = 2*pi/response; stiffness = omega^2; damping = 2*dampingFraction*omega; semi-implicit Euler at 1/480 s (SwiftUI .spring(response:dampingFraction:) equivalent)", - "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift:18-22,79-96 (SpringParameters.omega)" + "value": "omega = 2*pi/response; stiffness = omega^2; damping = 2*dampingFraction*omega (SwiftUI .spring(response:dampingFraction:), mass 1). Stepped in closed form from the current displacement AND velocity (exact damped harmonic oscillator, under-, critically or over-damped), so the path is identical at every frame rate; dt is capped at 0.1 s per frame. Retargeting keeps value and velocity. A spring rests (snaps to its target) once |value - target| < epsilon and |velocity| < epsilon * motion.settle.velocityFactor.", + "source": "cmux-tui/crates/cmux-motion/src/spring.rs (SpringValue::solve); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/SpringParameters.swift (SpringValue.solve)" + }, + "settle": { + "value": { + "geometry": 0.25, + "unit": 0.001, + "velocityFactor": 8 + }, + "source": "cmux-tui/crates/cmux-motion/src/spring.rs (GEOMETRY_EPSILON, UNIT_EPSILON, SpringValue::is_settled)", + "unit": "pt (geometry), fraction (unit)", + "use": "Rest rule: 0.25 pt is invisible after pixel snapping at 2x; 0..1 values (opacity) use 0.001" + }, + "structural": { + "value": [ + "move", + "appear", + "disappear", + "tabOpen" + ], + "source": "cmux-tui/crates/cmux-motion (MotionSpring::is_structural); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionKinds.swift (MotionSpring.isStructural)", + "use": "Springs that take or give up room. They follow motion.bounce; release, scroll and overlay springs keep their own damping." + }, + "bounce": { + "value": { + "none": 0, + "subtle": 0.15, + "default": "none" + }, + "source": "ui.animations.bounce (cmux.json); cmux-tui/crates/cmux-motion (MotionBounce); Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.subtleBounce)", + "use": "SwiftUI bounce of the structural springs: none = critically damped (never passes the target), subtle = damping fraction 1 - 0.15 (overshoots about 1%)." + }, + "kinds": { + "value": { + "paneAnimations": false, + "newTab": true, + "newWorkspace": false + }, + "source": "ui.paneAnimations / ui.animations.newTab / ui.animations.newWorkspace (cmux.json); cmux-tui/crates/cmux-motion (AnimationSwitches, MotionKind::config_path)", + "use": "Per-kind switches under ui.animationSpeed, paneAnimations and newWorkspace off by default (Lawrence 2026-10-14: no big animations by default), newTab on (Lawrence 2026-10-14: keep the tab-open motion); off applies the change in one frame (no crossfade). paneAnimations: split grow-in, close, move, swap, zoom and unzoom, column reveal, screen switch, divider release. newTab: a tab growing into its slot. newWorkspace: a sidebar row opening." }, "fades": { "hover": { @@ -2171,6 +2311,12 @@ "unit": "s", "use": "Hover fills, hover-revealed buttons, divider hover" }, + "hoverOut": { + "value": 0, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.fadeDefaults)", + "unit": "s", + "use": "Hover and pressed fills clearing: 0 is instant" + }, "focus": { "value": 0.1, "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:32 (MotionTunables.fadeDefaults)", @@ -2218,6 +2364,38 @@ "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift:39 (MotionTunables.fadeDefaults)", "unit": "s", "use": "Launch mark resolving on the glass of a window still connecting (Motion.revealLaunchMark); under 400 ms at normal speed" + }, + "clickPulse": { + "value": 0.25, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.fadeDefaults)", + "unit": "s", + "use": "Agent cursor click ripple grows and fades" + }, + "cursorHide": { + "value": 0.3, + "source": "Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift (MotionTunables.fadeDefaults)", + "unit": "s", + "use": "Agent cursor fades out once its agent goes idle" + }, + "pinLift": { + "value": 0.15, + "source": "cmux-tui/crates/cmux-motion (MotionFade): MessagesLab SidebarPinDragging.swift", + "unit": "s", + "use": "Home list pin drag: the tile lifting (linear)", + "curve": "linear" + }, + "pinSettle": { + "value": 0.25, + "source": "cmux-tui/crates/cmux-motion (MotionFade): MessagesLab SidebarPinDragging.swift", + "unit": "s", + "use": "Home list pin drag: a dropped tile scaling back (linear)", + "curve": "linear" + }, + "pinShrink": { + "value": 0.22, + "source": "cmux-tui/crates/cmux-motion (MotionFade): MessagesLab SidebarPinDragging.swift", + "unit": "s", + "use": "Home list pin drag: a ghost shrinking onto its row avatar" } }, "loops": { diff --git a/schemas/defaults/cmux-next.json b/schemas/defaults/cmux-next.json index 3010f245e083..e9f8e9a9e6d0 100644 --- a/schemas/defaults/cmux-next.json +++ b/schemas/defaults/cmux-next.json @@ -365,6 +365,7 @@ "setting.history.commandRetentionDays": 30, "setting.history.terminalCommands": false, "setting.home.attachments.keepLocation": false, + "setting.labs.classicHome": false, "setting.labs.previewFeatures": false, "setting.layout.centerFocusedColumn": "never", "setting.layout.closeFocus": "previousNeighbor", @@ -534,6 +535,9 @@ "setting.terminal.fontFamily": null, "setting.terminal.fontSize": null, "setting.ui.animationSpeed": "fast", + "setting.ui.animations.bounce": "none", + "setting.ui.animations.newTab": true, + "setting.ui.animations.newWorkspace": false, "setting.ui.paneAnimations": false, "setting.updates.checkAutomatically": true, "setting.updates.checkIntervalSeconds": 3600, diff --git a/schemas/settings/settings-schema.json b/schemas/settings/settings-schema.json index f95ee79ef08a..51d157a62316 100644 --- a/schemas/settings/settings-schema.json +++ b/schemas/settings/settings-schema.json @@ -247,7 +247,10 @@ "appearance.focusIndicator", "focus.inactiveTabStyle", "ui.animationSpeed", - "ui.paneAnimations" + "ui.paneAnimations", + "ui.animations.newTab", + "ui.animations.newWorkspace", + "ui.animations.bounce" ], "title" : { "key" : "settings.group.densityMotion", @@ -907,6 +910,7 @@ "key" : "settings.group.labs", "rows" : [ "labs.previewFeatures", + "labs.classicHome", "appearance.experimentalControls" ], "title" : { @@ -10146,6 +10150,164 @@ }, "validation" : "portable" }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : true, + "default_label" : null, + "group" : { + "key" : "settings.group.densityMotion", + "text" : "Density and Motion" + }, + "help" : { + "key" : "settings.ui.animations.newTab.help", + "text" : "A new tab grows into its place in the tab bar. Off shows it there at once." + }, + "kept_on_reset_all" : false, + "key" : "ui.animations.newTab", + "keywords" : [ + "motion", + "animation", + "tab" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "ui", + "animations", + "newTab" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "appearance", + "summary" : null, + "title" : { + "key" : "settings.ui.animations.newTab", + "text" : "Animate New Tabs" + }, + "validation" : "portable" + }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : false, + "default_label" : null, + "group" : { + "key" : "settings.group.densityMotion", + "text" : "Density and Motion" + }, + "help" : { + "key" : "settings.ui.animations.newWorkspace.help", + "text" : "A new workspace row opens in the sidebar. Off shows it there at once." + }, + "kept_on_reset_all" : false, + "key" : "ui.animations.newWorkspace", + "keywords" : [ + "motion", + "animation", + "workspace", + "sidebar" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "ui", + "animations", + "newWorkspace" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "appearance", + "summary" : null, + "title" : { + "key" : "settings.ui.animations.newWorkspace", + "text" : "Animate New Workspaces" + }, + "validation" : "portable" + }, + { + "accepts" : [ + "none", + "subtle" + ], + "agent_refusal" : null, + "agent_settable" : true, + "choices" : [ + { + "title" : { + "key" : "settings.choice.none", + "text" : "None" + }, + "value" : "none" + }, + { + "title" : { + "key" : "settings.choice.subtle", + "text" : "Subtle" + }, + "value" : "subtle" + } + ], + "consumers" : [ + "cmux-next" + ], + "default" : "none", + "default_label" : null, + "group" : { + "key" : "settings.group.densityMotion", + "text" : "Density and Motion" + }, + "help" : { + "key" : "settings.ui.animations.bounce.help", + "text" : "None stops tabs, rows and panes exactly at their place. Subtle lets them settle with a small bounce." + }, + "kept_on_reset_all" : false, + "key" : "ui.animations.bounce", + "keywords" : [ + "motion", + "spring", + "bounce" + ], + "kind" : "choice", + "page_hidden" : false, + "path" : [ + "ui", + "animations", + "bounce" + ], + "refuses" : [ + "__not_a_choice__", + 1, + true + ], + "section" : "appearance", + "summary" : null, + "title" : { + "key" : "settings.ui.animations.bounce", + "text" : "Animation Bounce" + }, + "validation" : "portable" + }, { "accepts" : [ "none", @@ -22159,6 +22321,54 @@ }, "validation" : "portable" }, + { + "accepts" : [ + true, + false + ], + "agent_refusal" : null, + "agent_settable" : true, + "consumers" : [ + "cmux-next" + ], + "default" : false, + "default_label" : null, + "group" : { + "key" : "settings.group.labs", + "text" : "Labs" + }, + "help" : { + "key" : "settings.labs.classicHome.help", + "text" : "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts." + }, + "kept_on_reset_all" : false, + "key" : "labs.classicHome", + "keywords" : [ + "home", + "chief", + "classic", + "dock", + "page" + ], + "kind" : "toggle", + "page_hidden" : false, + "path" : [ + "labs", + "classicHome" + ], + "refuses" : [ + "true", + 1, + null + ], + "section" : "advanced", + "summary" : null, + "title" : { + "key" : "settings.labs.classicHome", + "text" : "Classic Home" + }, + "validation" : "portable" + }, { "accepts" : [ true, @@ -23562,7 +23772,7 @@ "validation" : "portable" } ], - "schema_hash" : "f590edffcb3e42e89831d8e96c6ba3eece1917143936341f49819ccb666cb37c", + "schema_hash" : "bcd28b55ae95c1dc208eb2f6284da7af98930017e4294732258c1ba9f5563b5a", "sections" : [ { "id" : "general", diff --git a/schemas/settings/source/labs.json b/schemas/settings/source/labs.json index fcaac2207c36..a2fc1cb2646f 100644 --- a/schemas/settings/source/labs.json +++ b/schemas/settings/source/labs.json @@ -30,6 +30,27 @@ "beta", "prototype" ] + }, + { + "key": "labs.classicHome", + "note": "Home as the classic top page (the Chief conversation full window) instead of the home workspace with the Chief's page in a left dock (hq-6d cx-ebm.59.5). Off by default; kept for one release. HomeDock.classic reads it through DesignSettings.classicHome.", + "title": { + "key": "settings.labs.classicHome", + "text": "Classic Home" + }, + "help": { + "key": "settings.labs.classicHome.help", + "text": "Show Home as a full page instead of a workspace with the Chief docked on the left. Takes effect after cmux restarts." + }, + "kind": "toggle", + "default": false, + "keywords": [ + "home", + "chief", + "classic", + "dock", + "page" + ] } ] } diff --git a/scripts/ci/run-webviews-tests.sh b/scripts/ci/run-webviews-tests.sh index 8b50b307a761..06d014b73388 100755 --- a/scripts/ci/run-webviews-tests.sh +++ b/scripts/ci/run-webviews-tests.sh @@ -7,7 +7,8 @@ # jsdom globals). A process per file keeps that leak inside one file, and a # wall-clock timeout per file names a stuck file instead of hanging the job. # -# CMUX_WEB_TEST_FILE_TIMEOUT: seconds one file may run (default 120). +# CMUX_WEB_TEST_FILE_TIMEOUT: seconds one file may run (default 120); a file may declare more +# with a `// cmux-web-test-timeout: SECONDS` header line. # CMUX_WEB_TEST_JOBS: files run at once (default: CMUX_CI_CPU_BUDGET, the cores a fleet # worker granted the step, else the CPU count). # @@ -59,6 +60,16 @@ run_with_timeout() { fi } +# file_limit FILE DEFAULT: the seconds FILE may run. A file whose own tests need more than the +# default names its limit on a header line (first 20 lines), `// cmux-web-test-timeout: SECONDS` +# (editor-page: 24 browser tests in two engines, about 115 s on the CI runner, were killed at +# 120 s mid-WebKit). The longer of the two wins, so a declared limit never shortens the default. +file_limit() { + local declared + declared="$(head -n 20 "$1" 2> /dev/null | sed -n 's|^// cmux-web-test-timeout: \([1-9][0-9]*\)$|\1|p' | head -n 1)" + if [ -n "$declared" ] && [ "$declared" -gt "$2" ]; then echo "$declared"; else echo "$2"; fi +} + if [ "${1:-}" = --one-cached ]; then # xargs -n 3 appends FILE CACHE SEED after the fixed arguments. exec bash "$0" --one "$4" "$2" "$3" "$5" "${6:-}" @@ -66,7 +77,7 @@ fi if [ "${1:-}" = --one ]; then # Worker: run one file, write its log and status into the results directory. A dev server # file gets a Vite cache of its own (CMUX_VITE_CACHE_DIR), a copy of a warm one when given. - file="$2" results="$3" timeout_seconds="$4" cache="${5:-}" seed="${6:-}" + file="$2" results="$3" timeout_seconds="$(file_limit "$2" "$4")" cache="${5:-}" seed="${6:-}" log="$results/$(printf '%s' "$file" | tr '/' '_').log" status=0 if [ -n "$cache" ]; then @@ -202,6 +213,6 @@ done < <(sort -k2 "$results/summary") echo "### webviews tests: $count files, $failed failed or timed out" while read -r result file _; do - [ "$result" = PASS ] || echo "$result $file (limit ${timeout_seconds}s)" + [ "$result" = PASS ] || echo "$result $file (limit $(file_limit "$file" "$timeout_seconds")s)" done < <(sort -k2 "$results/summary") [ "$failed" -eq 0 ] diff --git a/scripts/cmux-next/app-size-baseline.txt b/scripts/cmux-next/app-size-baseline.txt index 9020042e4817..3faa47f403ca 100644 --- a/scripts/cmux-next/app-size-baseline.txt +++ b/scripts/cmux-next/app-size-baseline.txt @@ -2,4 +2,4 @@ # Packages/macOS/CmuxNext/Sources/CmuxNextApp/**/*.swift. It may only go down # (check-app-size.sh --update-baseline). New features go in their own target # (plans/cmux-next/code-quality.md SW14). -113750 +113692 diff --git a/scripts/cmux-next/build-agent-pane-web.sh b/scripts/cmux-next/build-agent-pane-web.sh index d70bf0a7fd62..9f9d5e6162fa 100755 --- a/scripts/cmux-next/build-agent-pane-web.sh +++ b/scripts/cmux-next/build-agent-pane-web.sh @@ -64,9 +64,6 @@ cat "$ROOT/webviews/src/pages/shared/desktop.css" > "$WORK/styles.css" # The shared stylesheet opens with a Tailwind @import that only Vite resolves; # the pane needs just its variables and rules, so drop @import lines. grep -v '^@import ' "$SRC/shared/styles.css" >> "$WORK/styles.css" -# KaTeX's rules and fonts (data URLs: the CSP below allows fonts only from data:) for the -# transcript's math (conversation/Math.tsx). -bun scripts/agent-pane/katex-css.mjs >> "$WORK/styles.css" # One surface for every menu and popover (the pane does not load ui.css, which imports it). cat "$ROOT/webviews/src/ui/popupSurface.css" >> "$WORK/styles.css" # The shared icon+label button (New Tab's tools, the composer footer), imported by main.tsx. @@ -114,6 +111,17 @@ CSP="default-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src d printf '\n\n\n\n
\n\n\n\n' } | perl -pe 's/[ \t]+$//' > "$WORK/index.html" +# KaTeX's rules and fonts load with KaTeX, only when a reply draws math (conversation/Math.tsx, +# katexStylePlugin.mjs): never in the page's first