acpmux: _acpmux/btw side sessions about a chat

Merged
TE
22d ago
+949-14

/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 side with the chat id (the lists hide it, as _acpmux/side does), 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.rs marking, 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 always approve-reads (or the chat's own ask / deny-all when stricter, so it never widens), with rules autoDeny on every tool kind except read, search, fetch and think.

    HarnessModereadOnlyWhy
    Claudeplanmodeanalyzes, never modifies files or runs commands
    Codex in process (codex app-server bridge)read-onlymodecmux-codex-acp config.rs MODES: sandbox read-only (networkAccess: false), approvals on-request, so an escalation asks and the rules refuse it
    Codex through the codex-acp adapterrefusednoneits read-only is on-request plus workspace-write: it edits and runs commands without asking (plans/cmux-next/acp-remote-guard.md)
    opencoderefusednonebuild never asks; plan still runs bash and user config can allow everything (acp-remote-guard.md); a refused family in web_modes.rs
    another familyits first webAskingModes entrypolicyasks before every change, and the rules refuse each
    no asking moderefusednonedata.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's plan. While such a profile is configured, Claude's own /btw is 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.rs set_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_mode as non-fatal for ACP harnesses (existing behavior).
    • Harness-side: Claude's permissions.allow still applies in plan mode, and Codex MCP tools run outside its sandbox.
  • Model: param, else config.json btw.model.<profile or family>, else haiku for 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_child event, as _acpmux/ask does 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/btw creates a session and starts an agent process on this machine with a read-approving policy. remote_guard.rs refuses it for Origin::Web and Origin::Peer (data.reason: btw.remote), whatever the chat. A chat that lives on a peer is forwarded there by peer_forward.rs and 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.rs gates _acpmux/btw like session/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 exactly sessionId, 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, side tag, approve-reads, plan mode, --model haiku in 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/btw falls 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 in read-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_mode fails, 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_writes now 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 return readOnly: "policy" instead of an error; the wrapper lacks "untrusted"); the Claude and model-order tests pass
  • green on 0ff95a5ca11b (run 38682575038): all 7 side_session tests 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 offers build and plan is 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 with readOnly: "mode" on haiku; the variant tags closed the wrapper); the other 5 pass
  • green on acab89e1c149 (run 38694363932): all 8 side_session tests executed and pass on the Linux and macOS test jobs; lint and MSRV pass. Then merged feat-cmux-next 628debcd7a8b (conflict in config.rs: both the btw and titles sections 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