Agent pane: /btw opens a side session beside the chat
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/btwwith{sessionId, question}(direct.tsbtw), then postschat.sideChatwith the returnedsessionId. 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, assessionList.tsalready 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
sideQuestionfocuses the composer with/btwat its start, keeping anything already typed after it. With Side sessions off it does nothing. - The inline aside it replaces (
_acpmux/askfrom the page, SideQuestion.tsx, its gallery entry, styles, three strings and its browser test) is removed. Theside-questionexperiment 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
sideQuestionprefill tests time out or mismatch, and the mid-turn test never sees_acpmux/btw. -
Green on b7306ce0ca1a: 5 pass, 0 fail.
-
Follow-up (no
/btwin 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 refusedchat.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.tabStatewithsideSessionson or off, and it adds two cases. With Side sessions off, the palette'ssideQuestionleaves the composer empty. Turning it on with aheaderStatepush 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.jsonlists_acpmux/btwinrequestsandsession_scoped, with gesture rulealwaysand known paramssessionId,questionandmodelonly.sessions.rsadds_acpmux/btwtoSTARTING, 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/askleaves the policy, because the page no longer sends it.the_allowlist_is_what_the_page_sendsrequires the policy to match direct.ts exactly. The Swift allowlist can drop it too.- New cases cover the frame passing, known params (
cwdand_metaare 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. Likesession/prompt, it needs a fresh user gesture. - Unowned objects:
sessionIdmust 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_orderon the test commit b64e94428aba (run 38665111669). It failed on Linux and macOS withbtw about the pane's session ... got Refuse { refusal: MethodRefused, method: "_acpmux/btw" }. - Green:
--fullon 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_sourcesandthe_built_in_policy_parses. The run as a whole still failed, on a single unrelated test,cmux-tuian_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