Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
c858619
tool-evals: screenshot-headless-honest accepts "isn't/aren't availabl…
lawrencecchen Oct 31, 2026
760ad56
cmux mcp: tab_move takes just the destination pane (its screen, works…
lawrencecchen Oct 31, 2026
f9e9d01
cmux mcp: tab_move sends the destination pane's own id (current, a pr…
lawrencecchen Oct 31, 2026
f568531
cmux mcp: a tab_move to a workspace with no pane says so and names pa…
lawrencecchen Oct 31, 2026
eea8c30
tool-evals: 20 open-placement cases (files, diffs, pages; right, left…
lawrencecchen Oct 31, 2026
8dfa97a
tool-evals: the stand-in app is reachable from the remote MCP server:…
lawrencecchen Oct 31, 2026
1e2ac6f
cmux mcp: an open tool puts a file, diff or page where the person ask…
lawrencecchen Oct 31, 2026
32a5137
cmux mcp: open checks for the app before it splits (no split left beh…
lawrencecchen Oct 31, 2026
f71ee53
cmux mcp: open takes where as an object ({pane} or {workspace}) and r…
lawrencecchen Oct 31, 2026
27bf9b8
open: a page targets the pane's shown tab, a relative path joins the …
lawrencecchen Oct 31, 2026
623a307
tool-evals: a remote sample's daemon is stopped on the remote host to…
lawrencecchen Oct 31, 2026
373848c
cmux mcp: the instructions name panes and columns as cmux objects ("m…
lawrencecchen Oct 31, 2026
00a36c4
cmux mcp: open's pane always wins in its answer; the compare hint say…
lawrencecchen Oct 31, 2026
3eaf71d
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
lawrencecchen Nov 1, 2026
d3ba243
Restore this branch's own changes over the merge of feat-cmux-next (t…
lawrencecchen Nov 1, 2026
af1ff21
Merge branch 'feat-cmux-next' into feat-cmux-next-tool-evals-r17
cmux-bot[bot] Nov 1, 2026
d1fa73a
Merge branch 'feat-cmux-next' into feat-cmux-next-tool-evals-r17
cmux-bot[bot] Nov 1, 2026
0e545f9
Merge branch 'feat-cmux-next' into feat-cmux-next-tool-evals-r17
cmux-bot[bot] Nov 2, 2026
0294309
Merge remote-tracking branch 'origin/feat-cmux-next' into feat-cmux-n…
lawrencecchen Nov 3, 2026
af7322a
tool-evals: 14 workspace cases (6 workspaces, the person looks at ano…
lawrencecchen Nov 3, 2026
e7bda04
cmux mcp test: an untargeted app viewer from a chat opens beside it, …
lawrencecchen Nov 3, 2026
db021ca
cmux mcp: an app viewer (file open, diff viewer, tab new-browser) an …
lawrencecchen Nov 3, 2026
effd0f1
mcp: test that Chief's instructions open a task's things in its works…
lawrencecchen Nov 3, 2026
30a49d8
mcp: Chief opens a task's things in that task's workspace
lawrencecchen Nov 3, 2026
a4199ca
tool-evals: Chief workspace cases, CLI surface without MCP
lawrencecchen Nov 3, 2026
63a365a
tool-evals: the stand-in app refuses an argument the action's schema …
lawrencecchen Nov 3, 2026
8ced5fd
mcp tests: review findings of the workspace placement (red)
lawrencecchen Nov 3, 2026
c1ee0b7
mcp: fix the review findings of the workspace placement
lawrencecchen Nov 3, 2026
ec6f6b4
mcp: Chief's rule gives an example ("run the api tests" is the api wo…
lawrencecchen Nov 3, 2026
aec62b6
mcp: keep #30752's in_progress receipt key; tests use open_tool directly
lawrencecchen Nov 3, 2026
a82b64b
mcp: Chief's rule says a workspace's name anywhere in the request pla…
lawrencecchen Nov 3, 2026
14b2987
mcp tests: Chief's rule starts the tool description; a long key keeps…
lawrencecchen Nov 3, 2026
af0e5f6
mcp: Chief's rule first in the open and cmux_exec descriptions; a key…
lawrencecchen Nov 3, 2026
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
74 changes: 65 additions & 9 deletions cmux-tui/crates/cmux-tui/src/cli/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,9 @@ const MAX_MESSAGE_BYTES: usize = 8 << 20;
pub(super) const MAX_RESULT_BYTES: usize = 256 << 10;

const INSTRUCTIONS: &str = "These tools drive the cmux terminal app and its session daemon. \
This chat runs in cmux: a terminal, tab or workspace the person asks for is a cmux one, \
made with these tools, not your own shell. \
This chat runs in cmux: a terminal, tab, pane, column or workspace the person asks for \
(\"my logs column\", \"on my right\") is a cmux one, made with these tools, not your own \
shell or a file. \
Objects have stable public ids (ws_…, screen_…, pane_…, tab_…, term_…, win_…) that the \
*_list tools report; a unique prefix works, and <session>:<id> reaches another session. \
`current` is this chat's own workspace or pane; `focused` is the one the person looks at. \
Expand Down Expand Up @@ -205,6 +206,11 @@ pub(super) trait Backend {
fn caller_terminal(&self) -> Option<String> {
std::env::var("CMUX_TUI_TERMINAL_ID").ok().filter(|id| !id.is_empty())
}
/// A Chief turn (`CMUX_CALLER_HOME_CHAT=1`, set by the Chief host): its `open` and
/// `cmux_exec` descriptions add [`CHIEF_RULE`].
fn home_chat(&self) -> bool {
std::env::var("CMUX_CALLER_HOME_CHAT").as_deref() == Ok("1")
}
/// Holds the session daemon's `session.events` stream open until
/// `deadline` and calls `on_wake` with each item (the initial snapshot
/// first, or with `cursor` the journal batches after it), and with
Expand Down Expand Up @@ -683,9 +689,12 @@ impl<B: Backend> Server<B> {
"ping" => Ok(json!({})),
"tools/list" if self.code_mode && self.allowed.is_none() => {
let tools = self.list();
Ok(json!({ "tools": code_mode::descriptors(&tools) }))
Ok(json!({ "tools": self.chief_rule(code_mode::descriptors(&tools)) }))
}
"tools/list" => {
let tools = self.list();
Ok(json!({ "tools": self.chief_rule(tools) }))
}
"tools/list" => Ok(json!({ "tools": self.list() })),
"tools/call" => {
// A proxied server's call runs on its own thread, so one that
// waits (a secret card) never holds up the other tools.
Expand Down Expand Up @@ -992,6 +1001,14 @@ impl<B: Backend> Server<B> {
let Some(tool) = self.actions.iter().find(|tool| tool.name == name) else {
return Err((-32602, self.unknown_tool(name)));
};
// A viewer an agent opens with no target goes beside its caller (its chat or terminal),
// in its own workspace, not where the person looks ([`open_tool::untargeted`]). A caller
// the app cannot place in a pane keeps the app's own placement.
if let Some(open) = open_tool::untargeted(&tool.cli_name, &arguments)
&& self.caller_has_pane()
{
return Ok(self.call_open(&open));
}
Ok(self.call_action(tool, &arguments))
}

Expand Down Expand Up @@ -1383,18 +1400,57 @@ impl<B: Backend> Server<B> {
Err(failure) => failure_result(failure),
}
}

/// Whether the app places this server's caller in a pane of a workspace: only then does
/// `open`'s `current` mean the caller's own pane, not the daemon's active one.
fn caller_has_pane(&self) -> bool {
let Ok(Some(identity)) = self.backend.identity() else { return false };
identity.get("caller").is_some_and(|caller| {
caller_scope::is_placed(caller) && caller.get("pane").and_then(Value::as_str).is_some()
})
}

/// `tools` with [`CHIEF_RULE`] at the start of the descriptions of `open` and `cmux_exec`
/// in a Chief turn.
fn chief_rule(&self, mut tools: Vec<Value>) -> Vec<Value> {
if !self.backend.home_chat() {
return tools;
}
for tool in &mut tools {
if !matches!(tool["name"].as_str(), Some(open_tool::NAME | code_mode::EXEC)) {
continue;
}
if let Some(description) = tool["description"].as_str() {
// First: `cmux_exec`'s description is about 6 KB, and a client may cut it.
tool["description"] = json!(format!("{CHIEF_RULE} {description}"));
}
}
tools
}
}

/// Chief works for many workspaces from its own: what it opens for a workspace's work goes
/// there, or every turn piles tabs into the column right of Chief (Lawrence 2026-11-02:
/// "Workspace of the task"). It rides on the tools that open things, not the instructions:
/// those are near the 2048 characters past which Claude Code may cut them.
const CHIEF_RULE: &str = "You are Chief: you work for many workspaces from your own. A \
request with a workspace's name in it, or about its project, agent, server or files (\"run \
the api tests\", \"open the infra dashboard\"), opens in that workspace, in the \
background: workspace: \"api\" on a terminal or tab, where: {workspace: \"infra\"} on \
open. Then tell the person which workspace. Only a request with no workspace in it or in the \
conversation opens beside you.";

fn initialize(params: &Value, list_changed: bool, code_mode: bool) -> Value {
let instructions = format!(
"{} {}",
if code_mode { code_mode::INSTRUCTIONS } else { INSTRUCTIONS },
acpmux::agent_tools::ASKING_RULE
);
json!({
"protocolVersion": acpmux::secret_mcp::protocol_version(params),
"capabilities": {"tools": {"listChanged": list_changed}},
"serverInfo": {"name": "cmux", "title": "cmux", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!(
"{} {}",
if code_mode { code_mode::INSTRUCTIONS } else { INSTRUCTIONS },
acpmux::agent_tools::ASKING_RULE
),
"instructions": instructions,
})
}

Expand Down
14 changes: 8 additions & 6 deletions cmux-tui/crates/cmux-tui/src/cli/mcp/code_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ const BOILERPLATE: [&str; 4] = [
focus.",
];

pub(super) const INSTRUCTIONS: &str = "This chat runs in cmux: a terminal, tab or workspace \
the person asks for (\"on my right\", \"a new workspace\") is a cmux one, made and run with \
these tools, not your own shell. cmux tools run through cmux_exec scripts; its \
pub(super) const INSTRUCTIONS: &str = "This chat runs in cmux: a terminal, tab, pane, column or \
workspace the person asks for (\"on my right\", \"my logs column\", \"a new workspace\") is a \
cmux one, made and run with these tools, not your own shell or a file. cmux tools run \
through cmux_exec scripts; its \
description lists the common ones. Selectors take a public id (ws_…, pane_…, tab_…, \
term_…), a unique prefix, <session>:<id> or an exact name; workspace, screen, pane and tab \
also take `current` (this chat's own) or `focused` (the one the person looks at). Tools of the chat's \
Expand Down Expand Up @@ -136,9 +137,10 @@ pub(super) fn descriptors(tools: &[Value]) -> Vec<Value> {
"title": "Run a cmux script",
"description": format!(
"Run JavaScript that calls cmux tools and return its result. This chat runs in \
cmux: a terminal, tab or workspace the person asks for is a cmux one, made here, \
not in your own shell, and \"this workspace\" is the cmux workspace this chat is \
in (workspace: \"current\"), never the folder.{computer_use} An async \
cmux: a terminal, tab, pane, column or workspace the person asks for (\"my logs \
column\") is a cmux one, made here, not in your own shell or a file, and \"this \
workspace\" is the cmux workspace this chat is in (workspace: \"current\"), never \
the folder.{computer_use} An async \
function \
body, `await cmux.<tool>({{...}})` per call, `return` a JSON value. Do related \
steps in one script: look up, act and check together. No file system, network or \
Expand Down
49 changes: 48 additions & 1 deletion cmux-tui/crates/cmux-tui/src/cli/mcp/open_tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub(super) fn descriptor() -> Value {
json!({
"name": NAME,
"title": "Open a file, diff or page",
"description": "Open a file, a diff or a web page in a cmux viewer tab, where the person asked, in one call. kind: file (path), diff (path: a file or folder; none for the working tree's changes) or page (url). where: right (the column right of this chat, made when there is none), left, below, above (a new pane on that side of this chat's pane, or of pane), here (this chat's own pane), sticky (a column docked to edge, default right; an existing docked column takes it), pane (a tab in pane), workspace (a tab in that workspace, which stays in the background). The person's view stays unless focus is true.",
"description": "Open a file, a diff or a web page in a cmux viewer tab, where the person asked, in one call. kind: file (path), diff (path: a file or folder; none for the working tree's changes) or page (url). where: right (the column right of this chat, made when there is none), left, below, above (a new pane on that side of this chat's pane, or of pane), here (this chat's own pane), sticky (a column docked to edge, default right; an existing docked column takes it), pane (a tab in pane), workspace (a tab in that workspace, which stays in the background). The answer names the pane. Two to compare, both in view: the second with where: below and pane: the first answer's pane (one above the other in that column). The person's view stays unless focus is true.",
"inputSchema": {
"type": "object",
"additionalProperties": false,
Expand All @@ -54,6 +54,46 @@ pub(super) fn descriptor() -> Value {
})
}

/// The `open` arguments for an app action that opens a viewer (`file open`, the diff viewer,
/// `tab new-browser`) called with no target: placed right of the chat, as `open` places it.
/// With no target the app opens it in the pane the person looks at, which with many
/// workspaces is often another workspace (Lawrence 2026-11-02: splits and browser tabs leaked
/// into other workspaces). `None` keeps the call as it is: a target, an argument `open` does
/// not take, or a viewer with nothing to show.
pub(super) fn untargeted(
cli_name: &str,
arguments: &Map<String, Value>,
) -> Option<Map<String, Value>> {
let (kind, needs) = match cli_name {
"file open" => ("file", Some("path")),
"browser open-diff-viewer" => ("diff", None),
"tab new-browser" => ("page", Some("url")),
_ => return None,
};
let plain = |key: &String| matches!(key.as_str(), "path" | "url" | "focus" | "idempotency_key");
let given = |key: &str| arguments.get(key).is_none() || text(arguments, key).is_some();
// `open` takes a key `validate_idempotency_key` accepts; a longer one an action takes
// (up to 200 characters) keeps the action's own path.
let key_fits = text(arguments, "idempotency_key")
.is_none_or(|key| cmux_tui_core::resource::validate_idempotency_key(key).is_ok());
if !arguments.keys().all(plain)
|| !key_fits
|| !given("path")
|| !given("url")
|| needs.is_some_and(|key| text(arguments, key).is_none())
{
return None;
}
let mut open = arguments.clone();
open.insert("kind".into(), json!(kind));
// With no path the app diffs the target pane's folder; beside the chat that pane is a new
// split or a page, so the diff names the chat's own folder.
if kind == "diff" && !open.contains_key("path") {
open.insert("path".into(), json!("."));
}
Some(open)
}

fn invalid(message: impl Into<String>) -> Value {
tool_error(envelope(v2_tools::invalid(message), "not_run", None))
}
Expand Down Expand Up @@ -143,6 +183,7 @@ impl<B: Backend> Server<B> {
Ok(target) => target,
Err(answer) => return self.failed_open(None, answer, &open_id),
};
let pane = target.clone();
// A page opens as a tab beside a tab (`tab new-browser` takes a tab target, not a
// pane): the pane's shown tab.
let target = match (kind, target) {
Expand Down Expand Up @@ -171,6 +212,12 @@ impl<B: Backend> Server<B> {
&& let Some(object) = receipt.as_object_mut()
{
object.insert("open_idempotency_key".into(), json!(open_id));
// The answer names the pane, so a second open can go beside it (`where: "below",
// pane`) with no layout read: "open a.txt and b.txt to compare" took Opus a split,
// a tab move and a close by hand (tests/tool-evals open-r16f).
if let Some(pane) = pane {
object.insert("pane".into(), json!(pane));
}
return super::success(receipt, true);
}
answer
Expand Down
113 changes: 113 additions & 0 deletions cmux-tui/crates/cmux-tui/src/cli/mcp/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ struct Fake {
/// What `Backend::caller_terminal` answers (none by default, whatever
/// terminal the test runs in).
caller_terminal: Option<String>,
/// What `Backend::home_chat` answers: a Chief turn.
home_chat: bool,
/// What `Backend::focus` answers.
focus: Option<Value>,
/// The app's `computer_use.status` through `Backend::app_caller`; no
Expand Down Expand Up @@ -439,6 +441,10 @@ impl Backend for Fake {
self.caller_terminal.clone()
}

fn home_chat(&self) -> bool {
self.home_chat
}

fn acpmux(
&self,
method: &str,
Expand Down Expand Up @@ -2067,6 +2073,8 @@ fn open_splits_and_opens_in_one_call() {
"arguments": {"kind": "file", "path": "./docs/notes.md", "where": "below"}}))
.expect("a tool result");
assert_ne!(result["isError"], true, "{result}");
// The answer names the pane, for a second open beside it.
assert_eq!(result["structuredContent"]["pane"], pane, "{result}");
let sent = server.backend.sent.borrow();
let split = sent.iter().find(|s| s["request"]["operation"] == "pane.split");
let split = split.unwrap_or_else(|| panic!("{sent:?}"));
Expand Down Expand Up @@ -2204,6 +2212,111 @@ fn open_splits_and_opens_in_one_call() {
assert!(result.to_string().contains("needs the cmux app"), "{result}");
}

/// Chief opens what a workspace's work needs in that workspace: the tools that open things
/// (`open`, and `cmux_exec` in code mode) say so in a Chief turn, and only there. The rule is
/// not in the instructions, which a client may cut past 2048 characters.
#[test]
fn chief_tools_open_in_the_workspace_of_the_task() {
let list = json!({"jsonrpc": "2.0", "id": "l", "method": "tools/list"});
let described = |mut server: Server<Fake>, name: &str| {
let answer = server.handle(&list).expect("an answer");
let tools = answer["result"]["tools"].as_array().cloned().unwrap_or_default();
let tool = tools.iter().find(|tool| tool["name"] == name).cloned();
tool.unwrap_or_else(|| panic!("no {name} in {answer}"))["description"].to_string()
};
let chief = || Fake { home_chat: true, ..Fake::with_actions() };
for text in [
described(code_mode_server(chief()), "cmux_exec"),
described(Server::new(chief(), None), "open"),
] {
assert!(
text.starts_with("\"You are Chief") && text.contains("opens in that workspace"),
"{text}"
);
}
for text in [
described(code_mode_server(Fake::with_actions()), "cmux_exec"),
described(Server::new(Fake::with_actions(), None), "open"),
] {
assert!(!text.contains("You are Chief"), "{text}");
}
let initialize = json!({"jsonrpc": "2.0", "id": "i", "method": "initialize"});
let answer = code_mode_server(chief()).handle(&initialize).expect("an answer");
let text = answer["result"]["instructions"].as_str().unwrap_or_default();
assert!(text.chars().count() <= 2048, "{} chars", text.chars().count());
}

/// An app viewer an agent opens with no target goes beside its chat (a split right, made
/// by `open`), not into the pane the person looks at, which with many workspaces is often
/// another workspace (tests/tool-evals workspace cases). A caller the app does not place in a
/// pane keeps the app's own placement: no target is sent.
#[test]
fn an_untargeted_viewer_from_a_chat_opens_beside_it() {
let pane = "pane_00000000000000000000000000000007";
let mut actions = fixture_actions();
actions["actions"].as_array_mut().unwrap().push(json!({
"id": "file.open", "title": "Open File", "cli_name": "file open", "cli": true,
"targets": ["pane"], "arguments": [{"name": "path", "kind": "string", "required": false}],
crate::app_identity::ACTION_SURFACE_PLAN: {"cli": "offered", "mcp": "offered"},
}));
let created = json!({"pane_id": pane, "id": pane});
let open = json!({"name": "app_file_open", "arguments": {"path": "/work/notes.md"}});
let chat = Fake {
actions: actions.clone(),
resource_result: Some(created.clone()),
caller: Some(json!({"workspace": "ws_a", "pane": "pane_chat"})),
..Fake::default()
};
let mut server = Server::new(chat, None);
let result = server.call(&open).expect("a tool result");
assert_ne!(result["isError"], true, "{result}");
let sent = server.backend.sent.borrow();
let run = sent.iter().find(|s| s["method"] == "action.run");
let run = run.unwrap_or_else(|| panic!("{sent:?}"));
assert_eq!(run["params"]["target"], pane, "{run}");
drop(sent);

// A caller the app does not place in a pane (none, unknown to it, or a workspace with
// no pane) keeps the app's own placement: `current` would be the daemon's active pane.
let unplaced = [
None,
Some(json!({"found": false, "workspace": "ws_a", "pane": "pane_chat"})),
Some(json!({"workspace": "ws_a"})),
];
for caller in unplaced {
let fake = Fake {
actions: actions.clone(),
resource_result: Some(created.clone()),
caller: caller.clone(),
..Fake::default()
};
let mut server = Server::new(fake, None);
let result = server.call(&open).expect("a tool result");
assert_ne!(result["isError"], true, "{caller:?}: {result}");
let sent = server.backend.sent.borrow();
let run = sent.iter().find(|s| s["method"] == "action.run");
let run = run.unwrap_or_else(|| panic!("{sent:?}"));
assert!(run["params"].get("target").is_none(), "{caller:?}: {run}");
}
}

/// A diff viewer opened beside the chat with no path diffs the chat's folder: with no path
/// the app would diff the folder of the pane it opens in, a new split or a page.
#[test]
fn an_untargeted_diff_names_the_chat_folder() {
let diff = open_tool::untargeted("browser open-diff-viewer", &Map::new()).expect("placed");
assert_eq!(diff["path"], ".", "{diff:?}");
let empty = json!({"path": ""});
assert!(open_tool::untargeted("file open", empty.as_object().unwrap()).is_none());
let number = json!({"path": 3});
assert!(
open_tool::untargeted("browser open-diff-viewer", number.as_object().unwrap()).is_none()
);
// A key longer than `open` takes (an action takes up to 200 characters) keeps the action.
let long = json!({"path": "/work/a.txt", "idempotency_key": "k".repeat(150)});
assert!(open_tool::untargeted("file open", long.as_object().unwrap()).is_none());
}

/// A history read answers the scrollback as lines of plain text, unstyled, where the
/// daemon's rows of styled runs were 88k characters for 300 lines (tests/tool-evals
/// r13a). Asked for `styled`, it answers the rows as the daemon sends them.
Expand Down
Loading
Loading