Agent pane: /btw opens a side session beside the chat

Merged
TE
16d ago
+814-414

With side sessions on, /btw <question> in the agent composer now opens a read-only side session about the chat in the shared right pane, instead of the inline one-shot answer above the composer. The chat's turn keeps running: /btw is a cmux command, not a prompt, so it is never queued, steered or cancelled.

This is the web piece of three. The acpmux piece adds _acpmux/btw (and owns policy.json and the pane method list). The Swift piece opens side sessions as tabs in one right pane and adds the palette's Side Question action.

What changes in the pane

  • /btw <question> calls _acpmux/btw with {sessionId, question} (direct.ts btw), then posts chat.sideChat with the returned sessionId. The composer keeps the question until acpmux returns the side session's id, then clears it. Once that id is back the question counts as sent, so it clears even if the app can't open the side session, and sending again can't start a duplicate. In that case the app's reason shows above the composer. When acpmux refuses, the question stays and its plain-words reason shows above the composer with Dismiss.
  • A side session (a session tagged side, as sessionList.ts already reads it) shows "Reply in this session…" in its empty composer and doesn't offer /btw. Its header and tab title come from the session title, which acpmux sets to the question, so the page needs nothing extra for them.
  • The page command sideQuestion focuses the composer with /btw at its start, keeping anything already typed after it. With Side sessions off it does nothing.
  • The inline aside it replaces (_acpmux/ask from the page, SideQuestion.tsx, its gallery entry, styles, three strings and its browser test) is removed. The side-question experiment had been its only way in.

