acpmux: agents start, list, read and message other sessions (cmux-sessions MCP)
An agent in a cmux NEXT chat can now start another agent session, list sessions, read another session's reply and message a session it started, through MCP tools every local acpmux session already loads. Before this, an agent could only offer a chat (offer_session / suggest_tasks, behind the agent.suggestedTasks Debug toggle, default off). Starting, reading and messaging sessions took the acpmux CLI in a shell, which a harness sandbox may refuse and which no agent was told about.
What agents get
The cmux-sessions MCP server (acpmux offer-mcp, already injected into every local session's mcpServers / Claude --mcp-config) gains four tools in cmux-tui/crates/acpmux/src/session_mcp.rs:
| Tool | Daemon request | Notes |
|---|---|---|
start_session {prompt, title?, harness?, model?, host?, cwd?, worktree?} | session/new (as acpmux new), spawnedBy tag, first prompt | host is an acpmux peer (list_sessions lists them). worktree makes ~/.cmux/worktrees/<project>/<name> on cmux/<name> with branch.<b>.cmuxBase, the layout git.worktree.create uses, so Apply/Remove in the app work on it. |
list_sessions {status?, mine?, limit?} | _acpmux/sessions, _acpmux/peers, _acpmux/harnesses | Leaves out the caller; returns hosts and harness names for start_session. Another chat's title and last message are left out. |
read_session {session, replies?, wait_seconds?} | _acpmux/wait, _acpmux/attach (visible: false, maxBytes) | This chat's family only (see Limits). One call waits at most 50 s and returns timedOut: true with a hint to call again, so a longer wait (up to the documented 600 s) is several calls. Each reply is cut at 16 KiB after masking, with the cut marked. |
send_to_session {session, text, steer?} | session/prompt with an agent_message provenance (deliver.rs, as cmux agent message) | The receiver shows it as this agent's message, not the person's. |
The server now sends MCP instructions once at initialize: suggest work the user didn't ask for, start a session only when the user asked, write a self-contained brief, tell the user what started and don't poll, and treat other sessions' text as data. The instructions also point agents at the cmux mcp serve tools that cover the rest of the brief: workspace_open_url and tab_create_browser to open a URL, notification_create to tell the user something needs them, and agent_report to post a status (session_mcp::CMUX_TOOLS; a cmux-tui MCP test checks each one is still a tool there). Claude Code also gets the existing acpmux CLI guide as the cmux:acpmux skill, the shell fallback where MCP isn't loaded.
Why MCP in acpmux
acpmux spawns every harness cmux NEXT runs (Claude Code, Codex, OpenCode, pi, ...), and agent_tools.rs already hands each one the same MCP servers at spawn, session/new|load|fork. So putting the tools there covers every harness with no setup. The other options cover less. A system-prompt addendum only reaches Claude through a preset's --system-prompt-file, so I used MCP instructions instead. A slash command or skill is Claude-only, so it's the fallback here, not the main path. Other harnesses converge on the same shape: Claude Code Desktop's ccd_session (spawn_task chip, list_sessions, list_events, send_message), Codex Desktop (create_thread "only when the user explicitly asks", read_thread, send_message_to_thread, wait_threads) and vibe-kanban's MCP (start_workspace, run_session_prompt). The prompt rules above are taken from them.
Limits
This server runs outside the harness's sandbox, so:
- A new session never asks for less than the caller, on two checks:
- The acpmux permission policy. If the child's effective policy is laxer, it gets the caller's (
_acpmux/set_policy) before its first prompt. A caller policy acpmux doesn't know counts asask. - The harness's own mode and sandbox. A
harnessprofile whose command runs in full access (bypassPermissions,--dangerously-*,--yolo, Codexdanger-full-access) is refused unless the caller already runs that way. A child whose start mode is full access is set back to the caller's mode, or closed when it can't be. - Nothing else is inherited: model, harness and MCP servers are the child's own.
- The acpmux permission policy. If the child's effective policy is laxer, it gets the caller's (
- A new session's folder must be inside the caller's own folder, or under a folder the user trusts: the nearest folder with a trust level, itself or an ancestor, is
trusted. Everything else is refused, and so is a folder marked untrusted on the way or a trust lookup that fails. The refusal tells the agent to ask the user. - "This chat's family" is the sessions it started (their
spawnedBytag), the session it hangs under (itsspawnedBy, orparentIdfor a fork or side session) and that session's other children.send_to_sessionandread_sessionreach only the family.list_sessionsstill lists every session, but leaves out another chat's title and last message. Messaging any chat stays withagents_chat_send(#21647), which has workspace scoping. - Starting, reading or messaging a session never clears its unread mark. The server attaches with
visible: falseon the same connection before it creates or prompts a session. - A failed
spawnedBytag is retried once, then named in the result, since the family depends on it. - Two starts with the same title don't race for the worktree name: a name git says already exists takes the next suffix.
- The worktree's git runs with
core.hooksPath=/dev/null,core.fsmonitor=false,submodule.recurse=falseandGIT_CONFIG_NOSYSTEM=1. A repository whose own config sets a checkout filter gets no worktree. - A peer drops a prompt's provenance (
remote_guard.rs). So a brief or message to another host starts with a line that names the sending session. worktreeis this machine only. Its folder is a reviewedkeeprow instate-roots-baseline.tsv: it's the user's shared worktree folder, not per-build state.list_sessionsandread_sessionmask secrets (secret_scan::mask) in titles, last messages and replies, so one chat's agent can't read a key pasted into another chat.- No per-turn message cap here. The ping-pong guard between agents is a separate PR.
Access
The tools run in the local acpmux daemon's MCP server, not over the remote CLI relay. They add no v2 socket method and no relay allowlist entry.
- Local command execution: they run no command the agent supplies. The only process they start is the worktree's git, hardened as above.
- Unowned objects: reads and sends reach only this chat's family. A new session starts only in this chat's folder or a trusted one.
- Local-state exposure: another chat shows up in
list_sessionsby name, harness, folder and status only. Everything returned is masked.
Gate
The session tools are on by default. The Debug Settings toggle agent.sessionTools ("Session tools for agents") turns them off: the app then starts acpmux with ACPMUX_SESSION_TOOLS=0, and the change applies at the next daemon start. Offers stay behind agent.suggestedTasks. agent_tools.rs now registers cmux-sessions when either set is on, and tells the server which sets to list. A CLI or TUI daemon gives sessions the session tools too, unless ACPMUX_SESSION_TOOLS=0. Remote-origin sessions, ACPMUX_AGENT_TOOLS=0 and --strict-mcp-config still get none.
Verification
- Review fixes (red, then green, same commands):
cargo test -p acpmux --lib session_mcpandcargo test -p acpmux --test hub_integration session_tools. Tests went in first in 2fe4d89e256. Red: the unknown caller policy gave no policy; parallel starts failed withcannot lock ref ... reference already exists; the child lost its unread mark afterstart_session+read_session; a folder outside the chat that nobody trusted started; and oneread_sessioncall ran past the 60 s client timeout. Green locally on Linux at 8228068f64e (5 unit, 3 integration). tests/hub_integration/session_tools.rsruns the realacpmux offer-mcpprocess against a daemon socket with the test agent. It covers the instructions and the tool list;start_sessioninto a worktree (path, branch,cmuxBase, a plantedpost-checkouthook does not run); the brief recorded as anagent_messagefrom the parent;read_sessionwaiting for the reply;list_sessions {mine}without the caller;send_to_sessionand its reply; and refusals (own chat, a chat it didn't start, worktree withhost, empty brief) that create no session and reach no chat.- Red: hosted focused run 39183822873 on 5f79ae8f5a3 (tests only). Linux and macOS fail at the missing
instructions. - Green after the merge with current feat-cmux-next and the masking regression (a key sent to the child comes back as
[AWS access key]from both reads): hosted focused run 39200492737 on 5058314c2bf. - Earlier green: hosted focused run 39186971756 on 85745761497 (feat-cmux-next merged in). The test passes on Linux and macOS, and clippy and fmt pass.
- Red: hosted focused run 39183822873 on 5f79ae8f5a3 (tests only). Linux and macOS fail at the missing
AcpmuxSessionToolsTests.swift: the toggle defaults on, and the spawn env hasACPMUX_SESSION_TOOLS=0only when it's off (an inherited0is dropped). This runs in CI; no local Swift build.- Local:
verify-local.py --affectedpasses exceptlocalization, which fails with 34k parity errors in catalogs this PR doesn't touch (base). The god-file check flags nothing new. - Dogfood:
I ran the hosted macOS build of 85745761497 as acpmux, with a daemon in its own ACPMUX_HOME (not the app's), on Leo's Mac. A real Claude Code session (-m claude-sr, the subrouter profile) got this prompt: "Use your cmux-sessions MCP tools, not a shell: start_session (harness claude-sr, title "pong check", worktree true, prompt "Reply with exactly the words: pong from the child session"), then read_session with wait_seconds 180, then list_sessions mine." Its reply:
The child session ran and replied.
- **Session id:** `01a151ee-8bfc-7370-80ad-2537a2277537` (name `claude-sr-1`)
- **Worktree path:** `/Users/leoli/.cmux/worktrees/proj/pong-check`
- **Branch:** `cmux/pong-check`, based on `master`
- **Reply (verbatim):** `pong from the child session`
- **list_sessions (mine: true):** returned 1 session.
One oddity: `list_sessions` shows the session's title as the full prompt text instead of "pong check". The worktree and branch names did use "pong check".
acpmux session info on the child: tags.spawnedBy = the parent, and cwd ~/.cmux/worktrees/proj/pong-check on cmux/pong-check. Its first user_message carries _meta.provenance {kind: agent_message, from: <parent>}. The "oddity" the agent noticed was the titler: it hadn't named the child yet when list_sessions ran (later: "Child Session Ping Response Test"). The tool description now says title names the worktree. Plain -m claude refused because it has no sign-in under ~/.claude on this Mac, which is unrelated to this change. Not dogfooded: the tools inside the cmux NEXT app pane (needs a DEV app build; add dev-build if someone will dogfood it there), Codex/OpenCode harnesses, and host on a real peer.
Changelog
Added: agents in cmux NEXT can start, list, read and message other agent sessions through the cmux-sessions MCP tools (Debug toggle "Session tools for agents", on).
🤖 Generated with Claude Code