cmux NEXT: /btw side sessions share one right pane; pane relay allows _acpmux/btw
Summary
This is the Swift part of /btw side sessions in cmux NEXT. Two sibling PRs carry the rest: the acpmux PR adds _acpmux/btw, and the web PR adds the /btw composer flow and the side session's composer.
One right pane for side sessions. Today every chat.sideChat opens a new split. Repeated New side chat and /btw side sessions of one chat now open as focused tabs in the pane that the chat's first side session split off to the right. The first one still opens a split on the right (open-beside), as it did before. A side tab that the person moved back into the chat's pane, or closed, no longer counts, so the next side session splits again. AgentSidePanes keeps one tracker per chat session and runs one open at a time, the same way AgentSubagentPanes does. A second request made while the first split is still moving waits for that move, so it finds the new pane. The app side reuses openBeside: it opens the tab in that pane and focuses it, or uses the existing split. AgentSidePanes.open(_:chat:pane:place:) in CmuxNextAgentPane takes one placement closure, so CmuxNextApp doesn't grow (the size cap fails a PR that adds App lines). The header hook openSide now passes the chat session along with the side session.
Pane relay allows _acpmux/btw. This follows the shared contract: params sessionId, question and an optional model. The request is session-scoped, so the chat must already be in the pane's scope. It needs a fresh gesture, the same as session/prompt and _acpmux/ask, because it runs an agent turn. Any other param is refused by the params rule. The reply's sessionId joins the pane's scope through AcpmuxPaneSessions.starting, the same way a fork's does. That lets the page's follow-up chat.sideChat {sessionId} open the side session. Side sessions never reach chat.sideChat otherwise, because it refuses any session outside the pane's scope.
_acpmux/ask removed from the pane relay. The web PR stops sending the inline side question and drops it from the Rust policy.json. The Swift allowlist, its session scope, gesture rule and params entry drop it too, so the two lists stay mirrored. A frame for it is now refused (methodRefused) even with a gesture, and it never reaches acpmux.
Tracker pruning. AgentSidePanes.chat drops idle trackers of other chats that have no side tab still open, so the registry doesn't keep one for every chat that ever had a side session.
Gated by a Debug Settings toggle, off by default. There's no Labs. /btw follows Debug Settings' Side sessions (/btw) (agentPane.debug.sideSessions, named like base's agentPane.debug.foldLongThoughts, AgentPaneTunables.sideSessions). 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). In Release and RC the tunable store is inert, so it stays off.
- The page learns about the toggle from
sideSessionsin the chat's header state (AgentPaneHeaderState, sent in thepane.tabStatereply and theheaderStatepush), the same way it learns aboutfloatingChat. The state's init reads the toggle, so the app's header watch observes it and pushes a change to open chats, with no reopen needed. The web side (#20812) treats anything buttrueas off. - Ask a Side Question… moved to a follow-up, #21083. Its action exports (
plans/cmux-next/actions.md,action-surfaces.json) are under FREEZE tokensac869c8838cdand0e7a8e0e3b9c. So this PR doesn't add the palette action, its context bit, its catalog strings or its exports. Against base, its diff onplans/cmux-next/and on the ActionCatalog and MiscHandlers catalogs is empty. #21083 restores all of that, with its red-then-green test, once those tokens are released. - Each PR is safe if it lands alone. Without #20812, the page still reads the old experiment (off). Without this PR, the page gets no
sideSessionsand stays off.
Relay analysis (agent pane transport, not RemoteRelayCommandPolicy)
No v2 socket method or cmux ssh relay allowlist changes. The new page method is _acpmux/btw on the agent pane's acpmux relay.
- Local command/content execution: it runs one agent turn in a new session. acpmux makes that session read-only: the harness's read-only mode where one exists, and a policy of approve-reads or stricter. The same gesture rule as a prompt applies. C1 still refuses
mcpServers, and the params rule refuses every param outside the three in the contract. - Unowned objects:
sessionIdmust be a session in the pane's scope. The pane can only open the new session because the daemon's reply to the pane's own request named it. - Local-state exposure: the side session reads the main chat's transcript, which the pane can already read. acpmux enforces the side session's file reads with its read-only mode and policy.
The Rust mirror of these lists (cmux-tui/crates/cmux-agent-pane-policy/policy.json and sessions.rs STARTING) is not in this PR. Its the_allowlist_is_what_the_page_sends test requires policy.json to match direct.ts's this.request(...) calls exactly, so those changes belong in the web PR next to direct.ts's _acpmux/btw request. Putting them here would leave this PR red until the web PR lands.
Testing
Behavior tests in CmuxNextAgentPaneTests:
-
AgentSideSessionPaneTests.aSideSessionGoesOutLikeAPromptAndOpensBesideItsChat: tests the relay boundary against the acpmux stand-in._acpmux/btwneeds a gesture and is refused for a chat outside the pane's scope or with an extra param. A refused request never reaches acpmux. The allowed one goes out with the contract params. Its reply's side session then opens throughchat.sideChatbeside its chat, and a session the pane never started is refused. -
AgentSideSessionPaneTests.laterSideSessionsOfAChatOpenAsTabsInItsOneRightPane: the first side session splits right. A second one, asked for mid-move, and a third both land as focused tabs in that pane. Once those tabs are closed or moved back, the next one splits again. -
AgentSideSessionPaneTests.aChatWhoseSideTabsAllClosedKeepsNoTracker: the tracker survives while a side tab is open and is dropped once all of them have closed. -
AgentPaneSideQuestionRelayTests.theInlineSideQuestionIsRefusedAndNeverReachesAcpmux: replaces the old test, which asserted that_acpmux/askgoes out. -
AgentPaneSideSessionsHeaderTests(Debug toggle): with the toggle off (the default), the header state tells the pagesideSessions: false. With it on,sideSessions: true. The palette test,AgentPaneSideQuestionPaletteTests, moved to #21083 with the action.
Every suite run below used cmux-ci run --class light --script scripts/ci/run-swift-testing-suites.sh --arg Packages/macOS/CmuxNext (Xcode 26.6):
- Right-pane reuse and
_acpmux/btw. Red at 84bf1eb31c09 (--red, old-behavior stubs): the_acpmux/btwrelay test and the right-pane test both failed on behavior._acpmux/btwwas refused, and events were["split s1 to right-1", "split s2 to right-2"]where a tab was expected. Green at 68795c812ee4: 345 of 345 suites passed. _acpmux/askrefusal. Red at ad725335277d (--red):theInlineSideQuestionIsRefusedAndNeverReachesAcpmuxfailed because the relay still sent_acpmux/askto acpmux.- Tracker pruning. Red at 2f4a2c319507 (
--red):aChatWhoseSideTabsAllClosedKeepsNoTrackerfailed because the tracker was kept after its side tabs closed. - Debug toggle. Red at e8c87d4af648 (
--red, step 904cba97f610ab60392e6267): the tests don't compile without the toggle. - Green at ea171ca1ef87 (step bf028dcb8e028b5abb339ea0): 371 suites passed and 3 failed, including
AgentPaneSideSessionsHeaderTests,AgentSideSessionPaneTestsandAgentPaneSideQuestionRelayTests.ClassicShortcutsUpgradeTestsandSettingsSchemaExportTestsare known base reds.PinnedPageTestsis a timing failure that changes from run to run (other runs failedRestoreWindowFieldsDebugTestsorNewTabHoverPreviewTestsinstead). - Release compile at ea171ca1ef87 (
check-release-compile.sh, isolated, step ddf3b473b6e5eac46949cbf9): BUILD SUCCEEDED. - Release compile at head adbaf9e1a57a (exact head, after the base merge with #21089's fix; isolated, step a92e473441d8d13a6d8b4dc5, aws-m4pro-7): BUILD SUCCEEDED.
- Action exports: unchanged against base, since the action moved to #21083.
Pre-push checks on the head:
check-concurrency.sh: ok.crash_ratchet.py: ok.check-no-godfiles.sh: no new god files, and the ActionRegistry growth left with the action. AgentPaneModel (+2 lines) was already over the limit at base.check-system-blue.py: ok.lint-ios-package-conventions.sh: only the base error (BuiltInAdBlockerSetting; this PR doesn't touch it).~/.cache/osm/freeze-check.sh 20826: clean.check-app-size.sh --base: CmuxNextApp is not above base.check-l10n.sh: 0 errors.
Localization: this PR adds no user-facing strings. The Debug Settings label is plain English, like every other tunable. The action's strings moved to #21083.
Not verified: no live run in a DEV build (toggling in the Debug window, the live header push), so there's no before/after recording yet.
Changelog
Added: Side chats and /btw side sessions of a chat open as tabs in one pane on its right; /btw is behind Debug Settings' Side sessions (/btw), off by default
🤖 Generated with Claude Code