Gallery: the composer entry has a new side-session variant (path ?entry=agent-pane.composer#/agent-pane.composer/side-session).

Gate

Off by default: Debug Settings' Side sessions (/btw) toggle (agentPane.debug.sideSessions, added in #20826). There's no Labs. To turn it on in a DEV or NIGHTLY build, open the Debug window (palette: Open Debug Settings), go to Pages and switch on Side sessions (/btw). Open chats pick up the change without being reopened: the app's header watch observes the toggle and pushes the new header state (not checked live in a DEV build).

Every entry point reads one helper, aside/sideSessions.ts useSideSessions(). It returns sideSessions from the chat's header state, which the app sends in its pane.tabState reply and its headerState push. Anything but true counts as off, so this page stays off with an app that doesn't send the field, and it's safe to land alone. The side-question localStorage experiment is removed.

Contract

Request _acpmux/btw { sessionId, question, model? } returns { sessionId, model, readOnly }. The page sends no model, so acpmux picks its fast default, and it reads only sessionId. A result with no id counts as a failure ("Couldn’t start a side session.").

Evidence

Behavior test at the pane boundary: test/agent-pane-btw-side-session.test.ts, using the pane's dev entry, a stand-in acpmux over WebSocket with a running turn, and Playwright WebKit. It checks that /btw mid-turn sends _acpmux/btw with the question and posts chat.sideChat with the returned id, that nothing is prompted or cancelled and a later reply chunk still streams in, that a refusal keeps the question and shows the reason, that with the gate off no side session starts, that a started side session clears the question even when the app can't open it (Enter again sends nothing), that a side session shows the placeholder and doesn't offer /btw, and that sideQuestion prefills /btw and then sends.

CMUX_BROWSER_TESTS=1 bun test test/agent-pane-btw-side-session.test.ts (from webviews/)

  • Red on the test commit de7936f1c008 (base code): 1 pass, 4 fail. Gate off passes, as it is today's behavior. The refusal note, placeholder (got "Do anything") and sideQuestion prefill tests time out or mismatch, and the mid-turn test never sees _acpmux/btw.

  • Green on b7306ce0ca1a: 5 pass, 0 fail.

  • Follow-up (no /btw in a side session, clear the question once started): red on the test commit 9e40d0040d30 with 4 pass and 2 fail. A side session still sent _acpmux/btw, and the question stayed after the app refused chat.sideChat. Green on 403b1ffbb410: 6 pass, 0 fail. That test commit was pushed together with its fix, not before it.

  • Debug toggle (replaces the experiment): the test now has the app answer pane.tabState with sideSessions on or off, and it adds two cases. With Side sessions off, the palette's sideQuestion leaves the composer empty. Turning it on with a headerState push reaches an open chat without reopening it. Red on the test commit 03be2ed2fa0c (page still on the experiment): 3 pass, 5 fail. Green on e51e0592efcd: 8 pass, 0 fail. Green again on the merged head 279926c7e353: 8 pass, 0 fail.

The first react-apps-check run (on 279926c7e353) read a side session's placeholder before the session list had tagged the session side, and got "Do anything". The test now waits up to 10 s for "Reply in this session…": 8 pass, 0 fail, on the merged head 9c2dc3061dc6. The head 28d0ba241841 changes only comments.

On the merged head 279926c7e353 (and again on 9c2dc3061dc6, after regenerating strings): vp check (types), vp fmt --check, vp lint, the full bun run check, bun test test/ui-rules.test.ts and gen-strings.mjs --check all pass.

Also run on 403b1ffbb410: vp check (types), vp fmt --check, vp lint, bun run check, bun test test/ui-rules.test.ts, and node scripts/pages/gen-strings.mjs --check.

Localization: three new agent-pane keys (sideSession.placeholder, sideSession.failed, sideSession.failedBecause) with all 21 locales, generated strings regenerated. The three removed sideQuestion.* keys had no other users, and sideQuestion.dismiss is kept for the note's button. scripts/localize-changes reports no new macOS keys.

Pane policy (Rust mirror) and relay authorization

The page's acpmux connection goes through the host's pane policy. This PR updates the Rust mirror, cmux-tui/crates/cmux-agent-pane-policy, to match the Swift allowlist in the Swift sibling PR:

  • policy.json lists _acpmux/btw in requests and session_scoped, with gesture rule always and known params sessionId, question and model only.
  • sessions.rs adds _acpmux/btw to STARTING, so the side session id in the reply joins the pane's scope. That's how the pane may then attach to and reply in the side session.
  • _acpmux/ask leaves the policy, because the page no longer sends it. the_allowlist_is_what_the_page_sends requires the policy to match direct.ts exactly. The Swift allowlist can drop it too.
  • New cases cover the frame passing, known params (cwd and _meta are refused), the gesture rule, the session scope (another session or no session is refused), the reply joining the scope, and two full-check cases. The full-check count goes up by two (46 with base's subagent_stop cases).

The questions from remote-relay-authorization.md:

  • Local command or content execution: none from the request. It carries no command-bearing params, and anything else (cwd, _meta, command) breaks the params rule. acpmux starts the side session in the main session's own harness and cwd, read-only (read-only mode and approve-reads-or-stricter policy). The question is text, and it reaches that read-only agent only as its first prompt. Like session/prompt, it needs a fresh user gesture.
  • Unowned objects: sessionId must be one of the pane's sessions (session_scoped), so a pane can't ask about another chat. Nothing is closed, renamed or deleted. The only new object is the side session acpmux creates, and it joins the scope of the pane that asked.
  • Local-state exposure: the reply holds only the new session id, the model name and the read-only kind. The side session can read the main thread and files, which the pane can already read through its own session.

Policy evidence comes from hosted Rust runs, since nothing ran locally:

  • Red: focused run full_check_order on the test commit b64e94428aba (run 38665111669). It failed on Linux and macOS with btw about the pane's session ... got Refuse { refusal: MethodRefused, method: "_acpmux/btw" }.
  • Green: --full on the fix cc282d075103 (run 38675268525). On Linux, every policy test passed: the_allowlist_is_what_the_page_sends, full_check_order, params_rule, trust_gate, frames, gestures, session_scope, scope_and_sources and the_built_in_policy_parses. The run as a whole still failed, on a single unrelated test, cmux-tui an_absolute_diff_path_is_sent_unchanged, which panicked on "Address already in use" (a port collision on the runner). This PR doesn't touch that crate.

This adds no relay (cmux ssh) path. The page reaches chat.sideChat through its existing host bridge.

Not verified here: the Swift palette action and the shared right pane (Swift PR), and a real harness answering in the side session (acpmux PR).

/btw side sessions ship as three PRs: #20847 (acpmux _acpmux/btw), #20812 (web: /btw, composer, Rust pane policy), #20826 (Swift: one right pane, palette action, Swift pane allowlist). #20847 and #20826 are merged into feat-cmux-next; this is the last piece. Until it lands, base still runs the old inline aside (_acpmux/ask, the side-question experiment), which the Swift host already refuses.

Base merge (2026-10-20)

Merged feat-cmux-next at a1e33779f823. The /btw failure note now shows through base's notice slot (Notice id="btw-failure", error tier). The side session's "Reply in this session…" replaces only the idle prompt text, so base's Esc-again and mid-turn steer/queue hints still win, and base's prompt suggestion still shows over it. Policy cases keep both base's agent_pair_resume cases and the btw ones.

Changelog

  • Added: /btw <question> in an agent chat opens a read-only side session beside it while the chat keeps working (behind the Side sessions (/btw) Debug Settings toggle, off by default).

Agent session: cosmic-gray

🤖 Generated with Claude Code