Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions cmux-tui/crates/acpmux/docs/background-processes.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,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:**
Expand Down Expand Up @@ -203,8 +203,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)
Expand All @@ -223,6 +225,9 @@ process's command line and its kill never leave the machine.
never answers or the turn ends first, keeps the first of two requests'
shell the person's when the second answers false, 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
Expand Down
5 changes: 5 additions & 0 deletions cmux-tui/crates/acpmux/spec/acpmux-protocol.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2516,6 +2520,7 @@
"queuePaused",
"moving",
"steering",
"backgroundsWork",
"queue",
"pendingPermissions",
"enforcement",
Expand Down
16 changes: 9 additions & 7 deletions cmux-tui/crates/acpmux/src/hub/processes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,13 +546,7 @@ impl Hub {
pub async fn background_work(&self, key: &str, tool: Option<&str>) -> Result<Value, RpcError> {
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",
));
Expand Down Expand Up @@ -588,6 +582,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:?}"))
Expand Down
6 changes: 6 additions & 0 deletions cmux-tui/crates/acpmux/src/hub/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions cmux-tui/crates/acpmux/src/protocol/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TurnSummary>,
pub queue: Vec<QueuedPromptSummary>,
pub pending_permissions: u64,
Expand Down
57 changes: 57 additions & 0 deletions cmux-tui/crates/acpmux/tests/hub_integration/processes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
});

Expand All @@ -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),
},
},
});
17 changes: 7 additions & 10 deletions webviews/src/agent-session/acpmux/conversation/runInBackground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,15 +15,12 @@ export type RunInBackground = (toolCallId?: string) => void;

export const RunInBackgroundContext = createContext<RunInBackground | undefined>(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<NonNullable<AcpmuxSnapshot["summary"]>> | 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
Expand Down
1 change: 1 addition & 0 deletions webviews/src/agent-session/acpmux/direct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2841,6 +2841,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,
Expand Down
3 changes: 3 additions & 0 deletions webviews/src/agent-session/acpmux/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,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
Expand Down
6 changes: 6 additions & 0 deletions webviews/src/agent-session/acpmux/protocol/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion webviews/src/gallery/fixtures/acpmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,21 @@ export function session(fields: Partial<AcpmuxSessionEntry> & { sessionId: strin
/** A connected snapshot of one chat: `rows`, with its session in the list. */
export function chat(
rows: AcpmuxRow[],
fields: Partial<AcpmuxSnapshot> & { title?: string; harness?: string; model?: string; branch?: string } = {},
fields: Partial<AcpmuxSnapshot> & {
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 {
Expand All @@ -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",
Expand Down
Loading