| null // per-tab divider (if split)
- }
-]
-activeTabId = "tab-1"
-```
-
-## Constraints
-
-- **Max 5 tabs** (up to 2 panes each = max 10 PTY sessions)
-- **Default label:** "Shell N" — double-click to rename
-- **No persistence:** tabs and sessions are lost on page refresh
-- **Backend unchanged:** tabs are purely frontend; each pane still calls `/api/session`
-
-## Tab Bar UI
-
-- 32px height, positioned above the pane container
-- Translucent/blurred background matching existing toolbar aesthetic
-- Theme-aware (adapts to light/dark)
-- Each tab shows: label + close "x" (visible on hover or when active)
-- "+" button at end (hidden when 5 tabs reached)
-- Active tab has a subtle bottom border accent
-
-## Keyboard Shortcuts
-
-### Tab shortcuts (new)
-| Shortcut | Action |
-|----------|--------|
-| `Ctrl+Shift+T` | New tab |
-| `Ctrl+Shift+[` | Previous tab |
-| `Ctrl+Shift+]` | Next tab |
-| `Ctrl+Shift+1-5` | Jump to tab by number |
-
-### Pane shortcuts (moved from Ctrl+Shift to Alt+Shift)
-| Shortcut | Action |
-|----------|--------|
-| `Alt+Shift+D` | Split pane within active tab |
-| `Alt+Shift+W` | Close pane (closes tab if last pane) |
-| `Alt+Shift+[` | Previous pane within tab |
-| `Alt+Shift+]` | Next pane within tab |
-
-## Tab Lifecycle
-
-1. **Create:** "+" button or `Ctrl+Shift+T`. Creates tab with one pane, spawns PTY, switches to it.
-2. **Switch:** Click tab or `Ctrl+Shift+[/]`. Hides current container, shows target, refits panes, focuses active pane.
-3. **Rename:** Double-click label, inline edit, Enter to confirm, Escape to cancel.
-4. **Close:** Click "x" or close last pane via `Alt+Shift+W`. Terminates all PTY sessions in that tab. If last tab, auto-creates a new "Shell 1".
-
-## Implementation Scope
-
-### Modified files
-- `static/index.html` — tab bar HTML/CSS, JS refactored to wrap panes inside tabs
-
-### Unchanged files
-- `app.py` — backend has no tab concept
-- `static/poll-worker.js` — already supports multiple panes by paneId
-
-### Estimated changes
-- ~40 lines CSS (tab bar styling)
-- ~150 lines net JS change (tab management functions, refactored pane logic)
-
-## Out of Scope (YAGNI)
-- Drag-to-reorder tabs
-- Tab persistence across page reloads
-- Tab-specific themes or settings
-- Session reconnection / PTY resumption (separate project)
diff --git a/docs/plans/2026-03-08-multi-tab-terminals-implementation.md b/docs/plans/2026-03-08-multi-tab-terminals-implementation.md
deleted file mode 100644
index c7517bf..0000000
--- a/docs/plans/2026-03-08-multi-tab-terminals-implementation.md
+++ /dev/null
@@ -1,946 +0,0 @@
-# Multi-Tab Terminals Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Add browser-style tabs to the web terminal where each tab owns its own independent split-pane layout, with renamable labels and a 5-tab cap.
-
-**Architecture:** Purely frontend change to `static/index.html`. The existing flat `panes[]` array and `activePaneId` get wrapped inside a `tabs[]` array. Each tab owns a dedicated pane container DOM element. Switching tabs toggles CSS `display` on these containers. The backend and poll-worker are unchanged.
-
-**Tech Stack:** Vanilla JS, xterm.js, existing CSS conventions (translucent/blurred, theme-aware)
-
----
-
-### Task 1: Add Tab Bar HTML and CSS
-
-**Files:**
-- Modify: `static/index.html:14-17` (pane container CSS)
-- Modify: `static/index.html:199-255` (HTML body, before pane-container)
-
-**Step 1: Add tab bar CSS**
-
-Insert after line 12 (`#status` rule) and before line 14 (`/* Pane container */`):
-
-```css
- /* Tab bar */
- #tab-bar {
- display: flex; align-items: center; height: 32px; width: 100vw;
- background: rgba(255,255,255,0.04);
- border-bottom: 1px solid rgba(255,255,255,0.08);
- backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
- overflow-x: auto; overflow-y: hidden;
- user-select: none; flex-shrink: 0;
- }
- .tab {
- display: flex; align-items: center; gap: 6px;
- padding: 0 12px; height: 100%; cursor: pointer;
- font-size: 12px; white-space: nowrap;
- border-right: 1px solid rgba(255,255,255,0.06);
- transition: background 0.15s;
- position: relative;
- }
- .tab:hover { background: rgba(255,255,255,0.06); }
- .tab.active {
- background: rgba(255,255,255,0.08);
- border-bottom: 2px solid rgba(100,150,255,0.6);
- }
- .tab-label {
- outline: none; border: none; background: none;
- color: inherit; font: inherit; padding: 0;
- min-width: 30px; max-width: 120px;
- cursor: inherit;
- }
- .tab-label:focus {
- cursor: text;
- border-bottom: 1px solid rgba(100,150,255,0.5);
- }
- .tab-close {
- opacity: 0; font-size: 10px; padding: 2px 4px;
- border-radius: 3px; border: none; background: none;
- color: inherit; cursor: pointer; transition: opacity 0.15s, background 0.15s;
- line-height: 1;
- }
- .tab:hover .tab-close, .tab.active .tab-close { opacity: 0.6; }
- .tab-close:hover { opacity: 1 !important; background: rgba(255,255,255,0.1); }
- #new-tab-btn {
- padding: 0 10px; height: 100%; border: none; background: none;
- color: inherit; font-size: 16px; cursor: pointer;
- opacity: 0.5; transition: opacity 0.15s;
- }
- #new-tab-btn:hover { opacity: 1; }
- #new-tab-btn:disabled { opacity: 0.2; cursor: default; }
-```
-
-**Step 2: Update pane container height**
-
-Change line 15 from:
-```css
- #pane-container { display: flex; flex-direction: row; height: 100vh; width: 100vw; }
-```
-to:
-```css
- .tab-pane-container { display: flex; flex-direction: row; height: calc(100vh - 33px); width: 100vw; }
- .tab-pane-container.hidden { display: none; }
-```
-
-Note: The old `#pane-container` ID is no longer used. Each tab creates its own `.tab-pane-container` div dynamically.
-
-**Step 3: Update pane divider CSS**
-
-Change line 20-27 from `#pane-divider` to class-based:
-```css
- .pane-divider {
- flex: 0 0 4px; cursor: col-resize;
- background: rgba(128,128,128,0.15);
- transition: background 0.15s;
- z-index: 1;
- }
- .pane-divider:hover, .pane-divider.dragging {
- background: rgba(100,150,255,0.5);
- }
-```
-
-**Step 4: Add tab bar HTML**
-
-Replace line 255 (`
`) with:
-```html
-
-
-
-
-```
-
-**Step 5: Verify the page loads without errors**
-
-Open in browser, confirm the tab bar strip renders (empty, with just the "+" button). Terminal won't work yet because the JS still references the old `#pane-container`.
-
-**Step 6: Commit**
-
-```bash
-git add static/index.html
-git commit -m "feat: add tab bar HTML and CSS"
-```
-
----
-
-### Task 2: Refactor State Model — Introduce Tabs Array
-
-**Files:**
-- Modify: `static/index.html:356-383` (State and Pane Object Model sections)
-
-**Step 1: Replace flat pane state with tabs model**
-
-Replace the Pane Object Model section (lines 367-383):
-```javascript
- // ── Pane Object Model ─────────────────────────────────────────
- // Each pane: { id, element, term, fitAddon, searchAddon, sessionId }
- let panes = [];
- let activePaneId = null;
- let paneIdCounter = 0;
-
- function getActivePane() {
- return panes.find(p => p.id === activePaneId) || panes[0];
- }
-
- function focusPane(id) {
- activePaneId = id;
- panes.forEach(p => {
- p.element.classList.toggle('active', p.id === id);
- if (p.id === id) p.term.focus();
- });
- }
-```
-
-With:
-```javascript
- // ── Tab & Pane Object Model ───────────────────────────────────
- // Tab: { id, label, panes[], activePaneId, paneContainer, divider }
- // Pane: { id, element, term, fitAddon, searchAddon, sessionId }
- const MAX_TABS = 5;
- let tabs = [];
- let activeTabId = null;
- let tabIdCounter = 0;
- let paneIdCounter = 0;
-
- function getActiveTab() {
- return tabs.find(t => t.id === activeTabId) || tabs[0];
- }
-
- function getActivePane() {
- const tab = getActiveTab();
- if (!tab) return null;
- return tab.panes.find(p => p.id === tab.activePaneId) || tab.panes[0];
- }
-
- function getAllPanes() {
- return tabs.flatMap(t => t.panes);
- }
-
- function focusPane(id) {
- const tab = getActiveTab();
- if (!tab) return;
- tab.activePaneId = id;
- tab.panes.forEach(p => {
- p.element.classList.toggle('active', p.id === id);
- if (p.id === id) p.term.focus();
- });
- }
-```
-
-**Step 2: Verify no syntax errors**
-
-Page will be broken (functions reference old `panes` global). That's expected — we fix the references in the next tasks.
-
-**Step 3: Commit**
-
-```bash
-git add static/index.html
-git commit -m "refactor: introduce tabs array data model"
-```
-
----
-
-### Task 3: Update Theme, Font, and Refit Functions for Tabs
-
-**Files:**
-- Modify: `static/index.html` — `applyTheme`, `setFontSize`, `setFontFamily`, `refitAllPanes` functions
-
-**Step 1: Update applyTheme**
-
-Replace line 403 (`panes.forEach(...)`) with:
-```javascript
- getAllPanes().forEach(p => { p.term.options.theme = preset.theme; });
-```
-
-**Step 2: Update setFontSize**
-
-Replace line 424 (`panes.forEach(...)`) with:
-```javascript
- getAllPanes().forEach(p => { p.term.options.fontSize = currentFontSize; });
-```
-
-**Step 3: Update setFontFamily**
-
-Replace line 434 (`panes.forEach(...)`) with:
-```javascript
- getAllPanes().forEach(p => { p.term.options.fontFamily = family; });
-```
-
-**Step 4: Update refitAllPanes to only refit the active tab's panes**
-
-Replace the `refitAllPanes` function:
-```javascript
- function refitAllPanes() {
- const tab = getActiveTab();
- if (!tab) return;
- tab.panes.forEach(p => {
- p.fitAddon.fit();
- if (p.sessionId) sendResize(p.term.cols, p.term.rows, p.sessionId);
- });
- }
-```
-
-**Step 5: Commit**
-
-```bash
-git add static/index.html
-git commit -m "refactor: update theme/font/refit to use tabs model"
-```
-
----
-
-### Task 4: Rewrite createPane to Accept a Parent Tab
-
-**Files:**
-- Modify: `static/index.html` — `createPane` function (lines ~773-838)
-
-**Step 1: Rewrite createPane**
-
-Replace the entire `createPane` function with:
-```javascript
- async function createPane(tab) {
- const id = 'pane-' + (++paneIdCounter);
- const container = tab.paneContainer;
- const element = document.createElement('div');
- element.className = 'pane';
- element.id = id;
-
- // Add divider before second pane
- if (tab.panes.length === 1) {
- const divider = document.createElement('div');
- divider.className = 'pane-divider';
- container.appendChild(divider);
- tab.divider = divider;
- setupDividerDrag(divider, tab);
- }
-
- container.appendChild(element);
-
- const term = new Terminal({
- cursorBlink: true,
- fontSize: currentFontSize,
- fontFamily: fontFamilies[currentFontFamily] || 'monospace',
- theme: themes[currentThemeName].theme
- });
-
- const fitAddon = new FitAddon.FitAddon();
- term.loadAddon(fitAddon);
- term.loadAddon(new WebLinksAddon.WebLinksAddon());
-
- let searchAddon = null;
- if (typeof SearchAddon !== 'undefined') {
- searchAddon = new SearchAddon.SearchAddon();
- term.loadAddon(searchAddon);
- }
-
- if (typeof ImageAddon !== 'undefined' && ImageAddon.ImageAddon) {
- term.loadAddon(new ImageAddon.ImageAddon({
- sixelSupport: true,
- sixelScrolling: true,
- iipSupport: true,
- enableSizeReports: true,
- storageLimit: 128
- }));
- }
-
- term.open(element);
- fitAddon.fit();
-
- const sid = await createSession();
- await sendResize(term.cols, term.rows, sid);
-
- term.write('\x1b[32mConnected. Type "claude" to start coding.\x1b[0m\r\n');
- term.write('\x1b[90mProjects in ~/projects auto-sync to Workspace on git commit.\x1b[0m\r\n');
- term.write('\x1b[90mCtrl+Shift+T new tab \u2502 Alt+Shift+D split pane \u2502 Alt+Shift+W close pane\x1b[0m\r\n\r\n');
-
- const pane = { id, element, term, fitAddon, searchAddon, sessionId: sid };
- term.onData(data => sendInput(data, pane.sessionId));
- pollWorker.postMessage({ type: 'start_poll', paneId: id, sessionId: sid });
-
- // Click to focus
- element.addEventListener('mousedown', () => focusPane(id));
-
- tab.panes.push(pane);
- focusPane(id);
-
- return pane;
- }
-```
-
-**Step 2: Commit**
-
-```bash
-git add static/index.html
-git commit -m "refactor: createPane now accepts parent tab"
-```
-
----
-
-### Task 5: Implement Tab Management Functions (createTab, switchTab, closeTab, renameTab)
-
-**Files:**
-- Modify: `static/index.html` — add new functions after createPane
-
-**Step 1: Add createTab function**
-
-Insert after the `createPane` function:
-```javascript
- // ── Tab Management ──────────────────────────────────────────────
- async function createTab() {
- if (tabs.length >= MAX_TABS) return null;
-
- const id = 'tab-' + (++tabIdCounter);
- const label = 'Shell ' + tabIdCounter;
-
- // Create per-tab pane container
- const paneContainer = document.createElement('div');
- paneContainer.className = 'tab-pane-container';
- paneContainer.id = id + '-panes';
- document.body.appendChild(paneContainer);
-
- const tab = {
- id,
- label,
- panes: [],
- activePaneId: null,
- paneContainer,
- divider: null
- };
-
- tabs.push(tab);
-
- // Render tab in the tab bar
- renderTabBar();
-
- // Switch to new tab (hides others)
- switchTab(id);
-
- // Create first pane
- await createPane(tab);
-
- updateTabButtons();
- return tab;
- }
-
- function switchTab(id) {
- const prevTab = getActiveTab();
- activeTabId = id;
-
- // Toggle pane container visibility
- tabs.forEach(t => {
- t.paneContainer.classList.toggle('hidden', t.id !== id);
- });
-
- // Update tab bar active state
- renderTabBar();
-
- // Refit panes in the newly visible tab and focus
- const tab = getActiveTab();
- if (tab && tab.panes.length > 0) {
- requestAnimationFrame(() => {
- refitAllPanes();
- const ap = tab.panes.find(p => p.id === tab.activePaneId) || tab.panes[0];
- if (ap) ap.term.focus();
- });
- }
- }
-
- function closeTab(id) {
- const tab = tabs.find(t => t.id === id);
- if (!tab) return;
-
- // Cleanup all panes in this tab
- tab.panes.forEach(p => {
- cleanupPane(p);
- p.term.dispose();
- });
-
- // Remove DOM
- tab.paneContainer.remove();
-
- // Remove from array
- tabs = tabs.filter(t => t.id !== id);
-
- // If we closed the active tab, switch to the last tab
- if (activeTabId === id) {
- if (tabs.length > 0) {
- switchTab(tabs[tabs.length - 1].id);
- }
- }
-
- // If no tabs left, create a new one
- if (tabs.length === 0) {
- tabIdCounter = 0;
- createTab();
- return;
- }
-
- renderTabBar();
- updateTabButtons();
- }
-
- function startRenameTab(id) {
- const labelEl = document.querySelector(`#tab-bar .tab[data-tab-id="${id}"] .tab-label`);
- if (!labelEl) return;
- labelEl.contentEditable = 'true';
- labelEl.focus();
-
- // Select all text
- const range = document.createRange();
- range.selectNodeContents(labelEl);
- window.getSelection().removeAllRanges();
- window.getSelection().addRange(range);
-
- function finishRename() {
- labelEl.contentEditable = 'false';
- const newLabel = labelEl.textContent.trim();
- const tab = tabs.find(t => t.id === id);
- if (tab && newLabel) {
- tab.label = newLabel;
- } else if (tab) {
- labelEl.textContent = tab.label; // revert empty
- }
- labelEl.removeEventListener('blur', finishRename);
- labelEl.removeEventListener('keydown', handleKey);
- // Refocus terminal
- const ap = getActivePane();
- if (ap) ap.term.focus();
- }
-
- function handleKey(e) {
- if (e.key === 'Enter') {
- e.preventDefault();
- finishRename();
- }
- if (e.key === 'Escape') {
- e.preventDefault();
- const tab = tabs.find(t => t.id === id);
- if (tab) labelEl.textContent = tab.label;
- finishRename();
- }
- }
-
- labelEl.addEventListener('blur', finishRename);
- labelEl.addEventListener('keydown', handleKey);
- }
-```
-
-**Step 2: Add renderTabBar function**
-
-```javascript
- function renderTabBar() {
- const tabBar = document.getElementById('tab-bar');
- const newTabBtn = document.getElementById('new-tab-btn');
-
- // Remove old tab elements (keep the + button)
- tabBar.querySelectorAll('.tab').forEach(el => el.remove());
-
- // Insert tabs before the + button
- tabs.forEach((tab, index) => {
- const tabEl = document.createElement('div');
- tabEl.className = 'tab' + (tab.id === activeTabId ? ' active' : '');
- tabEl.dataset.tabId = tab.id;
-
- const label = document.createElement('span');
- label.className = 'tab-label';
- label.textContent = tab.label;
- tabEl.appendChild(label);
-
- const closeBtn = document.createElement('button');
- closeBtn.className = 'tab-close';
- closeBtn.textContent = '\u00D7';
- closeBtn.title = 'Close tab';
- closeBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- closeTab(tab.id);
- });
- tabEl.appendChild(closeBtn);
-
- // Click to switch
- tabEl.addEventListener('click', () => switchTab(tab.id));
-
- // Double-click to rename
- tabEl.addEventListener('dblclick', (e) => {
- e.preventDefault();
- startRenameTab(tab.id);
- });
-
- tabBar.insertBefore(tabEl, newTabBtn);
- });
-
- // Update + button state
- newTabBtn.disabled = tabs.length >= MAX_TABS;
- }
-
- function updateTabButtons() {
- // Update toolbar pane buttons for active tab
- const tab = getActiveTab();
- const multi = tab && tab.panes.length > 1;
- document.getElementById('close-pane-btn').style.display = multi ? '' : 'none';
- document.getElementById('next-pane-btn').style.display = multi ? '' : 'none';
- document.getElementById('split-btn').style.display = (tab && tab.panes.length >= 2) ? 'none' : '';
- }
-```
-
-**Step 3: Commit**
-
-```bash
-git add static/index.html
-git commit -m "feat: implement createTab, switchTab, closeTab, renameTab"
-```
-
----
-
-### Task 6: Rewrite splitPane, closeActivePane, cyclePaneFocus for Tab Context
-
-**Files:**
-- Modify: `static/index.html` — replace `splitPane`, `closeActivePane`, `cyclePaneFocus` functions
-
-**Step 1: Replace splitPane**
-
-```javascript
- async function splitPane() {
- const tab = getActiveTab();
- if (!tab || tab.panes.length >= 2) return;
- status.textContent = 'Splitting...';
- status.style.display = '';
- try {
- await createPane(tab);
- // Reset flex for even split
- tab.panes.forEach(p => { p.element.style.flex = '1'; });
- refitAllPanes();
- updateTabButtons();
- status.style.display = 'none';
- } catch (e) {
- status.textContent = 'Split failed: ' + e.message;
- status.style.color = '#ff5555';
- }
- }
-```
-
-**Step 2: Replace closeActivePane**
-
-```javascript
- function closeActivePane() {
- const tab = getActiveTab();
- if (!tab) return;
-
- // If only one pane, close the whole tab
- if (tab.panes.length <= 1) {
- closeTab(tab.id);
- return;
- }
-
- const ap = tab.panes.find(p => p.id === tab.activePaneId) || tab.panes[0];
- if (!ap) return;
-
- cleanupPane(ap);
- ap.term.dispose();
- ap.element.remove();
-
- // Remove divider
- if (tab.divider) {
- tab.divider.remove();
- tab.divider = null;
- }
-
- tab.panes = tab.panes.filter(p => p.id !== ap.id);
-
- // Reset remaining pane to full width
- if (tab.panes.length === 1) {
- tab.panes[0].element.style.flex = '1';
- }
-
- focusPane(tab.panes[0].id);
- refitAllPanes();
- updateTabButtons();
- }
-```
-
-**Step 3: Replace cyclePaneFocus**
-
-```javascript
- function cyclePaneFocus(direction) {
- const tab = getActiveTab();
- if (!tab || tab.panes.length <= 1) return;
- const idx = tab.panes.findIndex(p => p.id === tab.activePaneId);
- const next = direction === 'next'
- ? (idx + 1) % tab.panes.length
- : (idx - 1 + tab.panes.length) % tab.panes.length;
- focusPane(tab.panes[next].id);
- }
-```
-
-**Step 4: Add cycleTabFocus**
-
-```javascript
- function cycleTabFocus(direction) {
- if (tabs.length <= 1) return;
- const idx = tabs.findIndex(t => t.id === activeTabId);
- const next = direction === 'next'
- ? (idx + 1) % tabs.length
- : (idx - 1 + tabs.length) % tabs.length;
- switchTab(tabs[next].id);
- }
-
- function jumpToTab(number) {
- // number is 1-indexed
- if (number >= 1 && number <= tabs.length) {
- switchTab(tabs[number - 1].id);
- }
- }
-```
-
-**Step 5: Commit**
-
-```bash
-git add static/index.html
-git commit -m "refactor: pane operations now scoped to active tab"
-```
-
----
-
-### Task 7: Update Divider Drag for Per-Tab Dividers
-
-**Files:**
-- Modify: `static/index.html` — `setupDividerDrag` function
-
-**Step 1: Update setupDividerDrag to accept tab parameter**
-
-Replace the function:
-```javascript
- function setupDividerDrag(divider, tab) {
- let dragging = false;
-
- divider.addEventListener('mousedown', e => {
- e.preventDefault();
- dragging = true;
- divider.classList.add('dragging');
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
- });
-
- document.addEventListener('mousemove', e => {
- if (!dragging || tab.panes.length < 2) return;
- const rect = tab.paneContainer.getBoundingClientRect();
- let pct = ((e.clientX - rect.left) / rect.width) * 100;
- pct = Math.max(15, Math.min(85, pct));
- tab.panes[0].element.style.flex = `0 0 ${pct}%`;
- tab.panes[1].element.style.flex = '1 1 0';
- refitAllPanes();
- });
-
- document.addEventListener('mouseup', () => {
- if (dragging) {
- dragging = false;
- divider.classList.remove('dragging');
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
- refitAllPanes();
- }
- });
- }
-```
-
-**Step 2: Commit**
-
-```bash
-git add static/index.html
-git commit -m "refactor: divider drag scoped to parent tab"
-```
-
----
-
-### Task 8: Update Keyboard Shortcuts
-
-**Files:**
-- Modify: `static/index.html` — the `document.addEventListener('keydown', ...)` block
-
-**Step 1: Replace the shortcut block**
-
-Replace the entire keyboard shortcuts section (lines 633-674) with:
-```javascript
- // ── Global Keyboard Shortcuts ──────────────────────────────────
- document.addEventListener('keydown', e => {
- // Ctrl+= : increase font
- if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.key === '=' || e.key === '+')) {
- e.preventDefault(); setFontSize(currentFontSize + 1); return;
- }
- // Ctrl+- : decrease font
- if (e.ctrlKey && !e.altKey && !e.shiftKey && e.key === '-') {
- e.preventDefault(); setFontSize(currentFontSize - 1); return;
- }
- // Ctrl+0 : reset font
- if (e.ctrlKey && !e.altKey && !e.shiftKey && e.key === '0') {
- e.preventDefault(); setFontSize(DEFAULT_FONT_SIZE); return;
- }
- // Ctrl+Shift+F : toggle search
- if (e.ctrlKey && e.shiftKey && e.key === 'F') {
- e.preventDefault(); toggleSearch(); return;
- }
- // Alt+V (Option+V) : toggle voice dictation
- if (e.altKey && !e.ctrlKey && !e.shiftKey && e.code === 'KeyV') {
- e.preventDefault();
- if (dictationActive) closeDictation();
- else startDictation();
- return;
- }
-
- // ── Tab shortcuts (Ctrl+Shift) ──
- // Ctrl+Shift+T : new tab
- if (e.ctrlKey && e.shiftKey && e.key === 'T') {
- e.preventDefault(); createTab(); return;
- }
- // Ctrl+Shift+W : close active pane (closes tab if last pane)
- if (e.ctrlKey && e.shiftKey && e.key === 'W') {
- e.preventDefault(); closeActivePane(); return;
- }
- // Ctrl+Shift+] : next tab
- if (e.ctrlKey && e.shiftKey && e.code === 'BracketRight') {
- e.preventDefault(); cycleTabFocus('next'); return;
- }
- // Ctrl+Shift+[ : prev tab
- if (e.ctrlKey && e.shiftKey && e.code === 'BracketLeft') {
- e.preventDefault(); cycleTabFocus('prev'); return;
- }
- // Ctrl+Shift+1-5 : jump to tab
- if (e.ctrlKey && e.shiftKey && e.code >= 'Digit1' && e.code <= 'Digit5') {
- e.preventDefault(); jumpToTab(parseInt(e.code.slice(-1))); return;
- }
-
- // ── Pane shortcuts (Alt+Shift) ──
- // Alt+Shift+D : split pane
- if (e.altKey && e.shiftKey && e.key === 'D') {
- e.preventDefault(); splitPane(); return;
- }
- // Alt+Shift+W : close pane
- if (e.altKey && e.shiftKey && e.key === 'W') {
- e.preventDefault(); closeActivePane(); return;
- }
- // Alt+Shift+] : next pane
- if (e.altKey && e.shiftKey && e.code === 'BracketRight') {
- e.preventDefault(); cyclePaneFocus('next'); return;
- }
- // Alt+Shift+[ : prev pane
- if (e.altKey && e.shiftKey && e.code === 'BracketLeft') {
- e.preventDefault(); cyclePaneFocus('prev'); return;
- }
- });
-```
-
-**Step 2: Update toolbar button tooltips**
-
-Update line 224 to reflect new shortcut:
-```html
-
-
-
-```
-
-**Step 3: Commit**
-
-```bash
-git add static/index.html
-git commit -m "feat: add tab keyboard shortcuts, move pane shortcuts to Alt+Shift"
-```
-
----
-
-### Task 9: Update Toolbar Button Wiring and Cleanup Functions
-
-**Files:**
-- Modify: `static/index.html` — toolbar button listeners, cleanupAllPanes, pagehide, updatePaneButtons
-
-**Step 1: Wire the new-tab button**
-
-Add after the existing toolbar button listeners:
-```javascript
- document.getElementById('new-tab-btn').addEventListener('click', () => createTab());
-```
-
-**Step 2: Update cleanupAllPanes to iterate all tabs**
-
-Replace:
-```javascript
- function cleanupAllPanes() {
- panes.forEach(p => cleanupPane(p));
- }
-```
-With:
-```javascript
- function cleanupAllPanes() {
- getAllPanes().forEach(p => cleanupPane(p));
- }
-```
-
-**Step 3: Update pagehide beacon to iterate all tabs**
-
-Replace the `pagehide` listener:
-```javascript
- window.addEventListener('pagehide', () => {
- getAllPanes().forEach(p => {
- if (p.sessionId) {
- navigator.sendBeacon(
- '/api/heartbeat',
- new Blob([JSON.stringify({ session_id: p.sessionId })], { type: 'application/json' })
- );
- }
- });
- });
-```
-
-**Step 4: Replace the old updatePaneButtons function**
-
-The `updatePaneButtons` function was already rewritten in Task 5 as `updateTabButtons`. Remove the old one (lines 930-935) if it still exists.
-
-**Step 5: Commit**
-
-```bash
-git add static/index.html
-git commit -m "feat: wire new-tab button, update cleanup for tabs"
-```
-
----
-
-### Task 10: Update init() to Create First Tab Instead of First Pane
-
-**Files:**
-- Modify: `static/index.html` — `init` function
-
-**Step 1: Replace init**
-
-```javascript
- async function init() {
- try {
- status.textContent = 'Initializing terminal...';
-
- if (typeof Terminal === 'undefined') throw new Error('xterm.js not loaded');
- if (typeof FitAddon === 'undefined') throw new Error('FitAddon not loaded');
-
- await createTab();
-
- status.textContent = 'Connected!';
- setTimeout(() => { status.style.display = 'none'; }, 1000);
-
- window.addEventListener('resize', () => refitAllPanes());
- window.addEventListener('beforeunload', () => cleanupAllPanes());
-
- } catch (e) {
- status.textContent = 'Error: ' + e.message;
- status.style.color = '#ff5555';
- console.error(e);
- }
- }
-```
-
-**Step 2: Remove the old `
`**
-
-This was already replaced in Task 1, but verify it's gone. The `createTab` function now creates per-tab pane containers dynamically.
-
-**Step 3: Verify end-to-end**
-
-Open the page in a browser. Verify:
-- Tab bar appears at top with "Shell 1" tab and "+" button
-- Terminal renders and works below the tab bar
-- Click "+" creates "Shell 2" with its own terminal session
-- Clicking tabs switches between them
-- Double-click a tab label to rename it
-- Click "x" on a tab to close it
-- `Ctrl+Shift+T` creates a new tab
-- `Ctrl+Shift+[/]` cycles tabs
-- `Alt+Shift+D` splits the active tab's pane
-- `Alt+Shift+W` closes a pane (or tab if last pane)
-- Closing the last tab auto-creates a new "Shell 1"
-- Max 5 tabs, "+" button disables at cap
-
-**Step 4: Commit**
-
-```bash
-git add static/index.html
-git commit -m "feat: init creates first tab, multi-tab terminals complete"
-```
-
----
-
-### Task 11: Remove Dead Code and Final Cleanup
-
-**Files:**
-- Modify: `static/index.html`
-
-**Step 1: Remove any remaining references to the old global `panes` variable**
-
-Search for `panes.forEach`, `panes.find`, `panes.length`, `panes.filter`, `panes.push`, `panes.pop`, `panes[` in the file. All should now reference `tab.panes` or `getAllPanes()`. Remove any dead code.
-
-**Step 2: Remove the old `#pane-container` and `#pane-divider` CSS rules if still present**
-
-They've been replaced by `.tab-pane-container` and `.pane-divider`.
-
-**Step 3: Verify no console errors**
-
-Open browser dev tools, check console is clean.
-
-**Step 4: Commit**
-
-```bash
-git add static/index.html
-git commit -m "chore: remove dead pane code, cleanup"
-```
diff --git a/docs/plans/2026-03-27-pat-auto-rotation-implementation.md b/docs/plans/2026-03-27-pat-auto-rotation-implementation.md
deleted file mode 100644
index df55204..0000000
--- a/docs/plans/2026-03-27-pat-auto-rotation-implementation.md
+++ /dev/null
@@ -1,510 +0,0 @@
-# PAT Auto-Rotation Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Implement automatic PAT rotation with 2-hour short-lived tokens, rotating every 90 minutes, with persistence to app secrets for restart survival. Fixes #81.
-
-**Architecture:** New `pat_rotator.py` module with a `PATRotator` class that runs a background daemon thread. Uses current PAT to mint new PAT, persists to Secrets API via SP credentials, writes to `~/.databrickscfg`, revokes old PAT. Integrated into `initialize_app()`.
-
-**Tech Stack:** Python, Flask, databricks-sdk, requests, threading
-
----
-
-### Task 1: Create PATRotator module with tests
-
-**Files:**
-- Create: `pat_rotator.py`
-- Create: `tests/test_pat_rotator.py`
-
-**Step 1: Write the failing tests**
-
-```python
-# tests/test_pat_rotator.py
-"""Tests for PAT auto-rotation — short-lived tokens with background refresh."""
-
-import os
-import time
-import threading
-from unittest import mock
-
-import pytest
-
-
-class TestPATRotation:
- """Core rotation logic."""
-
- def test_rotate_mints_new_token(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com", rotation_interval=5400, token_lifetime=7200)
- rotator._current_token = "old-pat"
- rotator._current_token_id = "old-id"
-
- mock_response_create = mock.MagicMock()
- mock_response_create.status_code = 200
- mock_response_create.json.return_value = {
- "token_value": "new-pat",
- "token_info": {"token_id": "new-id", "expiry_time": int(time.time() + 7200) * 1000}
- }
- mock_response_delete = mock.MagicMock()
- mock_response_delete.status_code = 200
-
- with mock.patch("pat_rotator.requests.post") as mock_post:
- mock_post.side_effect = [mock_response_create, mock_response_delete]
- with mock.patch.object(rotator, "_persist_token"):
- result = rotator._rotate_once()
-
- assert result is True
- assert rotator._current_token == "new-pat"
- assert rotator._current_token_id == "new-id"
-
- def test_rotate_revokes_old_token(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com")
- rotator._current_token = "old-pat"
- rotator._current_token_id = "old-id"
-
- mock_response_create = mock.MagicMock()
- mock_response_create.status_code = 200
- mock_response_create.json.return_value = {
- "token_value": "new-pat",
- "token_info": {"token_id": "new-id", "expiry_time": int(time.time() + 7200) * 1000}
- }
- mock_response_delete = mock.MagicMock()
- mock_response_delete.status_code = 200
-
- with mock.patch("pat_rotator.requests.post") as mock_post:
- mock_post.side_effect = [mock_response_create, mock_response_delete]
- with mock.patch.object(rotator, "_persist_token"):
- rotator._rotate_once()
-
- # Second call should be the delete with the OLD token id
- delete_call = mock_post.call_args_list[1]
- assert "token/delete" in delete_call[0][0]
- assert delete_call[1]["json"]["token_id"] == "old-id"
-
- def test_rotate_fails_gracefully_on_create_error(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com")
- rotator._current_token = "old-pat"
- rotator._current_token_id = "old-id"
-
- mock_response = mock.MagicMock()
- mock_response.status_code = 403
- mock_response.text = "Forbidden"
-
- with mock.patch("pat_rotator.requests.post", return_value=mock_response):
- result = rotator._rotate_once()
-
- assert result is False
- assert rotator._current_token == "old-pat" # Unchanged
-
- def test_rotate_continues_if_revoke_fails(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com")
- rotator._current_token = "old-pat"
- rotator._current_token_id = "old-id"
-
- mock_create = mock.MagicMock()
- mock_create.status_code = 200
- mock_create.json.return_value = {
- "token_value": "new-pat",
- "token_info": {"token_id": "new-id", "expiry_time": int(time.time() + 7200) * 1000}
- }
- mock_delete = mock.MagicMock()
- mock_delete.status_code = 500
-
- with mock.patch("pat_rotator.requests.post") as mock_post:
- mock_post.side_effect = [mock_create, mock_delete]
- with mock.patch.object(rotator, "_persist_token"):
- result = rotator._rotate_once()
-
- # New token should still be active even if old revocation failed
- assert result is True
- assert rotator._current_token == "new-pat"
-
-
-class TestTokenPersistence:
- """Writing token to ~/.databrickscfg."""
-
- def test_writes_databrickscfg(self, tmp_path):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com")
- rotator._databrickscfg_path = str(tmp_path / ".databrickscfg")
- rotator._write_databrickscfg("test-token")
-
- content = (tmp_path / ".databrickscfg").read_text()
- assert "test-token" in content
- assert "https://test.databricks.com" in content
-
- def test_databrickscfg_permissions(self, tmp_path):
- import stat
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com")
- rotator._databrickscfg_path = str(tmp_path / ".databrickscfg")
- rotator._write_databrickscfg("test-token")
-
- mode = os.stat(str(tmp_path / ".databrickscfg")).st_mode
- assert stat.S_IMODE(mode) == 0o600
-
- def test_updates_env_var(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com")
- with mock.patch.object(rotator, "_write_databrickscfg"):
- with mock.patch.object(rotator, "_persist_to_secret"):
- rotator._persist_token("new-token-value")
- assert os.environ.get("DATABRICKS_TOKEN") == "new-token-value"
-
-
-class TestSecretPersistence:
- """Persisting rotated token to app secret via SP."""
-
- def test_persist_to_secret_calls_sdk(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com",
- secret_scope="my-scope", secret_key="DATABRICKS_TOKEN")
-
- with mock.patch("pat_rotator.WorkspaceClient") as mock_ws:
- rotator._persist_to_secret("new-token")
- mock_ws.return_value.secrets.put_secret.assert_called_once_with(
- scope="my-scope", key="DATABRICKS_TOKEN", string_value="new-token"
- )
-
- def test_persist_skipped_when_no_scope_configured(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com",
- secret_scope=None, secret_key=None)
-
- with mock.patch("pat_rotator.WorkspaceClient") as mock_ws:
- rotator._persist_to_secret("new-token")
- mock_ws.return_value.secrets.put_secret.assert_not_called()
-
-
-class TestRotatorLifecycle:
- """Start/stop the background thread."""
-
- def test_start_creates_daemon_thread(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com", rotation_interval=9999)
- rotator._current_token = "test-pat"
- with mock.patch.object(rotator, "_rotation_loop"):
- rotator.start()
- assert rotator._thread is not None
- assert rotator._thread.daemon is True
- rotator.stop()
-
- def test_no_start_without_token(self):
- from pat_rotator import PATRotator
- rotator = PATRotator(host="https://test.databricks.com")
- rotator._current_token = None
- rotator.start()
- assert rotator._thread is None
-```
-
-**Step 2: Run tests to verify they fail**
-
-Run: `uv run pytest tests/test_pat_rotator.py -v`
-Expected: FAIL — `ModuleNotFoundError: No module named 'pat_rotator'`
-
-**Step 3: Write implementation**
-
-```python
-# pat_rotator.py
-"""Auto-rotate short-lived PATs in the background.
-
-Mints a new 2-hour PAT every 90 minutes, persists to app secret
-(survives restart), writes to ~/.databrickscfg (immediate CLI/SDK use),
-and revokes the old PAT. Fixes #81.
-"""
-
-import os
-import time
-import threading
-import logging
-
-import requests
-from databricks.sdk import WorkspaceClient
-
-from utils import ensure_https
-
-logger = logging.getLogger(__name__)
-
-# Defaults
-DEFAULT_TOKEN_LIFETIME = 7200 # 2 hours
-DEFAULT_ROTATION_INTERVAL = 5400 # 90 minutes
-
-
-class PATRotator:
- """Background PAT rotation with secret persistence."""
-
- def __init__(self, host=None, rotation_interval=DEFAULT_ROTATION_INTERVAL,
- token_lifetime=DEFAULT_TOKEN_LIFETIME,
- secret_scope=None, secret_key=None):
- self._host = ensure_https(host or os.environ.get("DATABRICKS_HOST", ""))
- self._rotation_interval = rotation_interval
- self._token_lifetime = token_lifetime
- self._secret_scope = secret_scope
- self._secret_key = secret_key
- self._current_token = os.environ.get("DATABRICKS_TOKEN", "").strip() or None
- self._current_token_id = None
- self._lock = threading.Lock()
- self._thread = None
- self._stop_event = threading.Event()
- self._databrickscfg_path = os.path.join(
- os.environ.get("HOME", "/app/python/source_code"),
- ".databrickscfg"
- )
-
- @property
- def token(self):
- with self._lock:
- return self._current_token
-
- def start(self):
- """Start the background rotation thread."""
- if not self._current_token:
- logger.warning("No PAT configured — rotation thread not started")
- return
- if self._thread and self._thread.is_alive():
- return
- self._stop_event.clear()
- self._thread = threading.Thread(target=self._rotation_loop, daemon=True,
- name="pat-rotation")
- self._thread.start()
- logger.info(f"PAT rotation started (interval={self._rotation_interval}s, "
- f"lifetime={self._token_lifetime}s)")
-
- def stop(self):
- """Signal the rotation thread to stop."""
- self._stop_event.set()
-
- def _rotation_loop(self):
- """Background loop: sleep, rotate, repeat."""
- while not self._stop_event.is_set():
- self._stop_event.wait(timeout=self._rotation_interval)
- if self._stop_event.is_set():
- break
- try:
- self._rotate_once()
- except Exception as e:
- logger.error(f"PAT rotation failed unexpectedly: {e}")
-
- def _rotate_once(self):
- """Mint new PAT, persist, revoke old. Returns True on success."""
- if not self._current_token:
- return False
-
- # 1. Mint new token
- try:
- resp = requests.post(
- f"{self._host}/api/2.0/token/create",
- headers={"Authorization": f"Bearer {self._current_token}"},
- json={
- "lifetime_seconds": self._token_lifetime,
- "comment": "coda-auto-rotated"
- },
- timeout=30
- )
- except requests.RequestException as e:
- logger.error(f"PAT rotation: create request failed: {e}")
- return False
-
- if resp.status_code != 200:
- logger.error(f"PAT rotation: create failed ({resp.status_code}): {resp.text}")
- return False
-
- data = resp.json()
- new_token = data["token_value"]
- new_token_id = data["token_info"]["token_id"]
-
- old_token_id = self._current_token_id
-
- # 2. Persist new token (secret + file + env)
- with self._lock:
- self._current_token = new_token
- self._current_token_id = new_token_id
- self._persist_token(new_token)
- logger.info(f"PAT rotated successfully (new_id={new_token_id})")
-
- # 3. Revoke old token (best-effort — old token expires in 2h anyway)
- if old_token_id:
- try:
- resp = requests.post(
- f"{self._host}/api/2.0/token/delete",
- headers={"Authorization": f"Bearer {new_token}"},
- json={"token_id": old_token_id},
- timeout=30
- )
- if resp.status_code == 200:
- logger.info(f"Old PAT revoked (id={old_token_id})")
- else:
- logger.warning(f"Old PAT revocation failed ({resp.status_code})")
- except requests.RequestException as e:
- logger.warning(f"Old PAT revocation request failed: {e}")
-
- return True
-
- def _persist_token(self, token):
- """Write rotated token to all persistence layers."""
- os.environ["DATABRICKS_TOKEN"] = token
- self._write_databrickscfg(token)
- self._persist_to_secret(token)
-
- def _write_databrickscfg(self, token):
- """Write token to ~/.databrickscfg for CLI/SDK tools."""
- content = (
- "[DEFAULT]\n"
- f"host = {self._host}\n"
- f"token = {token}\n"
- )
- try:
- with open(self._databrickscfg_path, "w") as f:
- f.write(content)
- os.chmod(self._databrickscfg_path, 0o600)
- except OSError as e:
- logger.warning(f"Could not write .databrickscfg: {e}")
-
- def _persist_to_secret(self, token):
- """Persist token to Databricks app secret (survives restart)."""
- if not self._secret_scope or not self._secret_key:
- return
- try:
- w = WorkspaceClient()
- w.secrets.put_secret(scope=self._secret_scope, key=self._secret_key,
- string_value=token)
- logger.info("Rotated PAT persisted to app secret")
- except Exception as e:
- logger.warning(f"Could not persist PAT to secret: {e}")
-```
-
-**Step 4: Run tests**
-
-Run: `uv run pytest tests/test_pat_rotator.py -v`
-Expected: All PASS
-
-**Step 5: Commit**
-
-```bash
-git add pat_rotator.py tests/test_pat_rotator.py
-git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "feat: add PATRotator for short-lived token auto-rotation (#81)"
-```
-
----
-
-### Task 2: Integrate PATRotator into app.py
-
-**Files:**
-- Modify: `app.py` (initialize_app, ~line 917)
-
-**Step 1: Write failing test**
-
-```python
-# tests/test_pat_rotation_integration.py
-"""Integration test: PATRotator wired into app."""
-
-from unittest import mock
-
-def test_app_has_pat_rotator():
- with mock.patch("app.initialize_app"):
- import app as app_module
- assert hasattr(app_module, "pat_rotator")
-```
-
-**Step 2: Run test — should fail**
-
-Run: `uv run pytest tests/test_pat_rotation_integration.py -v`
-
-**Step 3: Modify app.py**
-
-Add import near top (after existing imports):
-```python
-from pat_rotator import PATRotator
-```
-
-Add module-level instance:
-```python
-# PAT auto-rotation (short-lived tokens, background refresh)
-pat_rotator = PATRotator(
- secret_scope=os.environ.get("PAT_SECRET_SCOPE"),
- secret_key=os.environ.get("PAT_SECRET_KEY", "DATABRICKS_TOKEN"),
-)
-```
-
-In `initialize_app()`, after the setup thread start, add:
-```python
- # Start PAT auto-rotation if a PAT is configured
- pat_rotator.start()
-```
-
-**Step 4: Run all tests**
-
-Run: `uv run pytest tests/ -v`
-
-**Step 5: Commit**
-
-```bash
-git add app.py tests/test_pat_rotation_integration.py
-git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "feat: wire PATRotator into app startup (#81)"
-```
-
----
-
-### Task 3: Update app.yaml with secret resource and rotation env vars
-
-**Files:**
-- Modify: `app.yaml`
-
-**Step 1: Update app.yaml**
-
-```yaml
-command:
- - gunicorn
- - app:app
-env:
- - name: HOME
- value: /app/python/source_code
- - name: DATABRICKS_TOKEN
- valueFrom: DATABRICKS_TOKEN
- - name: PAT_SECRET_SCOPE
- value: coda-app
- - name: PAT_SECRET_KEY
- value: DATABRICKS_TOKEN
- - name: ANTHROPIC_MODEL
- value: databricks-claude-opus-4-6
- - name: GEMINI_MODEL
- value: databricks-gemini-3-1-pro
- - name: CODEX_MODEL
- value: databricks-gpt-5-2
- - name: DATABRICKS_GATEWAY_HOST
- valueFrom: DATABRICKS_GATEWAY_HOST
- - name: CLAUDE_CODE_DISABLE_AUTO_MEMORY
- value: 0
-resources:
- - name: pat-token
- secret:
- scope: coda-app
- key: DATABRICKS_TOKEN
- permission: WRITE
-```
-
-**Step 2: Commit**
-
-```bash
-git add app.yaml
-git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "chore: add secret resource with WRITE for PAT rotation (#81)"
-```
-
----
-
-### Task 4: Run full test suite and commit plan
-
-**Step 1: Run tests**
-
-Run: `uv run pytest tests/ -v`
-Expected: All PASS
-
-**Step 2: Commit plan doc**
-
-```bash
-git add docs/plans/2026-03-27-pat-auto-rotation-implementation.md
-git -c user.email=datasciencemonkey@gmail.com -c user.name="Sathish Gangichetty" commit -m "docs: PAT auto-rotation implementation plan (#81)"
-```
diff --git a/docs/plans/2026-03-28-session-detach-reconnect.md b/docs/plans/2026-03-28-session-detach-reconnect.md
deleted file mode 100644
index da22bda..0000000
--- a/docs/plans/2026-03-28-session-detach-reconnect.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# Session Detach & Reconnect
-
-**Date:** 2026-03-28
-**Context:** Coding agent sessions (claude, opencode, gemini) should survive tab closure. Only `exit` in the shell kills a session.
-
----
-
-## Problem
-
-Closing a browser tab kills the PTY process immediately via `sendBeacon('/api/session/close')`. For a coding agent mid-task, this destroys work in progress. The user didn't intend to kill the session — they just closed a tab.
-
-## Design
-
-### Principle: Detach, Don't Kill
-
-- **Tab/pane close = detach.** Frontend disconnects, PTY keeps running.
-- **`exit` in shell = the only kill.** PTY EOF detection triggers cleanup.
-- **24-hour reaper = safety net.** Orphaned sessions die after 24h with no heartbeat.
-
-### Changes
-
-#### 1. Frontend — `cleanupPane()` stops killing
-
-Remove `sendBeacon('/api/session/close')` from `cleanupPane()`. Keep poll stop, WS room leave, and xterm disposal. The `beforeunload` handler still calls `cleanupAllPanes()` but it no longer kills anything. `pagehide` already just sends a heartbeat.
-
-#### 2. Backend — `GET /api/sessions`
-
-Returns active sessions with process detection:
-
-```json
-[
- {
- "session_id": "abc-123",
- "created_at": 1743120382.5,
- "last_poll_time": 1743120982.5,
- "exited": false,
- "process": "claude",
- "idle_seconds": 342
- }
-]
-```
-
-Process detection: `ps --ppid {pid} -o comm=` to find the child process of the shell. Falls back to "bash" if no child.
-
-Added to auth skip list alongside `/api/pat-status`.
-
-#### 3. Backend — `POST /api/session/attach`
-
-Reattach to an existing session:
-
-- Input: `{ session_id }`
-- Validates session exists and not exited
-- Resets `last_poll_time` (restarts 24h idle clock)
-- Returns output buffer (last ~1000 lines) for replay
-- Returns metadata (process name, created_at)
-
-```json
-{
- "session_id": "abc-123",
- "output": ["line1\r\n", "line2\r\n"],
- "process": "claude",
- "created_at": 1743120382.5
-}
-```
-
-#### 4. Frontend — Session picker on return visit
-
-The picker only appears when PAT is already valid (return visit). First-time PAT flow always creates a new session.
-
-```
-createPane()
- → /api/pat-status
- → invalid → PAT prompt → setup → create new session
- → valid → GET /api/sessions
- → 0 sessions → create new
- → 1 session → auto-reattach (replay buffer)
- → N sessions → show picker
-```
-
-**Picker UI** (rendered in xterm with mouse support):
-
-```
- Existing sessions:
-
- claude (running, 2h ago) [Attach] [✕]
- opencode (running, 45m ago) [Attach] [✕]
- bash (idle, 3h ago) [Attach] [✕]
-
- [+ New session]
-```
-
-- Click **Attach** or session row → `POST /api/session/attach`, replay buffer, join WS room, start polling
-- Click **✕** → `POST /api/session/close` for that session, re-render picker
-- Click **+ New session** → `POST /api/session` as today
-- One session → skip picker, auto-reattach
-
-#### 5. Exited session cleanup
-
-When `read_pty_output()` detects EOF (user typed `exit`), call `terminate_session()` immediately to remove from dict. No zombie sessions in the picker.
-
-Session picker also filters out `exited: true` (defensive, race condition guard).
-
----
-
-## Files to Modify
-
-| File | Change |
-|------|--------|
-| `app.py` | Add `GET /api/sessions`, `POST /api/session/attach`. Update auth skip list. Update `read_pty_output()` to call `terminate_session()` on EOF. Add `_get_session_process(pid)` helper. |
-| `static/index.html` | Remove `sendBeacon('/api/session/close')` from `cleanupPane()`. Add session picker flow in `createPane()`. Add mouse click handling for picker UI. |
-
-## What Doesn't Change
-
-- `POST /api/session/close` endpoint stays — used by EOF cleanup path
-- `terminate_session()` stays — core kill logic unchanged
-- 24-hour timeout stays — safety net for orphans
-- `pagehide` heartbeat stays — already correct
-- WebSocket disconnect behavior stays — already doesn't kill PTY
-- PAT rotation, session awareness — unchanged (sessions still count)
diff --git a/docs/plans/PLAN-issue-8.md b/docs/plans/PLAN-issue-8.md
deleted file mode 100644
index ad4af7a..0000000
--- a/docs/plans/PLAN-issue-8.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# Issue #8: Frontend Keep-Alive, Reconnection & Web Worker Polling
-
-## Context
-
-The frontend polling is fragile. A single `setInterval` at 100ms calls `/api/output` — any non-200 response immediately kills the session with no retry. Browsers throttle background tab timers, so switching tabs easily causes polls to stall past the 300s timeout. The current workaround (bumping timeout from 60s to 300s) masks the problem but doesn't fix it.
-
-**Branch:** Create `feat/frontend-keepalive` off `main`
-
-## Architecture
-
-```
-Main Thread (index.html) Web Worker (poll-worker.js) Backend (app.py)
-───────────────────────── ────────────────────────── ────────────────
-- xterm.js / DOM - Output polling (100ms fg) - /api/output (existing)
-- visibilitychange handler ←──→ - Heartbeat polling (30s bg) ──→ - /api/heartbeat (NEW)
-- pagehide sendBeacon - Retry/backoff state - /api/session/close
-- Input/resize sending - Per-pane state map
-```
-
-Web Workers are NOT throttled by browsers in background tabs — this is the key benefit.
-
-## Changes
-
-### 1. Backend: Add `/api/heartbeat` endpoint
-
-**File:** `app.py` (insert after `/api/output` at line 530)
-
-```python
-@app.route("/api/heartbeat", methods=["POST"])
-def heartbeat():
- """Lightweight keep-alive — resets timeout without draining output buffer."""
- data = request.json
- session_id = data.get("session_id")
- with sessions_lock:
- if session_id not in sessions:
- return jsonify({"error": "Session not found"}), 404
- session = sessions[session_id]
- session["last_poll_time"] = time.time()
- timeout_warning = session.pop("timeout_warning", False)
- return jsonify({"status": "ok", "timeout_warning": timeout_warning})
-```
-
-Critical: does NOT touch `output_buffer` — output is only drained by `/api/output`.
-
-### 2. New file: `static/poll-worker.js`
-
-Web Worker handling all HTTP polling and retry logic (~120 lines).
-
-**Per-pane state:**
-```javascript
-const panes = new Map();
-// Each: { sessionId, pollTimerId, heartbeatTimerId, retryCount, mode: 'foreground'|'background' }
-```
-
-**Message protocol (main → worker):**
-- `{ type: 'start_poll', paneId, sessionId }` — begin polling for a pane
-- `{ type: 'stop_poll', paneId }` — stop polling on close
-- `{ type: 'visibility_change', hidden: bool }` — switch fg/bg mode
-
-**Message protocol (worker → main):**
-- `{ type: 'output', paneId, data }` — terminal output + flags
-- `{ type: 'session_ended', paneId, reason }` — 'exited' | 'auth_expired' | 'shutting_down'
-- `{ type: 'connection_status', paneId, status, attempt, maxAttempts }` — reconnecting/connected
-- `{ type: 'session_dead', paneId }` — retries exhausted
-
-**Retry strategy:** Capped exponential backoff with jitter
-- Base: 500ms, multiplier: 2x, max delay: 10s, max attempts: 5
-- Schedule: ~500ms → ~1s → ~2s → ~4s → ~8s (~15.5s total)
-- 403 (auth) and `exited` flag: no retry (permanent)
-- 404, 5xx, network error: full retry with backoff
-
-**Visibility modes:**
-- Foreground: output poll every 100ms, no heartbeat
-- Background: no output poll, heartbeat every 30s
-
-### 3. Modify `static/index.html`
-
-**Remove:**
-- `pollOutput(pane)` function (lines 704-738)
-- `setInterval(() => pollOutput(pane), 100)` (line 809)
-
-**Add:**
-- Worker init: `const pollWorker = new Worker('/static/poll-worker.js');`
-- `handleWorkerMessage(event)` — routes worker messages to xterm writes per pane
-- `visibilitychange` listener → sends `visibility_change` to worker
-- `pagehide` listener → `navigator.sendBeacon('/api/heartbeat', ...)` for all active panes
-
-**Modify:**
-- `createPane()`: replace `setInterval` with `pollWorker.postMessage({ type: 'start_poll', ... })`
-- `cleanupPane(pane)`: replace `clearInterval` with `pollWorker.postMessage({ type: 'stop_poll', ... })`
-- Remove `pollInterval` from pane object (no longer needed)
-
-### 4. New test: `tests/test_heartbeat.py`
-
-- Heartbeat with valid session returns 200, resets `last_poll_time`
-- Heartbeat with unknown session returns 404
-- Heartbeat does NOT drain output buffer (critical invariant)
-- Heartbeat returns and clears `timeout_warning` flag
-
-## Edge Cases Handled
-
-| Scenario | Behavior |
-|----------|----------|
-| Background tab | Worker switches to 30s heartbeat; resumes 100ms polling on return |
-| Laptop sleep (>5min) | Session expires server-side; on wake, retry exhaustion → "Connection lost" |
-| Backend restart/deploy | `shutting_down` flag warns client; retries handle brief downtime |
-| Auth expired (403) | No retry, immediate "refresh page" message |
-| Network blip | Backoff retries recover transparently |
-| Multiple panes | Independent per-pane state in Worker |
-| `pagehide` (tab close) | sendBeacon fires heartbeat as safety net before Worker dies |
-
-## Verification
-
-1. `uv run --with pytest pytest tests/test_heartbeat.py -v` — heartbeat tests pass
-2. `uv run --with pytest pytest tests/ -v` — all existing tests still pass
-3. Manual: open terminal, verify output works at 100ms (Network tab)
-4. Manual: background tab 30s → return → session alive, buffered output appears
-5. Manual: background tab >5min → return → clean "session expired" message
-6. Manual: check Network tab shows `/api/heartbeat` every ~30s when backgrounded
diff --git a/pyproject.toml b/pyproject.toml
index f9cc5c9..c89bd94 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -30,7 +30,7 @@ dependencies = [
"urllib3>=2.7.0",
# GHSA-65pc-fj4g-8rjx — idna < 3.15 lets crafted inputs bypass the
# CVE-2024-3651 fix in idna.encode(). idna is transitive; pin a floor.
- "idna>=3.15",
+ "idna>=3.17",
# Upper bound is forced by our transitive ecosystem: both mlflow-skinny 3.11.x
# AND opentelemetry-api 1.41.x cap importlib-metadata<8.8. Dependabot tried
# to bump it to 9.0.0 (PR #3) and broke every deploy — explicit ceiling so
@@ -59,7 +59,7 @@ dev = [
# against the deployed app to verify SSO + setup pipeline + security
# fixes end-to-end. Optional: tests skip cleanly when not installed.
"playwright>=1.40",
- "pytest-playwright>=0.5",
+ "pytest-playwright>=0.8.0",
]
[tool.uv]
diff --git a/requirements.txt b/requirements.txt
index 75e76d1..167319e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -34,7 +34,7 @@ charset-normalizer==3.4.7
# via requests
claude-agent-sdk==0.1.65
# via coda (pyproject.toml)
-click==8.3.3
+click==8.4.1
# via
# flask
# flask-socketio
@@ -78,7 +78,7 @@ httpx==0.28.1
# via mcp
httpx-sse==0.4.3
# via mcp
-idna==3.16
+idna==3.17
# via
# coda (pyproject.toml)
# anyio
@@ -139,7 +139,7 @@ pydantic==2.13.4
# mcp
# mlflow-skinny
# pydantic-settings
-pydantic-core==2.46.4
+pydantic-core==2.47.0
# via pydantic
pydantic-settings==2.14.1
# via mcp
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 908cab6..41d21dd 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -98,8 +98,17 @@ def pytest_collection_modifyitems(config, items):
)
if skips:
skip_marker = pytest.mark.skip(reason=" | ".join(skips))
+ e2e_dir = Path(__file__).parent
for item in items:
- item.add_marker(skip_marker)
+ # Only skip items that live in *this* directory. `items` is the
+ # whole session's collection, so an unguarded loop here skips the
+ # entire unit-test suite whenever e2e prerequisites are missing.
+ try:
+ item_path = Path(str(item.fspath)).resolve()
+ except Exception:
+ continue
+ if item_path.is_relative_to(e2e_dir):
+ item.add_marker(skip_marker)
@pytest.fixture(scope="module")