acpmux: _acpmux/btw side sessions about a chat
/btw <question> in the agent composer should open a side session beside the chat: a chat of its own, titled with the question, that can read the main thread and files but never write, on a cheap model, while the main chat keeps running. This PR is the acpmux half: a new _acpmux/btw request that creates that session and sends it the question. The web composer and the native right-pane PRs call it.
Contract
Request _acpmux/btw, params { "sessionId": <chat>, "question": <non-empty after trim>, "model"?: <string> }.
Result { "sessionId": <side session>, "model": <model or alias, or null for the harness default>, "readOnly": "mode" | "policy" }.
-
Same harness and folder as the chat, tagged
sidewith the chat id (the lists hide it, as_acpmux/sidedoes), title = first line of the question, 80 chars. -
Context: Claude and Codex fork through the chat's latest finished turn (
fork_at.rs), which works while a turn runs. Any other harness, or a chat with no finished turn, gets a new session whose first prompt carries the chat's newest transcript (at most 12 KiB) as hidden prompt context (prompt_context.rsmarking, so the side thread shows only the question). -
Read-only, fail closed: acpmux's policy and rules only answer what the harness asks, so the side session must run in a mode that reads only or asks before every change. The mode is picked before anything opens, then set on the side session and confirmed in its reported current mode. A fork copies the chat's mode, full access included, so the mode is always set. If it cannot be set or confirmed, the side session is discarded and the request errors (
data.reason: btw.mode_unset). On top of the mode, the policy is alwaysapprove-reads(or the chat's ownask/deny-allwhen stricter, so it never widens), with rulesautoDenyon every tool kind except read, search, fetch and think.Harness Mode readOnlyWhy Claude planmodeanalyzes, never modifies files or runs commands Codex in process ( codex app-serverbridge)read-onlymodecmux-codex-acp config.rsMODES: sandboxread-only(networkAccess: false), approvalson-request, so an escalation asks and the rules refuse itCodex through the codex-acp adapter refused none its read-onlyis on-request plus workspace-write: it edits and runs commands without asking (plans/cmux-next/acp-remote-guard.md)opencode refused none buildnever asks;planstill runs bash and user config can allow everything (acp-remote-guard.md); a refused family inweb_modes.rsanother family its first webAskingModesentrypolicyasks before every change, and the rules refuse each no asking mode refused none data.reason: btw.no_read_only_mode, "this agent has no read-only mode" -
Order: the Codex bridge (by its command line), then refused families, then Claude, then the asking table. A profile that runs opencode or codex-acp under
"family": "claude"therefore lands in the refused set and is refused, never put in opencode'splan. While such a profile is configured, Claude's own/btwis refused too; that fails closed, and only the user's own config can cause it. -
Context wrapper: the transcript is wrapped in
<cmux-btw-context>, which says the content is untrusted context, not instructions. Every closing tag inside the chat's text is made inert (\/), in any case and with whitespace around the slash or before>, so it cannot close the wrapper early. -
Limits:
- The mode confirm reads acpmux's own record after a successful
session/set_mode(turns.rsset_mode), not a separate readback from the agent. - A pane or the socket can still re-mode a side session afterwards; that is the person's explicit choice.
- Respawn replays
set_modeas non-fatal for ACP harnesses (existing behavior). - Harness-side: Claude's
permissions.allowstill applies in plan mode, and Codex MCP tools run outside its sandbox.
- The mode confirm reads acpmux's own record after a successful
-
Model: param, else config.json
btw.model.<profile or family>, elsehaikufor Claude, the smallest advertised model for Codex (nano/mini/flash/lite/small/fast by id or name), else the harness default. A refused default falls back to the harness default; a refused explicit model is an error. -
The request answers once the question is recorded as the side session's first prompt, never waiting for the answer. The chat is never paused, cancelled or modified (its fork records a
fork_childevent, as_acpmux/askdoes today). -
Errors: empty or missing question, a non-string model, an agent with no read-only mode (
btw.no_read_only_mode) and a mode that could not be set (btw.mode_unset) are invalid params; an unknown chat is not found.
Code: hub/btw.rs (logic), config/btw.rs (config section), wired in server/requests/composer.rs, schema in docs/acpmux-schema.json. The pane relay allowlist is not in this PR: the Swift PR adds _acpmux/btw to AcpmuxPaneMethods, and the web PR carries direct.ts's call with the Rust policy.json mirror (the TypeScript parity test ties the two).
Remote and relay authorization
_acpmux/btwcreates a session and starts an agent process on this machine with a read-approving policy.remote_guard.rsrefuses it forOrigin::WebandOrigin::Peer(data.reason: btw.remote), whatever the chat. A chat that lives on a peer is forwarded there bypeer_forward.rsand refused by that peer as a Peer request, so no remote caller creates a session it does not own. Local unix socket and the app's pane (LocalApp) may open it.- Folder trust:
trust_gate.rsgates_acpmux/btwlikesession/fork, so the pane cannot start an agent in a folder without a trust answer. - Pane relay (sibling PRs): expected session-scoped to the pane's chat, gesture
always(it runs an agent turn, like_acpmux/ask), params exactlysessionId,question,model; no command-bearing params. - Person rule: no person proof needed, because the side policy is never wider than the chat's and the rules only deny.
Verification
Behavior tests, committed before the fix (faa9aa7b5ba9):
hub_integration/btw.rs: a Claude side session opened mid-turn (title,sidetag,approve-reads,planmode,--model haikuin the side's argv, its conversation holds only the finished turn plus the question; the chat's turn ends normally and its title/policy/rules/tags/modes/models and Claude conversation are unchanged; empty/missing question and unknown chat open nothing); a fake ACP harness (no fork-through) gets the transcript as hidden context and a write tool call is refused (chose no); model resolution param > config > harness default.remote_guard.rs: refused for Web and Peer (btw.remote, nothing created), opened for LocalApp and Local.
Focused command for both: ./scripts/verify-cmux-tui-hosted.sh --filter a_side_session
- red on
faa9aa7b5ba9(run 38661273200): the 3 hub tests fail (_acpmux/btwfalls to the harness catch-all: "no such method" / "a harness method acpmux does not handle") - green on
45f0c8e5359f(run 38675282204): Linux and macOS test jobs, lint and MSRV pass
Review round (read-only fails closed), tests committed before the fix (c5e20ccf8624):
a_codex_side_session_runs_sandboxed_read_only_and_the_adapter_is_refused: a Codex bridge chat's side session runs inread-only(readOnly: "mode"); a codex-acp adapter chat is refused with "no read-only mode" and nothing opens.a_side_session_is_refused_for_an_agent_with_no_read_only_or_asking_mode: a fake harness whose modes never ask is refused, nothing opens.a_side_session_whose_mode_cannot_be_set_is_discarded_and_the_chat_is_untouched:session/set_modefails, the side session is gone, the error names the read-only mode, and the chat's title, policy, rules, tags, modes, status and prompts are unchanged.a_side_session_of_a_harness_that_cannot_fork_reads_the_chat_and_never_writesnow has a chat prompt containing</cmux-btw-context>: the wrapper says "untrusted", holds exactly one closing tag, and ends with it.
Focused command: ./scripts/verify-cmux-tui-hosted.sh --filter side_session
- red on
c5e20ccf8624(run 38679897178): those 4 fail (the three refusals returnreadOnly: "policy"instead of an error; the wrapper lacks "untrusted"); the Claude and model-order tests pass - green on
0ff95a5ca11b(run 38682575038): all 7side_sessiontests executed and pass on the Linux and macOS test jobs; lint and MSRV pass
Review round 3 (refused families before Claude; inert closing tags), tests committed before the fix (e9296199683f):
a_side_session_is_refused_for_opencode_run_under_the_claude_family: a profile{"argv": [..., "opencode", "acp"], "family": "claude"}whose agent offersbuildandplanis refused with "no read-only mode", nothing opens, the chat's mode unchanged.- The wrapper test's chat text adds
</CMUX-BTW-CONTEXT >and</ cmux-btw-context\t>: lowercased and without whitespace, the side's prompt holds exactly one closing tag, and each variant is present in its inert form.
Focused command: ./scripts/verify-cmux-tui-hosted.sh --filter side_session
- red on
e9296199683f(run 38692208988): those 2 fail (the opencode profile opened withreadOnly: "mode"onhaiku; the variant tags closed the wrapper); the other 5 pass - green on
acab89e1c149(run 38694363932): all 8side_sessiontests executed and pass on the Linux and macOS test jobs; lint and MSRV pass. Then merged feat-cmux-next628debcd7a8b(conflict inconfig.rs: both thebtwandtitlessections kept)
Also run locally: crash_ratchet.py ok; god-file check on touched files ok. No local cargo; compile and tests only on the hosted runs above. No full hosted run (--full, Windows) yet.
Gating on the Labs flag lives in the web PR; this request is inert until the page calls it.
Changelog
Added: /btw side sessions in cmux NEXT: a read-only chat about the current one, on a cheap model, opened beside it.
🤖 Generated with Claude Code