From 763316662ea8fff07ee650a2bf67e611497b1803 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 23:54:17 -0700 Subject: [PATCH 1/2] test(acpmux, agent pane): Ctrl-B goes by what acpmux says a chat can do, not the harness's name The cmux model router's profile is Claude Code under acpmux, named `cmux`, and gets no Ctrl-B; an ACP agent a user named `claude-acp` gets the hint and is refused. These tests want each chat's summary to say `backgroundsWork` and the pane to read it. They fail on the base. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-6db9-7611-bb2a-f87286aa9ae6&host=cosmic-gray --- .../acpmux/tests/hub_integration/processes.rs | 57 ++++++++++++++ .../test/agent-pane-ctrl-b-background.test.ts | 75 ++++++++++++------- webviews/test/run-in-background.test.ts | 25 ++++--- 3 files changed, 119 insertions(+), 38 deletions(-) diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/processes.rs b/cmux-tui/crates/acpmux/tests/hub_integration/processes.rs index dd89c34447c7..35a7018ddcbd 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/processes.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/processes.rs @@ -476,6 +476,63 @@ async fn background_on_a_chat_that_is_not_claude_is_refused() { hub.shutdown_all().await; } +/// Whether Ctrl-B can move a chat's work goes by its harness's kind, not its +/// name, and every chat's summary says so (`backgroundsWork`), so a client +/// offers Ctrl-B only where acpmux takes it. `cmux`, the model router's +/// profile, is Claude Code (claude-stdio) under a name that is not +/// `claude*`: it says yes and Ctrl-B moves its work. An ACP agent a user's +/// config named `claude-acp` says no and is refused. +#[tokio::test] +async fn a_chats_summary_says_whether_ctrl_b_moves_its_work() { + let root = std::env::temp_dir().join(format!("acpmux-proc-{}", uuid::Uuid::now_v7())); + let claude_home = root.join("claude"); + std::fs::create_dir_all(&claude_home).unwrap(); + let fake_claude = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_claude.py"); + let fake_agent = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); + let mut cfg: Config = serde_json::from_value(json!({ + "harnesses": { + "cmux": {"argv": ["python3", fake_claude], "kind": "claude-stdio", + "env": {"CLAUDE_CONFIG_DIR": claude_home}}, + "claude-acp": {"argv": ["python3", fake_agent]}, + }, + "defaultHarness": "cmux", + "permissionPolicy": "approve-all", + })) + .unwrap(); + cfg.store.mode = StoreMode::Memory; + let store = acpmux::store::open(&cfg.store, std::path::Path::new("/nonexistent")).unwrap(); + let hub = Hub::new(cfg, store); + let mut c = connect(&hub).await; + let new_on = |harness: &str| json!({"cwd": cwd(), "mcpServers": [], "_meta": {"acpmux": {"harness": harness}}}); + + let acp = c.request(method::SESSION_NEW, new_on("claude-acp")).await.unwrap(); + assert_eq!(acp["_meta"]["acpmux"]["backgroundsWork"], json!(false), "{acp}"); + let acp = acp["sessionId"].as_str().unwrap().to_owned(); + let refused = c.request(BACKGROUND, json!({"sessionId": acp})).await.unwrap_err(); + assert!(refused.contains("Claude Code"), "{refused}"); + + let router = c.request(method::SESSION_NEW, new_on("cmux")).await.unwrap(); + assert_eq!(router["_meta"]["acpmux"]["backgroundsWork"], json!(true), "{router}"); + let router = router["sessionId"].as_str().unwrap().to_owned(); + for (id, moves) in [(&router, true), (&acp, false)] { + let summary = hub.session_summary(&hub.resolve(id).unwrap()); + assert_eq!(summary["backgroundsWork"], json!(moves), "{summary}"); + } + + // The router's chat (the default harness) runs a foreground shell, and + // Ctrl-B moves it. + let (id, turn) = fg_shell(&mut c).await; + assert_eq!(hub.resolve(&id).unwrap().meta().harness, "cmux"); + let mut other = connect(&hub).await; + let r = other.request(BACKGROUND, json!({"sessionId": id})).await.unwrap(); + assert_eq!(r, json!({"backgrounded": true})); + let (answer, _) = c.response(turn).await; + assert_eq!(answer.unwrap()["stopReason"], "end_turn"); + assert_eq!(listed(&mut c, &id).await[0]["origin"], "user_backgrounded"); + hub.shutdown_all().await; + let _ = std::fs::remove_dir_all(&root); +} + /// What fake_claude.py's "bg-output" shell writes to its output file. fn shell_output() -> String { (0..100).map(|i| format!("line {i}\n")).collect() diff --git a/webviews/test/agent-pane-ctrl-b-background.test.ts b/webviews/test/agent-pane-ctrl-b-background.test.ts index 41f86defdae4..92569bc481a8 100644 --- a/webviews/test/agent-pane-ctrl-b-background.test.ts +++ b/webviews/test/agent-pane-ctrl-b-background.test.ts @@ -2,8 +2,9 @@ // Ctrl-B moves a Claude Code chat's running work to the background, as in Claude Code // (conversation/runInBackground.ts): a running command's row says "⌃B to run in background", and // Ctrl-B, with the composer focused too, asks acpmux `_acpmux/process/background`. A chat whose -// turn holds no running call keeps Ctrl-B for the composer's caret, and a Codex chat never shows -// the hint. A running subagent draws no tool row, and takes Ctrl-B by itself. The page is the pane's dev entry with its browser host, talking JSON-RPC to a +// turn holds no running call keeps Ctrl-B for the composer's caret. Which chats offer it is acpmux's +// word (the summary's `backgroundsWork`), not the harness's name: the model router's `cmux` chat +// does, a Codex chat and an ACP agent named `claude-acp` never show the hint. A running subagent draws no tool row, and takes Ctrl-B by itself. The page is the pane's dev entry with its browser host, talking JSON-RPC to a // stand-in acpmux daemon. // // Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). @@ -29,6 +30,8 @@ await requireBrowserLane("agent-pane-ctrl-b-background.test.ts", async () => { const CODEX = "0190a000-0000-7000-8000-0000000000b2"; const IDLE = "0190a000-0000-7000-8000-0000000000b3"; const SUBAGENT = "0190a000-0000-7000-8000-0000000000b4"; + const ROUTER = "0190a000-0000-7000-8000-0000000000b5"; + const CLAUDE_ACP = "0190a000-0000-7000-8000-0000000000b6"; const START = Date.UTC(2026, 9, 20, 9, 0, 0); type Step = [dir: string, kind: string, msg: Record]; const update = (update: Record, meta?: Record) => ({ @@ -54,27 +57,24 @@ await requireBrowserLane("agent-pane-ctrl-b-background.test.ts", async () => { "agent_message_chunk", update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } }), ]; - const chats: Record = { - [CLAUDE]: { - harness: "claude", - steps: [ - ["mux", "user_message", { text: "Run the full test suite" }], - ["mux", "turn_started", {}], - say("Running the suite."), - running("fg1", "cargo test --workspace"), - ], - }, - [CODEX]: { - harness: "codex", - steps: [ - ["mux", "user_message", { text: "Run the full test suite" }], - ["mux", "turn_started", {}], - say("Running the suite."), - running("fg2", "cargo test --workspace"), - ], - }, + /// A turn running `cargo test --workspace` in the foreground, as call `id`. + const suite = (id: string): Step[] => [ + ["mux", "user_message", { text: "Run the full test suite" }], + ["mux", "turn_started", {}], + say("Running the suite."), + running(id, "cargo test --workspace"), + ]; + /// Each chat's harness, whether acpmux moves its work (`backgroundsWork` on its summary), and its events. + const chats: Record = { + [CLAUDE]: { harness: "claude", moves: true, steps: suite("fg1") }, + [CODEX]: { harness: "codex", moves: false, steps: suite("fg2") }, + // The model router's profile: Claude Code under acpmux, named `cmux`. + [ROUTER]: { harness: "cmux", moves: true, steps: suite("fg3") }, + // A user's config named an ACP agent after Claude: acpmux refuses its Ctrl-B. + [CLAUDE_ACP]: { harness: "claude-acp", moves: false, steps: suite("fg4") }, [SUBAGENT]: { harness: "claude", + moves: true, steps: [ ["mux", "user_message", { text: "Explore the uploader" }], ["mux", "turn_started", {}], @@ -91,6 +91,7 @@ await requireBrowserLane("agent-pane-ctrl-b-background.test.ts", async () => { }, [IDLE]: { harness: "claude", + moves: true, steps: [ ["mux", "user_message", { text: "Say hi" }], ["mux", "turn_started", {}], @@ -105,6 +106,7 @@ await requireBrowserLane("agent-pane-ctrl-b-background.test.ts", async () => { title: "Tests", harness: chats[id]!.harness, family: chats[id]!.harness, + backgroundsWork: chats[id]!.moves, status: chats[id]!.steps.some(([, kind]) => kind === "turn_result") ? "idle" : "running", turnCount: 1, updatedAt: START, @@ -258,17 +260,32 @@ await requireBrowserLane("agent-pane-ctrl-b-background.test.ts", async () => { await page.close(); }); - test("a Codex chat shows no hint and keeps Ctrl-B", async () => { + test("the model router's chat, Claude Code named `cmux`, shows the hint and moves its work", async () => { backgrounded.length = 0; - const page = await open(CODEX); - await page.getByText("cargo test --workspace").first().waitFor(); - await watchCtrlB(page); + const page = await open(ROUTER); + await page.getByText("⌃B to run in background").waitFor({ timeout: 5_000 }); await composer(page).click(); await page.keyboard.press("Control+b"); - expect(await ctrlBTaken(page)).toEqual([false]); - await Bun.sleep(300); - expect(backgrounded).toEqual([]); - expect(await page.getByText("⌃B to run in background").count()).toBe(0); + expect(await requested()).toEqual([{ sessionId: ROUTER }]); await page.close(); }); + + for (const [name, session] of [ + ["a Codex chat", CODEX], + ["an ACP agent named claude-acp", CLAUDE_ACP], + ] as const) { + test(`${name} shows no hint and keeps Ctrl-B`, async () => { + backgrounded.length = 0; + const page = await open(session); + await page.getByText("cargo test --workspace").first().waitFor(); + await watchCtrlB(page); + await composer(page).click(); + await page.keyboard.press("Control+b"); + expect(await ctrlBTaken(page)).toEqual([false]); + await Bun.sleep(300); + expect(backgrounded).toEqual([]); + expect(await page.getByText("⌃B to run in background").count()).toBe(0); + await page.close(); + }); + } }); diff --git a/webviews/test/run-in-background.test.ts b/webviews/test/run-in-background.test.ts index 4411936b1935..0b5de6540741 100644 --- a/webviews/test/run-in-background.test.ts +++ b/webviews/test/run-in-background.test.ts @@ -24,17 +24,24 @@ const row = ( }) as unknown as AcpmuxRow; describe("backgroundsWork", () => { - test("a local Claude Code chat moves its work, a profile's too", () => { - expect(backgroundsWork({ harness: "claude" })).toBe(true); - expect(backgroundsWork({ harness: "claude", hostKind: "local" })).toBe(true); - expect(backgroundsWork({ harness: "claude-sr" })).toBe(true); + test("a local chat whose acpmux summary says it moves its work does, whatever its harness's name", () => { + expect(backgroundsWork({ harness: "claude", backgroundsWork: true })).toBe(true); + expect(backgroundsWork({ harness: "claude", backgroundsWork: true, hostKind: "local" })).toBe(true); + expect(backgroundsWork({ harness: "claude-sr", backgroundsWork: true })).toBe(true); + // The model router's profile is Claude Code under acpmux, named `cmux`. + expect(backgroundsWork({ harness: "cmux", backgroundsWork: true })).toBe(true); }); - test("other harnesses, a peer's or Cloud chat and a chat not yet known do not", () => { - expect(backgroundsWork({ harness: "codex" })).toBe(false); - expect(backgroundsWork({ harness: "claudette" })).toBe(false); - expect(backgroundsWork({ harness: "claude", peer: "huge-blue" })).toBe(false); - expect(backgroundsWork({ harness: "claude", hostKind: "cloud" })).toBe(false); + test("a chat acpmux does not move work for does not, a Claude-named ACP harness included", () => { + expect(backgroundsWork({ harness: "codex", backgroundsWork: false })).toBe(false); + expect(backgroundsWork({ harness: "claude-acp", backgroundsWork: false })).toBe(false); + // An acpmux that does not say (older, or a summary not read yet) gets no Ctrl-B. + expect(backgroundsWork({ harness: "claude" })).toBe(false); + }); + + test("a peer's or Cloud chat and a chat not yet known do not", () => { + expect(backgroundsWork({ harness: "claude", backgroundsWork: true, peer: "huge-blue" })).toBe(false); + expect(backgroundsWork({ harness: "claude", backgroundsWork: true, hostKind: "cloud" })).toBe(false); expect(backgroundsWork(undefined)).toBe(false); }); }); From 88f6549ed189c068534b31926f3ecad33c765fe8 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 23:54:48 -0700 Subject: [PATCH 2/2] fix(acpmux, agent pane): Ctrl-B works in the cmux router's chats; acpmux says which chats it moves The pane offered Ctrl-B by harness name (`claude`, `claude-*`), while acpmux takes `_acpmux/process/background` for any Claude Code (claude-stdio) harness. So the model router's `cmux` profile, Claude Code under another name, got no Ctrl-B, and an ACP agent named `claude-*` got a hint acpmux refuses. Every session summary now says `backgroundsWork`, from the same predicate `process/background` checks (`hub/processes.rs` `backgrounds_work`). The pane's `backgroundsWork` reads it, plus the existing no-peer and not-Cloud checks; an acpmux that does not say gets no Ctrl-B. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15cfc-6db9-7611-bb2a-f87286aa9ae6&host=cosmic-gray --- .../crates/acpmux/docs/background-processes.md | 11 ++++++++--- .../crates/acpmux/spec/acpmux-protocol.json | 5 +++++ cmux-tui/crates/acpmux/src/hub/processes.rs | 16 +++++++++------- cmux-tui/crates/acpmux/src/hub/views.rs | 6 ++++++ cmux-tui/crates/acpmux/src/protocol/session.rs | 4 ++++ .../conversation/BackgroundHint.gallery.ts | 16 +++++++++++++--- .../acpmux/conversation/runInBackground.ts | 17 +++++++---------- webviews/src/agent-session/acpmux/direct.ts | 1 + webviews/src/agent-session/acpmux/model.ts | 3 +++ .../acpmux/protocol/generated/types.ts | 6 ++++++ webviews/src/gallery/fixtures/acpmux.ts | 11 ++++++++++- 11 files changed, 72 insertions(+), 24 deletions(-) diff --git a/cmux-tui/crates/acpmux/docs/background-processes.md b/cmux-tui/crates/acpmux/docs/background-processes.md index 147abc2ba290..edfc7ce64cae 100644 --- a/cmux-tui/crates/acpmux/docs/background-processes.md +++ b/cmux-tui/crates/acpmux/docs/background-processes.md @@ -157,7 +157,7 @@ process's command line and its kill never leave the machine. | `_acpmux/process/tail` (request, built) | `{processId, fromByte?, maxBytes?}` (default: the last 64 KiB; at most 1 MiB) | `{data (base64), fromByte, nextByte, eof}`; `eof`: the process ended and `nextByte` is the file's end; a missing file is empty data | | `_acpmux/process/watch` (request) | `{processId}` | `{}`; then `_acpmux/process/output` notifications `{processId, data (base64), atByte}`, the same shape as v2's `terminal_output_chunk`, until `unwatch` or the process ends | | `_acpmux/process/kill` (request, built) | `{processId, signal?: "term" \| "kill"}` | `{}`; the outcome arrives as a `process_update` | -| `_acpmux/process/background` (request, built) | `{sessionId, toolCallId?}` (none: every foreground task) | `{backgrounded: bool}`; false when no turn runs or nothing ran in the foreground. Claude Code only: any other chat is refused with invalid params (Codex moves long commands to the background itself). Claude Code's error is the request's error. What moved arrives as `process_update`s, origin `user_backgrounded` | +| `_acpmux/process/background` (request, built) | `{sessionId, toolCallId?}` (none: every foreground task) | `{backgrounded: bool}`; false when no turn runs or nothing ran in the foreground. Claude Code (a claude-stdio harness, whatever its name) only: any other chat is refused with invalid params (Codex moves long commands to the background itself). Every session summary says which chats it takes (`backgroundsWork`). Claude Code's error is the request's error. What moved arrives as `process_update`s, origin `user_backgrounded` | | `_acpmux/process_update` (notification, built) | `{process: Process}` | | **Migration:** @@ -197,8 +197,10 @@ process's command line and its kill never leave the machine. running tool row's own button may name its `toolCallId`. On `backgrounded: true` the turn goes on and the moved work shows up as running rows (origin `user_backgrounded`); `false` means nothing ran in - the foreground. Offer it only for Claude Code chats (other harnesses are - refused). The CLI is `acpmux process background SESSION [--tool-call ID]`. + the foreground. Offer it only where the chat's session summary says + `backgroundsWork` (a Claude Code chat whatever its harness is named, the + `cmux` model router's profile too), never by the harness's name; other + harnesses are refused. The CLI is `acpmux process background SESSION [--tool-call ID]`. - **SHOW LEO:** a new visual, so it is shown to Leo before merging. ## Tests (behavior, at the hub boundary) @@ -215,6 +217,9 @@ process's command line and its kill never leave the machine. `user_backgrounded`, answers false with no turn running without asking, turns Claude Code's refusal into an error, and refuses a chat of another agent (Codex too, in `codex_processes.rs`) and Web and Peer connections. + A chat's summary says `backgroundsWork` by its harness's kind: the + `cmux` router profile (claude-stdio) yes, and Ctrl-B moves its work; an + ACP agent named `claude-acp` no, and it is refused. - To build: after a daemon restart an adopted host's processes are listed again. - Built (`tests/hub_integration/codex_processes.rs`, the fake Codex diff --git a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json index 3a1534a48f9a..ff3ea40c8490 100644 --- a/cmux-tui/crates/acpmux/spec/acpmux-protocol.json +++ b/cmux-tui/crates/acpmux/spec/acpmux-protocol.json @@ -2203,6 +2203,10 @@ "minimum": 0, "type": "integer" }, + "backgroundsWork": { + "description": "`_acpmux/process/background` (Ctrl-B) moves its foreground work: its\nharness is Claude Code (claude-stdio), whatever the harness's name.\nClients offer Ctrl-B from this, not from `harness`.", + "type": "boolean" + }, "children": { "description": "Its sub-sessions that are not closed.", "format": "uint32", @@ -2516,6 +2520,7 @@ "queuePaused", "moving", "steering", + "backgroundsWork", "queue", "pendingPermissions", "enforcement", diff --git a/cmux-tui/crates/acpmux/src/hub/processes.rs b/cmux-tui/crates/acpmux/src/hub/processes.rs index 736ac9791f00..3d450a763620 100644 --- a/cmux-tui/crates/acpmux/src/hub/processes.rs +++ b/cmux-tui/crates/acpmux/src/hub/processes.rs @@ -540,13 +540,7 @@ impl Hub { pub async fn background_work(&self, key: &str, tool: Option<&str>) -> Result { let session = self.resolve(key)?; let harness = session.meta().harness; - let claude = self - .config - .read() - .await - .profile(&harness) - .is_some_and(|p| p.kind == crate::config::HarnessKind::ClaudeStdio); - if !claude { + if !backgrounds_work(&*self.config.read().await, &harness) { return Err(RpcError::invalid_params( "only Claude Code can move running work to the background", )); @@ -576,6 +570,14 @@ impl Hub { } } +/// Whether `_acpmux/process/background` moves the foreground work of a +/// chat on `harness`: a Claude Code (claude-stdio) profile, whatever its +/// name (the `cmux` model router's too). Every session summary says it +/// (`backgroundsWork`), so a client offers Ctrl-B exactly where this takes it. +pub(super) fn backgrounds_work(cfg: &crate::config::Config, harness: &str) -> bool { + cfg.profile(harness).is_some_and(|p| p.kind == crate::config::HarnessKind::ClaudeStdio) +} + /// No process has the id. fn unknown_process(id: &str) -> RpcError { RpcError::not_found(format!("no background process {id:?}")) diff --git a/cmux-tui/crates/acpmux/src/hub/views.rs b/cmux-tui/crates/acpmux/src/hub/views.rs index 41f58ca0eebe..8e5b7444312f 100644 --- a/cmux-tui/crates/acpmux/src/hub/views.rs +++ b/cmux-tui/crates/acpmux/src/hub/views.rs @@ -297,6 +297,12 @@ impl Hub { // 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), + // Ctrl-B moves its work (`processes.rs`); no while the configuration is + // being written, until the next summary. + backgrounds_work: self + .config + .try_read() + .is_ok_and(|cfg| super::processes::backgrounds_work(&cfg, &m.harness)), turn: turn.map(|t| TurnSummary { started_at: t.started_at, client: t.client, diff --git a/cmux-tui/crates/acpmux/src/protocol/session.rs b/cmux-tui/crates/acpmux/src/protocol/session.rs index e90a09b1154a..481df54b2732 100644 --- a/cmux-tui/crates/acpmux/src/protocol/session.rs +++ b/cmux-tui/crates/acpmux/src/protocol/session.rs @@ -64,6 +64,10 @@ pub struct SessionSummary { pub moving: bool, /// The agent takes a prompt mid-turn. pub steering: bool, + /// `_acpmux/process/background` (Ctrl-B) moves its foreground work: its + /// harness is Claude Code (claude-stdio), whatever the harness's name. + /// Clients offer Ctrl-B from this, not from `harness`. + pub backgrounds_work: bool, pub turn: Option, pub queue: Vec, pub pending_permissions: u64, diff --git a/webviews/src/agent-session/acpmux/conversation/BackgroundHint.gallery.ts b/webviews/src/agent-session/acpmux/conversation/BackgroundHint.gallery.ts index 246b4741244e..b9b21626a2a3 100644 --- a/webviews/src/agent-session/acpmux/conversation/BackgroundHint.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/BackgroundHint.gallery.ts @@ -1,7 +1,8 @@ // l10n-allow-file: gallery fixtures (sample prompts and commands), not shipped UI. // A Claude Code chat's running command (BackgroundHint.tsx): its row ends with "⌃B to run in -// background", and Ctrl-B moves the command to the background (runInBackground.ts). A Codex chat's -// running command has no hint. +// background", and Ctrl-B moves the command to the background (runInBackground.ts). The model +// router's `cmux` chat is Claude Code too. A Codex chat's running command, or an ACP agent's that a +// user named `claude-acp`, has no hint: acpmux's summary says which (`backgroundsWork`). import { agentPaneEntry } from "../../../gallery/format"; import { activity, assistant, chat, tool, user } from "../../../gallery/fixtures/acpmux"; import { minutesAgo } from "../../../gallery/clock"; @@ -12,9 +13,10 @@ const suite = tool("cargo test --workspace", "execute", "in_progress", { startedAt: minutesAgo(1), }); -const running = (harness: string) => +const running = (harness: string, backgroundsWork = harness === "claude") => chat([user("Run the full test suite", 2), assistant("Running the suite.", 1.5), activity([suite], 1)], { harness, + backgroundsWork, isWorking: true, }); @@ -29,9 +31,17 @@ export default agentPaneEntry({ note: "A Claude Code chat's running command: the row ends with the Ctrl-B hint.", snapshot: running("claude"), }, + router: { + note: "The model router's chat (Claude Code named `cmux`): the row ends with the Ctrl-B hint.", + snapshot: running("cmux", true), + }, codex: { note: "A Codex chat's running command: no hint, Ctrl-B stays the composer's.", snapshot: running("codex"), }, + claudeAcp: { + note: "An ACP agent a user named `claude-acp`: acpmux refuses its Ctrl-B, so no hint.", + snapshot: running("claude-acp", false), + }, }, }); diff --git a/webviews/src/agent-session/acpmux/conversation/runInBackground.ts b/webviews/src/agent-session/acpmux/conversation/runInBackground.ts index f28cff5710c0..830a7e0bf337 100644 --- a/webviews/src/agent-session/acpmux/conversation/runInBackground.ts +++ b/webviews/src/agent-session/acpmux/conversation/runInBackground.ts @@ -6,7 +6,7 @@ // composer Ctrl-B is the text system's caret back, so the page takes it only while a call runs in // the foreground, and a running command's row says so. import { createContext, useEffect, useRef } from "react"; -import type { AcpmuxRow } from "../model"; +import type { AcpmuxRow, AcpmuxSnapshot } from "../model"; import { continuesTurn } from "../turnStart"; /// Moves the chat's foreground work to the background: only the call `toolCallId` started, or all @@ -15,15 +15,12 @@ export type RunInBackground = (toolCallId?: string) => void; export const RunInBackgroundContext = createContext(undefined); -/// Whether acpmux moves this chat's work: a local Claude Code chat only (`claude` and its profiles, -/// `claude-sr` and the like). acpmux refuses the other harnesses (Codex moves a long command to the -/// background itself), and a peer's or a Cloud chat's processes are not this acpmux's to move. -export function backgroundsWork( - summary: { harness?: string; peer?: string; hostKind?: "local" | "cloud" } | undefined, -): boolean { - const harness = summary?.harness; - const claude = harness === "claude" || Boolean(harness?.startsWith("claude-")); - return claude && !summary?.peer && summary?.hostKind !== "cloud"; +/// Whether acpmux moves this chat's work: its summary says so (`backgroundsWork`, a Claude Code +/// chat whatever its harness is named, the model router's `cmux` too), and the chat is local. acpmux +/// refuses the other harnesses (Codex moves a long command to the background itself), and a peer's +/// or a Cloud chat's processes are not this acpmux's to move. An acpmux that does not say gets none. +export function backgroundsWork(summary: Partial> | undefined): boolean { + return summary?.backgroundsWork === true && !summary.peer && summary.hostKind !== "cloud"; } /// The calls of the running turn that hold it up: a running command or subagent (what Claude diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 61ea506fbc6e..f9115be10739 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -2784,6 +2784,7 @@ export class AcpmuxDirectClient { audio: takesAudio(summary.agentCapabilities.promptCapabilities, summary.configOptions), }, steering: summary.steering === true, + backgroundsWork: summary.backgroundsWork === true, queuePaused: summary.queuePaused === true, ...(Array.isArray(summary.mcpServers) ? { mcpServers: summary.mcpServers } : {}), status: summary.status, diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index 64ca96a843bd..2c623a3f07b0 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -370,6 +370,9 @@ export type AcpmuxSnapshot = { promptCapabilities?: { image?: boolean; audio?: boolean }; /// The agent takes a prompt mid-turn (acpmux `steering`); without it a steer stops the turn. steering?: boolean; + /// acpmux's Ctrl-B (`_acpmux/process/background`) moves its running work: a Claude Code + /// chat, whatever its harness is named (acpmux `backgroundsWork`). + backgroundsWork?: boolean; /// A turn failed: the queued prompts wait until the user resumes the queue (acpmux `queuePaused`). queuePaused?: boolean; /// The MCP servers the harness says it loaded (acpmux `mcpServers`, from Claude Code's diff --git a/webviews/src/agent-session/acpmux/protocol/generated/types.ts b/webviews/src/agent-session/acpmux/protocol/generated/types.ts index a2a0cbf93512..39cd6f6c859f 100644 --- a/webviews/src/agent-session/acpmux/protocol/generated/types.ts +++ b/webviews/src/agent-session/acpmux/protocol/generated/types.ts @@ -390,6 +390,12 @@ export type SessionSummary = { agentSessionId?: (string | null); /** Attached clients. */ attached: number; + /** + * `_acpmux/process/background` (Ctrl-B) moves its foreground work: its + * harness is Claude Code (claude-stdio), whatever the harness's name. + * Clients offer Ctrl-B from this, not from `harness`. + */ + backgroundsWork: boolean; /** Its sub-sessions that are not closed. */ children: number; createdAt: number; diff --git a/webviews/src/gallery/fixtures/acpmux.ts b/webviews/src/gallery/fixtures/acpmux.ts index b26db76db0fa..77372e0f452b 100644 --- a/webviews/src/gallery/fixtures/acpmux.ts +++ b/webviews/src/gallery/fixtures/acpmux.ts @@ -111,13 +111,21 @@ export function session(fields: Partial & { sessionId: strin /** A connected snapshot of one chat: `rows`, with its session in the list. */ export function chat( rows: AcpmuxRow[], - fields: Partial & { title?: string; harness?: string; model?: string; branch?: string } = {}, + fields: Partial & { + title?: string; + harness?: string; + model?: string; + branch?: string; + /// acpmux's Ctrl-B moves its work; acpmux says so for a Claude Code chat. + backgroundsWork?: boolean; + } = {}, ): AcpmuxSnapshot { const { title = "Add retries to the fetch helper", harness = "claude", model = "claude-opus-5-5", branch = "main", + backgroundsWork = harness === "claude", ...rest } = fields; return { @@ -138,6 +146,7 @@ export function chat( turnCount: rows.filter((candidate) => candidate.kind === "user").length, usage: { used: 48_000, size: 200_000 }, promptCapabilities: { image: true }, + backgroundsWork, }, connection: "connected", origin: "local",