`), `app.sessionOrder` (`string[]`), `app.activeSessionId` (`string | null`)
+- Produces: `app.openSplitPicker()`, `app.openSplitPane(sessionId)`, `app.closeSplitPane()`, `app._splitPane` (the live `SplitTerminalPane` instance or `null`), `app._splitSessionId` (the Pane B session id or `null`)
+
+- [ ] **Step 1: Write the failing test**
+
+```typescript
+// test/browser/split-pane-orchestration.test.ts
+import { test, expect } from '@playwright/test';
+
+const BASE_URL = process.env.CODEMAN_TEST_URL || 'http://localhost:3151';
+
+test('opening and closing a split reparents and restores .terminal-wrap', async ({ page }) => {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2000); // let SSE/handleInit settle
+
+ const sessionIds = await page.evaluate(async () => {
+ const create = async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
+ });
+ return (await res.json()).data.id;
+ };
+ return { a: await create(), b: await create() };
+ });
+
+ await page.evaluate((id) => window.app.selectSession(id), sessionIds.a);
+ await page.waitForTimeout(500);
+
+ const beforeSplit = await page.evaluate(
+ () => document.querySelector('.terminal-split-container') === null
+ );
+ expect(beforeSplit).toBe(true);
+
+ await page.evaluate((id) => window.app.openSplitPane(id), sessionIds.b);
+ await page.waitForTimeout(1000);
+
+ const duringSplit = await page.evaluate(() => ({
+ hasContainer: document.querySelector('.terminal-split-container') !== null,
+ wrapIsChildOfContainer:
+ document.querySelector('.terminal-split-container > .terminal-wrap') !== null,
+ hasPaneB: document.querySelector('.terminal-pane-b') !== null,
+ }));
+ expect(duringSplit.hasContainer).toBe(true);
+ expect(duringSplit.wrapIsChildOfContainer).toBe(true);
+ expect(duringSplit.hasPaneB).toBe(true);
+
+ await page.evaluate(() => window.app.closeSplitPane());
+ await page.waitForTimeout(500);
+
+ const afterClose = await page.evaluate(() => ({
+ hasContainer: document.querySelector('.terminal-split-container') === null,
+ wrapRestored: document.querySelector('.main .terminal-wrap') !== null,
+ }));
+ expect(afterClose.hasContainer).toBe(true);
+ expect(afterClose.wrapRestored).toBe(true);
+
+ await page.evaluate(async (ids) => {
+ await fetch(`/api/sessions/${ids.a}`, { method: 'DELETE' });
+ await fetch(`/api/sessions/${ids.b}`, { method: 'DELETE' });
+ }, sessionIds);
+});
+
+test('the split picker excludes the active session', async ({ page }) => {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2000);
+
+ const sessionId = await page.evaluate(async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
+ });
+ return (await res.json()).data.id;
+ });
+
+ await page.evaluate((id) => window.app.selectSession(id), sessionId);
+ await page.waitForTimeout(500);
+
+ const pickerExcludesActive = await page.evaluate((id) => {
+ window.app.openSplitPicker();
+ const items = Array.from(document.querySelectorAll('.split-picker-item'));
+ return !items.some((el) => el.getAttribute('data-session-id') === id);
+ }, sessionId);
+ expect(pickerExcludesActive).toBe(true);
+
+ await page.evaluate(async (id) => {
+ await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
+ }, sessionId);
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npm run test:browser -- test/browser/split-pane-orchestration.test.ts`
+Expected: FAIL — `app.openSplitPane`/`closeSplitPane`/`openSplitPicker` are undefined.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `src/web/public/terminal-split.js`, after the `SplitTerminalPane` class and its `global.SplitTerminalPane = SplitTerminalPane;` line, still inside the same IIFE closure is not required — this part attaches to `CodemanApp.prototype` at module scope like every other UI mixin file:
+
+```javascript
+Object.assign(CodemanApp.prototype, {
+ openSplitPicker() {
+ if (this._splitPane) {
+ this.closeSplitPane();
+ return;
+ }
+ const candidates = window.CodemanSplitPane.buildSplitPickerSessions(
+ this.sessions,
+ this.sessionOrder,
+ this.activeSessionId
+ );
+ const existing = document.getElementById('splitPickerMenu');
+ if (existing) existing.remove();
+
+ const menu = document.createElement('div');
+ menu.id = 'splitPickerMenu';
+ menu.className = 'split-picker-menu';
+ if (candidates.length === 0) {
+ menu.innerHTML = 'No other sessions to split with
';
+ } else {
+ menu.innerHTML = candidates
+ .map(
+ (c) =>
+ `${escapeHtml(c.label)}
`
+ )
+ .join('');
+ }
+ document.body.appendChild(menu);
+ const splitBtn = document.querySelector('.btn-split');
+ if (splitBtn) {
+ const rect = splitBtn.getBoundingClientRect();
+ menu.style.position = 'fixed';
+ menu.style.top = `${rect.bottom + 4}px`;
+ menu.style.right = `${window.innerWidth - rect.right}px`;
+ }
+ },
+
+ openSplitPane(sessionId) {
+ if (this._splitPane) this.closeSplitPane();
+
+ const wrap = document.querySelector('.terminal-wrap');
+ const parent = wrap.parentElement;
+
+ const container = document.createElement('div');
+ container.className = 'terminal-split-container';
+
+ const divider = document.createElement('div');
+ divider.className = 'split-divider';
+
+ const paneB = document.createElement('div');
+ paneB.className = 'terminal-pane-b';
+ const session = this.sessions.get(sessionId);
+ paneB.innerHTML = `
+
+
+ `;
+
+ parent.insertBefore(container, wrap);
+ container.appendChild(wrap);
+ wrap.style.flexBasis = '50%';
+ container.appendChild(divider);
+ container.appendChild(paneB);
+ paneB.style.flexBasis = '50%';
+
+ this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'));
+ this._splitPane.connect();
+ this._splitSessionId = sessionId;
+
+ this._installSplitDividerDrag(divider, wrap, paneB);
+ },
+
+ closeSplitPane() {
+ if (!this._splitPane) return;
+ this._splitPane.destroy();
+ this._splitPane = null;
+ this._splitSessionId = null;
+
+ const container = document.querySelector('.terminal-split-container');
+ if (!container) return;
+ const wrap = container.querySelector('.terminal-wrap');
+ const parent = container.parentElement;
+ wrap.style.flexBasis = '';
+ parent.insertBefore(wrap, container);
+ container.remove();
+
+ if (this.fitAddon) this.fitAddon.fit();
+ this.sendResize?.(this.activeSessionId, { force: true })?.catch?.(() => {});
+ },
+
+ _installSplitDividerDrag(divider, wrap, paneB) {
+ let dragging = false;
+
+ const onMove = (e) => {
+ if (!dragging) return;
+ const container = divider.parentElement;
+ const rect = container.getBoundingClientRect();
+ const rawPercent = ((e.clientX - rect.left) / rect.width) * 100;
+ const percent = window.CodemanSplitPane.clampDividerPercent(rawPercent);
+ wrap.style.flexBasis = `${percent}%`;
+ paneB.style.flexBasis = `${100 - percent}%`;
+ if (this.fitAddon) this.fitAddon.fit();
+ this._splitPane?.fit();
+ };
+
+ const onUp = () => {
+ dragging = false;
+ divider.classList.remove('dragging');
+ document.removeEventListener('mousemove', onMove);
+ document.removeEventListener('mouseup', onUp);
+ };
+
+ divider.addEventListener('mousedown', () => {
+ dragging = true;
+ divider.classList.add('dragging');
+ document.addEventListener('mousemove', onMove);
+ document.addEventListener('mouseup', onUp);
+ });
+ },
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npm run test:browser -- test/browser/split-pane-orchestration.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Run the full gate to check for regressions**
+
+Run: `npm test`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/web/public/terminal-split.js test/browser/split-pane-orchestration.test.ts
+git commit -m "feat(split-pane): add open/close orchestration, picker, and divider drag"
+```
+
+---
+
+### Task 6: Auto-collapse on either session ending
+
+**Files:**
+- Modify: `src/web/public/terminal-split.js` (hook into existing SSE session-lifecycle handlers)
+- Test: `test/browser/split-pane-auto-collapse.test.ts` (new, Playwright)
+
+**Interfaces:**
+- Consumes: the existing `_onSessionDeleted(data)` handler in `app.js` (find it via `[SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted']` in the `app.js` handler map) — this task wraps it rather than replacing it.
+- Produces: no new public interface; behavior only.
+
+- [ ] **Step 1: Write the failing test**
+
+```typescript
+// test/browser/split-pane-auto-collapse.test.ts
+import { test, expect } from '@playwright/test';
+
+const BASE_URL = process.env.CODEMAN_TEST_URL || 'http://localhost:3151';
+
+test('deleting the Pane B session auto-collapses the split', async ({ page }) => {
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
+ await page.waitForTimeout(2000);
+
+ const sessionIds = await page.evaluate(async () => {
+ const create = async () => {
+ const res = await fetch('/api/sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workingDir: '/tmp', mode: 'shell' }),
+ });
+ return (await res.json()).data.id;
+ };
+ return { a: await create(), b: await create() };
+ });
+
+ await page.evaluate((id) => window.app.selectSession(id), sessionIds.a);
+ await page.waitForTimeout(500);
+ await page.evaluate((id) => window.app.openSplitPane(id), sessionIds.b);
+ await page.waitForTimeout(1000);
+
+ // Delete Pane B's session from "outside" (simulating the SSE event another
+ // client's delete would produce, by hitting the DELETE route directly).
+ await page.evaluate(async (id) => {
+ await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
+ }, sessionIds.b);
+ await page.waitForTimeout(1000);
+
+ const collapsed = await page.evaluate(() => ({
+ hasContainer: document.querySelector('.terminal-split-container') === null,
+ splitPaneNulled: window.app._splitPane === null,
+ }));
+ expect(collapsed.hasContainer).toBe(true);
+ expect(collapsed.splitPaneNulled).toBe(true);
+
+ await page.evaluate(async (id) => {
+ await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
+ }, sessionIds.a);
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npm run test:browser -- test/browser/split-pane-auto-collapse.test.ts`
+Expected: FAIL — deleting Pane B's session leaves the split container in place with a dead `SplitTerminalPane`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `src/web/public/terminal-split.js`, inside the same `Object.assign(CodemanApp.prototype, {...})` block from Task 5, add one more method and wrap the existing handler registration. Since `_onSessionDeleted` is defined in `app.js` and this file loads after it (load order 7.5 vs app.js's 6), monkey-patch it here rather than editing `app.js` — this keeps all split-pane logic in one file per the "files that change together live together" principle:
+
+```javascript
+ _wireSplitAutoCollapse() {
+ const original = this._onSessionDeleted.bind(this);
+ this._onSessionDeleted = (data) => {
+ if (this._splitSessionId === data.id) {
+ this.closeSplitPane();
+ } else if (this._splitPane && this.activeSessionId === data.id) {
+ // Pane A's session ended: promote Pane B by closing the split and
+ // selecting its session as the new (single) active pane.
+ const promoted = this._splitSessionId;
+ this.closeSplitPane();
+ if (promoted) this.selectSession(promoted);
+ }
+ original(data);
+ };
+ },
+```
+
+Then, at the bottom of `terminal-split.js` (module scope, after the `Object.assign` call), wire it once at load time:
+
+```javascript
+document.addEventListener('DOMContentLoaded', () => {
+ if (window.app && typeof window.app._wireSplitAutoCollapse === 'function') {
+ window.app._wireSplitAutoCollapse();
+ }
+});
+```
+
+Note: `window.app` is assigned during `app.js` init, which per load order runs before `terminal-split.js`'s `DOMContentLoaded` listener fires (both are deferred to the same event, and script *evaluation* order — app.js at position 6, terminal-split.js at 7.5 — determines listener *registration* order, so app.js's own init logic that creates `window.app` runs first). If this ordering assumption proves wrong when this step is actually run, the fallback is to call `_wireSplitAutoCollapse()` directly from the end of `terminal-split.js` at module-evaluation time instead of inside `DOMContentLoaded`, since `window.app` is what needs to exist, not the DOM.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `npm run test:browser -- test/browser/split-pane-auto-collapse.test.ts`
+Expected: PASS. If it fails specifically because `window.app._onSessionDeleted` was undefined at wire time, switch the wiring approach per the note in Step 3 (call `_wireSplitAutoCollapse()` at module scope, not inside `DOMContentLoaded`) and re-run.
+
+- [ ] **Step 5: Run the full gate to check for regressions**
+
+Run: `npm test`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/web/public/terminal-split.js test/browser/split-pane-auto-collapse.test.ts
+git commit -m "feat(split-pane): auto-collapse split when either session ends"
+```
+
+---
+
+### Task 7: Documentation — architecture-invariants.md entry
+
+**Files:**
+- Modify: `docs/architecture-invariants.md` (one new section, following the existing pattern for feature write-ups in this file)
+
+**Interfaces:** none — documentation only.
+
+- [ ] **Step 1: Write the section**
+
+Add a new section (placed near other terminal/session-UI entries, e.g. after "Header button visibility"):
+
+```markdown
+**Split-pane sessions** (`showSplitButton`, header button, default OFF): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is a new, independent `SplitTerminalPane` (terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket. ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession()`, never by trying to hot-swap the lightweight `SplitTerminalPane` object into the primary singleton state. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. Design: `docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md`.
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/architecture-invariants.md
+git commit -m "docs: add split-pane sessions architecture-invariants entry"
+```
+
+---
+
+## Self-Review Notes (completed during plan authoring)
+
+- **Spec coverage**: side-by-side split (Task 5), draggable divider (Task 5), 50/50 default clamped 20–80% (Task 1 + Task 5), button+picker trigger excluding active session (Task 2 + Task 5), Pane A untouched (Global Constraints + every task explicitly avoids editing `terminal-ui.js`), Pane B feature-reduced (Task 4's fileoverview comment + Global Constraints), no persistence (no task writes a localStorage key), auto-collapse both directions (Task 6), subagent windows unchanged (no task touches `subagent-windows.js` or `ultracode-windows.js` — confirmed nothing in this plan needs to, since they already float independent of `.terminal-wrap`'s layout).
+- **Placeholder scan**: none found — every step has real code or a real doc paragraph.
+- **Type/name consistency checked**: `SplitTerminalPane` (class name) used identically in Tasks 4, 5, 6; `_splitPane`/`_splitSessionId` (instance state) used identically in Tasks 5, 6; `openSplitPicker`/`openSplitPane`/`closeSplitPane` (method names) used identically in Tasks 2 (button onclick), 5 (definition), 6 (auto-collapse caller).
diff --git a/docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md b/docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md
index bf386c65..507ab265 100644
--- a/docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md
+++ b/docs/superpowers/specs/2026-09-15-split-pane-sessions-design.md
@@ -50,6 +50,15 @@ Showing two sessions at once therefore requires a second, independently
alive xterm + WebSocket pair running concurrently — not a layout change to
one shared instance.
+**Related prior art**: `detachSession(id)` (app.js) already opens one session
+in a genuinely separate browser window (`isSoloWindow` mode) with its own
+independent WebSocket, and two of those can already be snapped side-by-side
+today with zero new code. That covers "two sessions visible at once" but not
+what this spec is for: one Codeman window with two panes and a divider you
+can drag without leaving your seat, each still a full participant in that
+window's floating subagent windows, header, and settings. This spec builds
+past detach, not a duplicate of it.
+
**Server-side check (done, not just assumed)**: `MAX_WS_PER_SESSION = 5`
(`src/web/routes/ws-routes.ts`), scoped by `clientId:tabNonce`
(`ws-connection-registry.ts`). Splitting always opens a *different* session