From 0720526b64c79820562749ce3c6c744164dc1024 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab Date: Tue, 15 Sep 2026 20:13:05 +0800 Subject: [PATCH 01/42] feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper An Android WebView wrapper has no browser pop-ups, so a foldable could not show two sessions, or a session and a file, side by side. A wrapper that can open a window of its own now exposes window.CodemanHost.openWindow(url); detachSession, detachFilePreview and openWebviewExternal hand their URL to it, mobile.css keeps the pop-out icon under html.host-windows, and a solo window closes and raises itself through the host when it offers the calls. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/host-window-popout.md | 18 +++++ src/web/public/app.js | 59 +++++++++++++- src/web/public/index.html | 2 +- src/web/public/mobile.css | 13 +++- src/web/public/panels-ui.js | 6 ++ src/web/public/settings-ui.js | 8 +- src/web/public/webview-tabs.js | 4 +- test/file-preview-detach.test.ts | 24 ++++++ test/host-window-detach.test.ts | 127 +++++++++++++++++++++++++++++++ 9 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 .changeset/host-window-popout.md create mode 100644 test/host-window-detach.test.ts diff --git a/.changeset/host-window-popout.md b/.changeset/host-window-popout.md new file mode 100644 index 00000000..d7e0d484 --- /dev/null +++ b/.changeset/host-window-popout.md @@ -0,0 +1,18 @@ +--- +"aicodeman": minor +--- + +feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper + +A WebView app has no browser pop-ups, so "Open in a new window" had nothing to open on a +phone, and mobile.css hid it there. An embedding app that can put a page in a window of its +own (an Android app on a foldable or in split screen) now says so with +`window.CodemanHost.openWindow(absoluteUrl)`, returning whether a window opened. When it is +present, the tab pop-out, the file viewer's detach button and a web tab's "open externally" +go through it, and the tab's pop-out icon defaults on and shows at tablet widths (phone tabs +keep their gear + close tap zones, so the host offers the pop-out from its own chrome through +`app.detachSession`). The dashboard +tracks such a window over the existing window channel, the path a reloaded dashboard already +uses, and a solo window closes and raises itself through the optional +`CodemanHost.closeWindow()` / `CodemanHost.focusWindow()`. Browsers define none of these, so +nothing changes there. diff --git a/src/web/public/app.js b/src/web/public/app.js index 4c688899..86c36fe7 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -910,6 +910,8 @@ class CodemanApp { // strip never flashes before handleInit selects the target session. this._initWindowChannel(); if (this.isSoloWindow) document.body.classList.add('solo-mode'); + // mobile.css keeps the pop-out icon off phones unless a host can open windows. + document.documentElement.classList.toggle('host-windows', this.hasHostWindows()); // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); @@ -1295,6 +1297,21 @@ class CodemanApp { // false only when we owned a now-closed window (re-dock + fall through to // genuinely re-open below). if (this.detachedSessions.has(id) && this._raiseDetached(id)) return; + // A native wrapper (an Android WebView app) has no browser pop-ups, but can + // open the solo URL in a window of its own, beside this one on a foldable or + // a split screen. There is no WindowProxy to poll, so the tab is tracked the + // way a dashboard reload tracks it: the solo window's channel announcements + // plus the roll-call liveness check. + const hosted = this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id))); + if (hosted !== null) { + if (!hosted) { + this.showToast?.('Could not open a new window for this session', 'error'); + return; + } + this._markDetached(id, true); + this._postWindowMessage({ type: 'detached', id }); + return; + } const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no'; let win = null; try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {} @@ -1309,6 +1326,32 @@ class CodemanApp { try { win.focus(); } catch {} } + /** + * The embedding app's window opener, when there is one. A native wrapper + * exposes `window.CodemanHost.openWindow(absoluteUrl)` (returning whether a + * window opened) to say it can put a page in a window of its own; browsers + * never define it. + * @returns {boolean} whether a host window opener is present + */ + hasHostWindows() { + try { + return typeof window !== 'undefined' && typeof window.CodemanHost?.openWindow === 'function'; + } catch { return false; } + } + + /** + * Open a same-origin page in a host window. + * @param {string} url absolute or base-relative URL + * @returns {boolean|null} null when there is no host (use window.open), + * otherwise whether the host opened a window + */ + openInHostWindow(url) { + if (!this.hasHostWindows()) return null; + try { + return window.CodemanHost.openWindow(new URL(url, location.href).href) !== false; + } catch { return false; } + } + /** Raise the popup for an already-detached session. Returns true if the raise * was handled (caller should stop); false if we owned a now-closed window and * re-docked it (caller should fall through to inline / re-open). Unifies the @@ -1435,8 +1478,12 @@ class CodemanApp { // Roll-call has no id (broadcast to all) — answer before the id filter. if (msg.type === 'roll-call') { this._postWindowMessage({ type: 'detached', id: this.soloSessionId }); return; } if (msg.id !== this.soloSessionId) return; - if (msg.type === 'close-request') { try { window.close(); } catch {} } - else if (msg.type === 'focus-request') { try { window.focus(); } catch {} } + // A host window ignores window.close()/focus() from script it did not + // open by window.open, so ask the host when it offers the call. + if (msg.type === 'close-request') { this._closeSoloWindow(); } + else if (msg.type === 'focus-request') { + try { if (typeof window.CodemanHost?.focusWindow === 'function') window.CodemanHost.focusWindow(); else window.focus(); } catch {} + } return; } // Dashboard side. @@ -1488,6 +1535,14 @@ class CodemanApp { }, 1200); } + /** Solo window: close itself (the re-dock button and a dashboard close-request). */ + _closeSoloWindow() { + try { + if (typeof window.CodemanHost?.closeWindow === 'function') window.CodemanHost.closeWindow(); + else window.close(); + } catch {} + } + /** Solo window: select the target session and apply minimal single-session * chrome. Called from handleInit once the session list has loaded. */ _applySoloMode() { diff --git a/src/web/public/index.html b/src/web/public/index.html index 39e956f1..500a05a3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -147,7 +147,7 @@ Admin Panel - + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 9be29153..9f57dbc6 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -39,8 +39,10 @@ html.mobile-init .file-browser-panel { /* No "open in new window" (detach) on phones/tablets — popped-out browser windows aren't usable there. !important beats the hover/detached reveal - rules in styles.css */ - .session-tab .tab-detach { + rules in styles.css. A native wrapper that opens windows of its own (side + by side on a foldable) keeps it at tablet widths: app.js sets + html.host-windows. Phone widths hide it again in the 599px block. */ + html:not(.host-windows) .session-tab .tab-detach { display: none !important; } } @@ -730,6 +732,13 @@ html.mobile-init .file-browser-panel { display: none; } + /* The tap-zone reserve counts gear + close only (test/mobile-tab-tap-zones), + so the pop-out icon stays off phone tabs even under a window-opening host, + which offers the pop-out from its own chrome (app.detachSession). */ + .session-tab .tab-detach { + display: none !important; + } + /* Gear icon on active tab - tiny, subtle */ .session-tab.active .tab-gear { display: inline-flex; diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 80e6ce83..18254d44 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4203,6 +4203,12 @@ Object.assign(CodemanApp.prototype, { */ detachFilePreview() { if (!this.filePreviewDetachUrl) return; + const hosted = this.openInHostWindow?.(this.filePreviewDetachUrl) ?? null; + if (hosted !== null) { + if (hosted) this.closeFilePreview(); + else this.showToast('Could not open a new window for this preview', 'error'); + return; + } const win = window.open(this.filePreviewDetachUrl, '_blank'); if (!win) { this.showToast('Pop-up blocked: allow pop-ups for this site to detach previews', 'error'); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 92631b0f..e5a7447a 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -464,7 +464,8 @@ Object.assign(CodemanApp.prototype, { settings.tabRailDetail ?? defaults.tabRailDetail ?? 'rich'; document.getElementById('appSettingsTabRailSort').value = settings.tabRailSort ?? defaults.tabRailSort ?? 'activity'; - document.getElementById('appSettingsShowTabDetachButton').checked = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; + document.getElementById('appSettingsShowTabDetachButton').checked = + settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false)); document.getElementById('appSettingsSessionListLayout').value = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; const sessionSidebarFontSize = this.resolveSessionSidebarFontSize( @@ -2662,7 +2663,10 @@ Object.assign(CodemanApp.prototype, { // default OFF, per-device). Mirrored as a class on : styles.css hides // .tab-detach without it (a tab that is already detached keeps its icon as // the re-focus affordance for the popped-out window). - const showTabDetach = settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false; + // Under a host that opens windows (see hasHostWindows) popping out is the + // way to get two panes side by side, so the button defaults on there. + const showTabDetach = + settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false)); document.documentElement.classList.toggle('tabs-show-detach', showTabDetach); const compactHeader = MobileDetection.getDeviceType() !== 'desktop'; const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false); diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 98d40188..f0369f3f 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -492,7 +492,9 @@ Object.assign(CodemanApp.prototype, { openWebviewExternal(id) { const webview = this.webviews.get(id || this.activeWebviewId); - if (webview) window.open(webview.url, '_blank', 'noopener'); + if (!webview) return; + if (this.openInHostWindow?.(webview.url)) return; + window.open(webview.url, '_blank', 'noopener'); }, closeWebviewTab(id) { diff --git a/test/file-preview-detach.test.ts b/test/file-preview-detach.test.ts index 6773386c..9f80537c 100644 --- a/test/file-preview-detach.test.ts +++ b/test/file-preview-detach.test.ts @@ -128,6 +128,30 @@ describe('file viewer detach button', () => { expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Pop-up blocked'), 'error'); }); + it('hands the URL to a host window opener instead of window.open', () => { + const { app, overlay, windowStub } = loadApp(); + app.openInHostWindow = vi.fn().mockReturnValue(true); + app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf'; + + app.detachFilePreview(); + + expect(app.openInHostWindow).toHaveBeenCalledWith('/api/sessions/s1/file-raw?path=doc.pdf'); + expect(windowStub.open).not.toHaveBeenCalled(); + expect(overlay.classList.contains('visible')).toBe(false); + }); + + it('keeps the overlay and toasts when the host could not open a window', () => { + const { app, overlay, windowStub } = loadApp(); + app.openInHostWindow = vi.fn().mockReturnValue(false); + app.filePreviewDetachUrl = '/api/sessions/s1/file-raw?path=doc.pdf'; + + app.detachFilePreview(); + + expect(windowStub.open).not.toHaveBeenCalled(); + expect(overlay.classList.contains('visible')).toBe(true); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error'); + }); + it('does nothing when no preview is armed', () => { const { app, windowStub } = loadApp(); app.filePreviewDetachUrl = ''; diff --git a/test/host-window-detach.test.ts b/test/host-window-detach.test.ts new file mode 100644 index 00000000..59b67aa8 --- /dev/null +++ b/test/host-window-detach.test.ts @@ -0,0 +1,127 @@ +/** + * @fileoverview Pop-out through a native host's window opener. + * + * An Android WebView wrapper has no browser pop-ups: `window.open` there either + * does nothing or replaces the page. On a foldable the app can still put a page + * in a window of its own beside the dashboard, and says so by exposing + * `window.CodemanHost.openWindow(absoluteUrl)`. When it does: + * 1. `detachSession` hands the solo URL to the host instead of `window.open`, + * marks the tab detached and announces it on the window channel (there is + * no WindowProxy, so liveness is the roll-call path a reloaded dashboard + * already uses), + * 2. a host that refuses leaves the tab docked and toasts, + * 3. without a host nothing changes (`openInHostWindow` returns null), + * 4. a solo window closes and raises itself through the host when it can. + * + * Loaded via `vm` with a stubbed context (no jsdom — see connection-indicator.test.ts). + */ +import { readFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); + +function load(host?: Record) { + const windowStub: Record = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + open: vi.fn(), + close: vi.fn(), + focus: vi.fn(), + }; + if (host) windowStub.CodemanHost = host; + const context = vm.createContext({ + console, + performance, + setInterval: vi.fn(), + clearInterval: vi.fn(), + setTimeout, + clearTimeout, + requestAnimationFrame: vi.fn(), + HTMLCanvasElement: class HTMLCanvasElement {}, + URL, + location: { href: 'http://10.0.0.2:8095/' }, + document: { addEventListener: vi.fn() }, + localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + window: windowStub, + MobileDetection: {}, + }); + const constants = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8'); + const source = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); + vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context); + const CodemanApp = (context as { __CodemanApp: { prototype: object } }).__CodemanApp; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = Object.create(CodemanApp.prototype) as Record; + app.isSoloWindow = false; + app.sessions = new Map([['s1', {}]]); + app.detachedSessions = new Set(); + app.detachedWindows = new Map(); + app.$ = () => null; + app.showToast = vi.fn(); + app._postWindowMessage = vi.fn(); + app._watchDetachedWindow = vi.fn(); + return { app, windowStub }; +} + +describe('detach through a host window opener', () => { + it('opens the solo URL in a host window and tracks the tab over the channel', () => { + const openWindow = vi.fn().mockReturnValue(true); + const { app, windowStub } = load({ openWindow }); + + app.detachSession('s1'); + + expect(openWindow).toHaveBeenCalledWith('http://10.0.0.2:8095/session/s1'); + expect(windowStub.open).not.toHaveBeenCalled(); + expect(app.detachedSessions.has('s1')).toBe(true); + expect(app.detachedWindows.size).toBe(0); + expect(app._watchDetachedWindow).not.toHaveBeenCalled(); + expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'detached', id: 's1' }); + }); + + it('leaves the tab docked and toasts when the host opens nothing', () => { + const { app, windowStub } = load({ openWindow: vi.fn().mockReturnValue(false) }); + + app.detachSession('s1'); + + expect(windowStub.open).not.toHaveBeenCalled(); + expect(app.detachedSessions.has('s1')).toBe(false); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error'); + }); + + it('treats a throwing host as a failed open', () => { + const { app } = load({ + openWindow: () => { + throw new Error('bridge gone'); + }, + }); + + expect(app.openInHostWindow('/session/s1')).toBe(false); + }); + + it('keeps window.open when there is no host', () => { + const { app, windowStub } = load(); + + expect(app.hasHostWindows()).toBe(false); + expect(app.openInHostWindow('/session/s1')).toBeNull(); + app.detachSession('s1'); + expect(windowStub.open).toHaveBeenCalledWith('/session/s1', 'codeman-session-s1', expect.any(String)); + }); + + it('closes and raises a solo window through the host', () => { + const closeWindow = vi.fn(); + const focusWindow = vi.fn(); + const { app, windowStub } = load({ openWindow: vi.fn(), closeWindow, focusWindow }); + app.isSoloWindow = true; + app.soloSessionId = 's1'; + + app._onWindowMessage({ type: 'focus-request', id: 's1' }); + app._onWindowMessage({ type: 'close-request', id: 's1' }); + + expect(focusWindow).toHaveBeenCalledTimes(1); + expect(closeWindow).toHaveBeenCalledTimes(1); + expect(windowStub.close).not.toHaveBeenCalled(); + expect(windowStub.focus).not.toHaveBeenCalled(); + }); +}); From 0b122e2c76b1708208d3fb22e4edf75b29f82d39 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 26 Sep 2026 23:13:21 -0400 Subject: [PATCH 02/42] feat(preview): render XLSX spreadsheets in the file-preview overlay xlsx files were download-only. Add a read-only, virtualized preview (sheet tabs, number formats, merges, theme colours) parsed entirely in a browser Web Worker with exceljs and fflate, loaded only when a spreadsheet is opened. The workbook is checked against ZIP-bomb, entry and cell limits before exceljs loads; cell text is written with textContent, formulas are never evaluated and nothing referenced by the workbook is fetched. On the server xlsx only joins the existing allowlist and classification, with a 10 MB cap on ?preview=true. xls and ods stay download-only. --- CLAUDE.md | 4 +- config/test-suites.ts | 1 + package-lock.json | 1004 +++++++++++++++++ package.json | 2 + scripts/build.mjs | 4 + scripts/check-public-assets.mjs | 40 +- scripts/postinstall.js | 16 + scripts/prepare-spreadsheet-assets.mjs | 31 + src/attachment-registry.ts | 3 + src/types/tools.ts | 1 + src/web/public/constants.js | 4 +- src/web/public/index.html | 1 + src/web/public/mobile.css | 21 + src/web/public/panels-ui.js | 44 + src/web/public/spreadsheet-preview-worker.js | 220 ++++ src/web/public/spreadsheet-preview.js | 414 +++++++ src/web/public/spreadsheet-xlsx-core.js | 588 ++++++++++ src/web/public/styles.css | 130 +++ src/web/routes/file-routes.ts | 48 +- test/dependency-security.test.ts | 15 +- .../file-routes-attachment-path-guard.test.ts | 46 + test/routes/file-routes.test.ts | 44 +- test/spreadsheet-assets.test.ts | 84 ++ test/spreadsheet-preview-worker.test.ts | 237 ++++ test/spreadsheet-preview.browser.test.ts | 131 +++ test/spreadsheet-preview.test.ts | 339 ++++++ test/spreadsheet-xlsx-core.test.ts | 306 +++++ 27 files changed, 3765 insertions(+), 13 deletions(-) create mode 100644 scripts/prepare-spreadsheet-assets.mjs create mode 100644 src/web/public/spreadsheet-preview-worker.js create mode 100644 src/web/public/spreadsheet-preview.js create mode 100644 src/web/public/spreadsheet-xlsx-core.js create mode 100644 test/spreadsheet-assets.test.ts create mode 100644 test/spreadsheet-preview-worker.test.ts create mode 100644 test/spreadsheet-preview.browser.test.ts create mode 100644 test/spreadsheet-preview.test.ts create mode 100644 test/spreadsheet-xlsx-core.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index c4a910cc..712f6e17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,7 +284,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments) -**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) +**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) @@ -312,7 +312,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) diff --git a/config/test-suites.ts b/config/test-suites.ts index 9f3ed295..76d7f94e 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -33,6 +33,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/split-pane-terminal.browser.test.ts', 'test/split-pane-orchestration.browser.test.ts', 'test/split-pane-auto-collapse.browser.test.ts', + 'test/spreadsheet-preview.browser.test.ts', ]; /** diff --git a/package-lock.json b/package-lock.json index 14841794..dee5eaa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,8 @@ "agent-browser": "^0.6.0", "esbuild": "^0.27.3", "eslint": "^9.0.0", + "exceljs": "4.4.0", + "fflate": "0.8.2", "pixelmatch": "^6.0.0", "playwright": "^1.58.0", "pngjs": "^7.0.0", @@ -1192,6 +1194,51 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@fastify/accept-negotiator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz", @@ -4803,6 +4850,134 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -4867,6 +5042,13 @@ "js-tokens": "^10.0.0" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5078,6 +5260,30 @@ "node": ">=4" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5090,6 +5296,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "dev": true, + "license": "MIT" + }, "node_modules/bn.js": { "version": "4.12.3", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", @@ -5199,6 +5464,25 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "dev": true, + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -5289,6 +5573,19 @@ "node": ">=18" } }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "dev": true, + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -5425,6 +5722,37 @@ "node": ">=18" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5515,6 +5843,48 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5631,6 +6001,13 @@ "node": ">=18" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5798,6 +6175,49 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/duplexify": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", @@ -6387,6 +6807,66 @@ "bare-events": "^2.7.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6465,6 +6945,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -6654,6 +7148,13 @@ "pend": "~1.2.0" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6756,6 +7257,13 @@ "node": ">= 6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -6778,6 +7286,13 @@ "dev": true, "license": "Unlicense" }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -6792,6 +7307,23 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -7241,6 +7773,13 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7278,6 +7817,18 @@ "node": ">=0.8.19" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7660,6 +8211,52 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -7701,6 +8298,52 @@ "node": ">=6" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7724,6 +8367,16 @@ "node": ">=8.0.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -8042,6 +8695,13 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "dev": true, + "license": "ISC" + }, "node_modules/load-tsconfig": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", @@ -8078,6 +8738,84 @@ "node": ">=8" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -8099,6 +8837,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -8342,6 +9094,19 @@ "dev": true, "license": "MIT" }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -8685,6 +9450,13 @@ "quansync": "^0.2.7" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8739,6 +9511,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9602,6 +10384,39 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9740,6 +10555,42 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -10043,6 +10894,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10775,6 +11633,16 @@ "node": ">=14.0.0" } }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -10854,6 +11722,16 @@ "node": ">=18" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "dev": true, + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11578,6 +12456,58 @@ "node": ">= 4.0.0" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -12244,6 +13174,80 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index c51e42cc..b8931321 100644 --- a/package.json +++ b/package.json @@ -127,6 +127,8 @@ "agent-browser": "^0.6.0", "esbuild": "^0.27.3", "eslint": "^9.0.0", + "exceljs": "4.4.0", + "fflate": "0.8.2", "pixelmatch": "^6.0.0", "playwright": "^1.58.0", "pngjs": "^7.0.0", diff --git a/scripts/build.mjs b/scripts/build.mjs index 2ab8ecd5..15e42518 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -49,6 +49,9 @@ run('xterm-addon-serialize', 'npx esbuild node_modules/@xterm/addon-serialize/li run('xterm-addon-webgl', 'cp node_modules/@xterm/addon-webgl/lib/addon-webgl.js dist/web/public/vendor/xterm-addon-webgl.min.js'); run('xterm-addon-unicode11', 'npx esbuild node_modules/@xterm/addon-unicode11/lib/addon-unicode11.js --minify --outfile=dist/web/public/vendor/xterm-addon-unicode11.min.js'); run('xterm-zerolag-input', 'npx esbuild packages/xterm-zerolag-input/src/zerolag-input-addon.ts --bundle --minify --format=iife --global-name=XtermZerolagInput --outfile=dist/web/public/vendor/xterm-zerolag-input.js'); +// XLSX preview parser bundles: loaded only inside spreadsheet-preview-worker.js, +// never by the page (see scripts/prepare-spreadsheet-assets.mjs). +run('spreadsheet preview vendors', 'node scripts/prepare-spreadsheet-assets.mjs dist/web/public/vendor'); // Append global aliases so app.js can use `new LocalEchoOverlay(terminal)` appendFileSync( @@ -125,6 +128,7 @@ console.log('\n[build] content-hash cache busting'); 'api-client.js', 'subagent-windows.js', 'image-input.js', + 'spreadsheet-preview.js', 'vendor/xterm-zerolag-input.js', 'vendor/xterm-predictive-echo.js', ]; diff --git a/scripts/check-public-assets.mjs b/scripts/check-public-assets.mjs index a9b5daeb..3ef9ab97 100644 --- a/scripts/check-public-assets.mjs +++ b/scripts/check-public-assets.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { execFileSync } from 'node:child_process'; -import { readdirSync, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, extname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -9,6 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const publicRoot = resolve(repoRoot, 'src/web/public'); const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier'); const checkedExtensions = new Set(['.js', '.css', '.html', '.json']); +// Combined budget for the two XLSX-preview vendor bundles (exceljs + fflate). +// They load only inside the spreadsheet worker, but a dependency bump that +// balloons them should be a deliberate decision, not a silent one. +const SPREADSHEET_VENDOR_MAX_BYTES = 1_100_000; function collectTextAssets(dir) { const files = []; @@ -35,6 +40,39 @@ function findNullByte(buffer) { const files = collectTextAssets(publicRoot); const failures = []; +// The spreadsheet worker is a stable (unhashed) URL, cache-busted by the +// SPREADSHEET_ASSET_VERSION token in spreadsheet-preview.js. That token must be +// the content hash of everything the worker loads, or a deploy can pair a new +// worker with a stale cached core/vendor file (static assets are cached 1y). +const spreadsheetWorker = join(publicRoot, 'spreadsheet-preview-worker.js'); +const spreadsheetCore = join(publicRoot, 'spreadsheet-xlsx-core.js'); +const spreadsheetEntry = join(publicRoot, 'spreadsheet-preview.js'); +const spreadsheetVendors = [join(publicRoot, 'vendor', 'exceljs.min.js'), join(publicRoot, 'vendor', 'fflate.min.js')]; + +if ([spreadsheetWorker, spreadsheetCore, spreadsheetEntry, ...spreadsheetVendors].every(existsSync)) { + const vendorBytes = spreadsheetVendors.reduce((total, file) => total + readFileSync(file).length, 0); + if (vendorBytes > SPREADSHEET_VENDOR_MAX_BYTES) { + failures.push(`Spreadsheet vendor bundles exceed ${SPREADSHEET_VENDOR_MAX_BYTES} bytes (${vendorBytes} bytes)`); + } + + const expectedVersion = createHash('sha256') + .update(readFileSync(spreadsheetWorker)) + .update(readFileSync(spreadsheetCore)) + .update(readFileSync(spreadsheetVendors[0])) + .update(readFileSync(spreadsheetVendors[1])) + .digest('hex') + .slice(0, 12); + const entrySource = readFileSync(spreadsheetEntry, 'utf8'); + const actualVersion = entrySource.match(/const SPREADSHEET_ASSET_VERSION = '([a-f0-9]+)'/)?.[1]; + if (actualVersion !== expectedVersion) { + failures.push( + `SPREADSHEET_ASSET_VERSION mismatch: expected ${expectedVersion}, found ${actualVersion || 'missing'}` + ); + } +} else { + failures.push('Spreadsheet preview assets are missing; run `node scripts/prepare-spreadsheet-assets.mjs`'); +} + for (const file of files) { const rel = relative(repoRoot, file); const data = readFileSync(file); diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 02d23f59..9991bccd 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -341,6 +341,22 @@ if (isGlobalInstall) { } } +// ---------------------------------------------------------------------------- +// 4a. Copy the XLSX preview's browser bundles (exceljs, fflate) into +// src/web/public/vendor/ for dev mode. The build does the same into dist/. +// ---------------------------------------------------------------------------- + +if (!isGlobalInstall) { + try { + execSync(`node "${join(import.meta.dirname, 'prepare-spreadsheet-assets.mjs')}"`, { stdio: 'pipe' }); + console.log(colors.green('✓ Spreadsheet preview vendor files prepared')); + } catch (err) { + hasWarnings = true; + console.log(colors.yellow('⚠ Failed to prepare spreadsheet preview vendor files')); + console.log(colors.dim(` ${err.message}`)); + } +} + // ---------------------------------------------------------------------------- // 4b. Fetch gesture-overlay runtime assets (MediaPipe wasm + model) for dev mode // (src/web/public/gesture/). Opt-in feature (CODEMAN_GESTURE=1); non-fatal. diff --git a/scripts/prepare-spreadsheet-assets.mjs b/scripts/prepare-spreadsheet-assets.mjs new file mode 100644 index 00000000..dafa2a07 --- /dev/null +++ b/scripts/prepare-spreadsheet-assets.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Copy the XLSX preview's browser bundles (exceljs, fflate) into a public vendor + * dir. Run by postinstall for dev (src/web/public/vendor, gitignored) and by + * build.mjs for prod (dist/web/public/vendor). Both packages are pinned exactly + * in package.json, and check-public-assets.mjs hashes the output into + * SPREADSHEET_ASSET_VERSION (the worker's cache-bust token), so a version bump + * that changes the bytes fails that check until the token is refreshed. + * Source-map comments are stripped: the maps are not shipped. + */ + +import { createRequire } from 'node:module'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const outputDir = resolve(process.argv[2] || join(import.meta.dirname, '..', 'src', 'web', 'public', 'vendor')); +const excelSource = require.resolve('exceljs/dist/exceljs.min.js'); +const fflateSource = join(dirname(require.resolve('fflate')), '..', 'umd', 'index.js'); + +function copyBrowserBundle(source, outputName) { + const content = readFileSync(source, 'utf8').replace(/\n?\/\/# sourceMappingURL=.*(?:\n|$)/g, '\n'); + if (/sourceMappingURL/.test(content)) { + throw new Error(`Failed to strip sourceMappingURL from ${outputName}`); + } + writeFileSync(join(outputDir, outputName), content, 'utf8'); +} + +mkdirSync(outputDir, { recursive: true }); +copyBrowserBundle(excelSource, 'exceljs.min.js'); +copyBrowserBundle(fflateSource, 'fflate.min.js'); diff --git a/src/attachment-registry.ts b/src/attachment-registry.ts index 261fda67..2d47f59d 100644 --- a/src/attachment-registry.ts +++ b/src/attachment-registry.ts @@ -62,6 +62,8 @@ const SUPPORTED_ATTACHMENT_EXTENSIONS = new Set([ 'pdf', 'docx', 'pptx', + // Previewed client-side (spreadsheet-preview-worker.js); served raw like the rest. + 'xlsx', 'md', 'txt', ...VIDEO_ATTACHMENT_EXTENSIONS, @@ -154,6 +156,7 @@ export function getAttachmentType(extension: string): AttachmentDetectedType { if (AUDIO_ATTACHMENT_EXTENSIONS.has(normalized)) return 'audio'; if (normalized === 'pdf') return 'pdf'; if (normalized === 'pptx') return 'presentation'; + if (normalized === 'xlsx') return 'spreadsheet'; if (normalized === 'md') return 'markdown'; // Everything else in the text family reads as text, including code and // config: the card and the preview both treat it as a plain-text file. diff --git a/src/types/tools.ts b/src/types/tools.ts index 85a974ec..5ce83146 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -70,6 +70,7 @@ export type AttachmentDetectedType = | 'pdf' | 'document' | 'presentation' + | 'spreadsheet' | 'markdown' | 'text'; diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 6f9d85c8..4ba8737f 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1458,7 +1458,7 @@ function computeRewriteScrollLine(input) { * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call. */ const FILE_PATH_LINK_PATTERN = - /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|mp4|webm|mov|mp3|wav))\b/g; + /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|bmp|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g; /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */ function absoluteFilePathPattern() { @@ -1476,7 +1476,7 @@ function absoluteFilePathPattern() { * file in /tmp played fine. test/media-extension-parity.test.ts pins the sync. */ const FILE_PREVIEW_EXTENSIONS = new Set( - ('png jpg jpeg gif webp bmp svg pdf docx pptx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ') + ('png jpg jpeg gif webp bmp svg pdf docx pptx xlsx mp4 webm mov m4v ogv mp3 wav ogg oga m4a aac flac opus').split(' ') ); /** Whether a path's extension is one {@link FILE_PREVIEW_EXTENSIONS} covers. */ diff --git a/src/web/public/index.html b/src/web/public/index.html index a5009b53..bea9a5f7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3766,5 +3766,6 @@ + diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 1a5136f4..bd64ffe6 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -3937,3 +3937,24 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close { max-height: min(88vh, env(viewport-segment-height 0 1, 88vh)); } } + +/* XLSX preview (spreadsheet-preview.js): larger sheet tabs and a taller, + touch-scrollable grid on phones. */ +@media (max-width: 700px) { + .spreadsheet-sheet-tabs { + padding-inline: 4px; + scroll-snap-type: x proximity; + } + + .spreadsheet-sheet-tab { + min-width: 96px; + min-height: 40px; + scroll-snap-align: start; + } + + .spreadsheet-grid { + min-height: 55vh; + -webkit-overflow-scrolling: touch; + touch-action: pan-x pan-y; + } +} diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 8bdf5df8..16dc7eed 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4083,6 +4083,8 @@ Object.assign(CodemanApp.prototype, { bodyEl.innerHTML = ``; } else if (ext === 'docx' || ext === 'pptx') { bodyEl.innerHTML = ``; + } else if (ext === 'xlsx') { + this._openSpreadsheetPreview(bodyEl, `${base}/raw`, externalSize); } else { try { // Bounded like the workspace text preview: a Range for the first @@ -4167,6 +4169,9 @@ Object.assign(CodemanApp.prototype, { } else if (data.type === 'audio') { bodyEl.innerHTML = ``; footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; + } else if (data.type === 'spreadsheet') { + this._openSpreadsheetPreview(bodyEl, CodemanBase.url(data.url), data.size); + footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`; } else if (data.type === 'binary') { const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`); bodyEl.innerHTML = `
Binary file (${this.formatFileSize(data.size)})
Cannot preview
Download
`; @@ -4237,6 +4242,9 @@ Object.assign(CodemanApp.prototype, { * the in-flight network fetch and puts the element back in NETWORK_EMPTY. */ _stopFilePreviewMedia() { + // A spreadsheet preview owns a fetch and a Web Worker; emptying the body + // leaves both running, so tear them down with the rest of the media. + this._disposeSpreadsheetPreview(); const bodyEl = this.$('filePreviewBody'); if (!bodyEl) return; for (const media of bodyEl.querySelectorAll('video, audio')) { @@ -4251,6 +4259,42 @@ Object.assign(CodemanApp.prototype, { bodyEl.innerHTML = ''; }, + /** + * Render an XLSX into the preview body via spreadsheet-preview.js, which + * parses it in a Web Worker (the ExcelJS bundle loads there, on demand, and + * never on page load). `url` is a raw route; the renderer adds `?preview=true` + * so the server applies its preview size cap. Superseded by the next + * _stopFilePreviewMedia(), which runs on every open and on close. + */ + _openSpreadsheetPreview(bodyEl, url, size) { + this._disposeSpreadsheetPreview(); + const renderer = window.CodemanSpreadsheetPreview; + if (!renderer?.open) { + bodyEl.innerHTML = '
Spreadsheet preview is unavailable.
'; + return; + } + bodyEl.textContent = ''; + const token = {}; + this._spreadsheetPreviewToken = token; + this._spreadsheetPreview = renderer.open({ + container: bodyEl, + url, + size, + isCurrent: () => this._spreadsheetPreviewToken === token, + }); + }, + + _disposeSpreadsheetPreview() { + const handle = this._spreadsheetPreview; + this._spreadsheetPreview = null; + this._spreadsheetPreviewToken = null; + try { + handle?.dispose(); + } catch (err) { + console.warn('Failed to dispose spreadsheet preview:', err); + } + }, + // ═══════════════════════════════════════════════════════════════ // File Viewer edit mode (issue #212 — docs/file-viewer-edit-plan.md) // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/spreadsheet-preview-worker.js b/src/web/public/spreadsheet-preview-worker.js new file mode 100644 index 00000000..839e07f0 --- /dev/null +++ b/src/web/public/spreadsheet-preview-worker.js @@ -0,0 +1,220 @@ +/** + * @fileoverview Same-origin XLSX parsing worker for the file-preview overlay. + * + * Runs off the main thread and is the ONLY place the spreadsheet vendor bundles + * load: fflate + the pure core at worker start, ExcelJS only after the ZIP has + * passed `admitXlsx()` (entry/inflate/ratio/cell/style caps). The page never + * loads either vendor file. Cell values are sent back as plain strings; the + * renderer writes them with `textContent`. Formulas are never evaluated (the + * cached result is shown, else the formula text), and nothing here fetches: + * external links, images and drawings are reported as unsupported features. + * + * Script URLs are RELATIVE so they resolve against this worker's own URL, which + * keeps a reverse-proxy `--base-url` mount working. + */ + +'use strict'; + +const spreadsheetAssetVersion = new URL(self.location.href).searchParams.get('v') || 'dev'; +const spreadsheetAssetQuery = `?v=${encodeURIComponent(spreadsheetAssetVersion)}`; +importScripts(`vendor/fflate.min.js${spreadsheetAssetQuery}`, `spreadsheet-xlsx-core.js${spreadsheetAssetQuery}`); + +const core = self.CodemanSpreadsheetXlsxCore; +let workbook = null; +let sheetsById = new Map(); +let populatedRowsById = new Map(); +let normalizedStyles = []; +let styleIds = new Map(); +let themePalette = core.DEFAULT_THEME_PALETTE; + +function postError(error) { + self.postMessage({ + type: 'error', + code: error?.code || 'parse-failed', + message: error?.message || 'Spreadsheet preview failed', + }); +} + +// ExcelJS keeps the workbook's raw theme XML on `_themes.theme1`; the +// fflate re-read is a fallback (admission already bounded the file) and the +// default Office palette is the last resort. +function readThemeXml(loadedWorkbook, bytes) { + const stashed = loadedWorkbook?._themes?.theme1; + if (typeof stashed === 'string' && stashed.length > 0) return stashed; + try { + const entries = self.fflate.unzipSync(new Uint8Array(bytes), { + filter: (file) => file.name === 'xl/theme/theme1.xml', + }); + const theme = entries['xl/theme/theme1.xml']; + if (theme) return new TextDecoder().decode(theme); + } catch (error) { + void error; + } + return ''; +} + +function normalizeStyle(cell) { + // Colours are resolved and contrast-checked as a PAIR. Emitting a + // font colour without its background lets workbook text land on the skin's + // `var(--bg-primary)` and disappear. + const colors = core.resolveCellColors(cell.fill?.fgColor, cell.font?.color, themePalette); + const style = { + font: { + bold: Boolean(cell.font?.bold), + italic: Boolean(cell.font?.italic), + color: colors.foreground, + }, + fill: colors.background, + alignment: ['left', 'center', 'right'].includes(cell.alignment?.horizontal) ? cell.alignment.horizontal : undefined, + wrapText: Boolean(cell.alignment?.wrapText), + }; + const key = JSON.stringify(style); + if (styleIds.has(key)) return styleIds.get(key); + if (normalizedStyles.length >= core.LIMITS.maxStyles) { + throw new core.XlsxPreviewError('style-limit', 'Workbook exceeds the normalized styles limit'); + } + const id = normalizedStyles.length; + normalizedStyles.push(style); + styleIds.set(key, id); + return id; +} + +function worksheetMetadata(sheet) { + const cellRefs = []; + const populatedRows = []; + sheet.eachRow({ includeEmpty: false }, (row) => { + populatedRows.push(row.number); + row.eachCell({ includeEmpty: false }, (cell) => { + cellRefs.push(cell.address); + normalizeStyle(cell); + }); + }); + const merges = Array.from(sheet.model?.merges || []); + const extent = core.deriveExtent(cellRefs, merges); + const rowOverrides = []; + sheet.eachRow({ includeEmpty: false }, (row) => { + if (row.hidden) rowOverrides.push([row.number, 0]); + else if (row.height) rowOverrides.push([row.number, Math.min(546, Math.max(0, row.height * (4 / 3)))]); + }); + const columnOverrides = []; + for (let col = 1; col <= extent.cols; col += 1) { + const column = sheet.getColumn(col); + if (column.hidden) columnOverrides.push([col, 0]); + else if (column.width) columnOverrides.push([col, Math.min(1785, Math.max(0, column.width * 7))]); + } + return { + populatedRows, + metadata: { + id: String(sheet.id), + name: sheet.name, + rows: extent.rows, + cols: extent.cols, + defaultRowHeight: Math.min(546, Math.max(1, (sheet.properties?.defaultRowHeight || 15) * (4 / 3))), + defaultColumnWidth: Math.min(1785, Math.max(1, (sheet.properties?.defaultColWidth || 9.14) * 7)), + rowOverrides, + columnOverrides, + merges, + }, + }; +} + +function cellDisplay(cell, date1904, warnings) { + const formatted = core.formatCellValue(cell.value, cell.numFmt || 'General', date1904); + if (formatted.warning) warnings.add(formatted.warning); + return formatted.text; +} + +async function loadWorkbook(bytes) { + const admission = core.admitXlsx(new Uint8Array(bytes), self.fflate); + if (!self.ExcelJS) importScripts(`vendor/exceljs.min.js${spreadsheetAssetQuery}`); + const nextWorkbook = new self.ExcelJS.Workbook(); + await nextWorkbook.xlsx.load(bytes); + const nextSheets = new Map(); + const nextRows = new Map(); + normalizedStyles = []; + styleIds = new Map(); + themePalette = core.parseThemePalette(readThemeXml(nextWorkbook, bytes)); + const sheets = []; + for (const sheet of nextWorkbook.worksheets) { + if (sheet.state === 'hidden' || sheet.state === 'veryHidden') continue; + const sheetResult = worksheetMetadata(sheet); + const metadata = sheetResult.metadata; + nextSheets.set(metadata.id, sheet); + nextRows.set(metadata.id, sheetResult.populatedRows); + sheets.push(metadata); + } + workbook = nextWorkbook; + sheetsById = nextSheets; + populatedRowsById = nextRows; + self.postMessage({ + type: 'metadata', + sheets, + styles: normalizedStyles, + date1904: Boolean(workbook.properties?.date1904), + empty: sheets.length === 0, + warnings: admission.features, + }); +} + +function sendTile(message) { + if (!workbook) throw new Error('Workbook is not loaded'); + const sheet = sheetsById.get(String(message.sheetId)); + if (!sheet) throw new Error('Worksheet is unavailable'); + const range = message.range; + const warnings = new Set(); + const cells = []; + const seenCells = new Set(); + const addCell = (cell) => { + const key = `${cell.row}:${cell.col}`; + if (seenCells.has(key) || (cell.isMerged && cell.master !== cell)) return; + seenCells.add(key); + cells.push({ + row: cell.row, + col: cell.col, + text: cellDisplay(cell, Boolean(workbook.properties?.date1904), warnings), + styleId: normalizeStyle(cell), + }); + }; + const populatedRows = populatedRowsById.get(String(message.sheetId)) || []; + for (const rowNumber of populatedRows) { + if (rowNumber < range.r1) continue; + if (rowNumber > range.r2) break; + const row = sheet.getRow(rowNumber); + row.eachCell({ includeEmpty: false }, (cell) => { + if (cell.col < range.c1 || cell.col > range.c2) return; + addCell(cell); + }); + if (cells.length > 2500) throw new core.XlsxPreviewError('tile-limit', 'Spreadsheet tile exceeds the cell limit'); + } + const merges = core.intersectingMerges(Array.from(sheet.model?.merges || []), range); + for (const merge of merges) { + const anchor = core.parseRange(merge); + if (anchor) addCell(sheet.getCell(anchor.r1, anchor.c1)); + } + self.postMessage({ + type: 'tile', + requestId: message.requestId, + sheetId: String(message.sheetId), + cells, + merges, + warnings: Array.from(warnings), + }); +} + +self.onmessage = async (event) => { + try { + const message = event.data || {}; + if (message.type === 'load') await loadWorkbook(message.bytes); + else if (message.type === 'tile') sendTile(message); + else if (message.type === 'dispose') { + workbook = null; + sheetsById = new Map(); + populatedRowsById = new Map(); + themePalette = core.DEFAULT_THEME_PALETTE; + } + } catch (error) { + postError(error); + } +}; + +self.postMessage({ type: 'ready' }); diff --git a/src/web/public/spreadsheet-preview.js b/src/web/public/spreadsheet-preview.js new file mode 100644 index 00000000..01b690e5 --- /dev/null +++ b/src/web/public/spreadsheet-preview.js @@ -0,0 +1,414 @@ +/** + * @fileoverview Read-only, virtualized XLSX preview for the file-preview overlay. + * + * `CodemanSpreadsheetPreview.open({ container, url, size })` fetches the workbook + * bytes (same-origin only, `?preview=true` so the server applies its 10 MB + * preview cap), hands them to spreadsheet-preview-worker.js, and renders only + * the visible tile of cells. Parsing happens entirely in the browser worker; the + * server just streams the file through its existing confined raw routes. + * + * Every workbook string (cell text, sheet names) is written with `textContent`, + * never markup. The per-style ` - -
- ← Back to Codeman -

Upload Screenshot

-
-

Tap to select image

- PNG, JPG, WebP — up to 10 MB -
- -
- Preview -
-
- -
-
-
- - diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 53275f5e..e2251c24 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -1295,8 +1295,20 @@ export function registerSystemRoutes( // ═══════════════════════════════════════════════════════════════ // ========== Screenshots ========== + // Deprecated (the upload page is gone): removed in a later MAJOR per + // docs/versioning-policy.md. Warns once per process on first use. + let screenshotsDeprecationWarned = false; + const warnScreenshotsDeprecated = (): void => { + if (screenshotsDeprecationWarned) return; + screenshotsDeprecationWarned = true; + console.warn( + '[deprecated] /api/screenshots is deprecated and will be removed in a future major release. ' + + 'Use POST /api/sessions/:id/paste-image to hand a file to a session.' + ); + }; app.post('/api/screenshots', async (req, reply) => { + warnScreenshotsDeprecated(); const contentType = req.headers['content-type'] ?? ''; if (!contentType.includes('multipart/form-data')) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data'); @@ -1383,6 +1395,7 @@ export function registerSystemRoutes( }); app.get('/api/screenshots', async () => { + warnScreenshotsDeprecated(); if (!existsSync(SCREENSHOTS_DIR)) { return { files: [] }; } @@ -1396,6 +1409,7 @@ export function registerSystemRoutes( }); app.get('/api/screenshots/:name', async (req, reply) => { + warnScreenshotsDeprecated(); const { name } = req.params as { name: string }; // Prevent path traversal if (name.includes('/') || name.includes('\\') || name.includes('..')) { diff --git a/src/web/server.ts b/src/web/server.ts index 4442e544..04546a57 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -920,7 +920,9 @@ export class WebServer extends EventEmitter { }); // Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively. - // HTML must revalidate every time so browsers pick up new hashed filenames after deploys. + // HTML must revalidate every time so browsers pick up new hashed filenames after deploys, and every + // HTML page has its own route that says so (/, /index.html, /session/:id). ⚠️ A new static .html + // needs such a route too: this plugin would hand it a year of `immutable`. // cacheControl disabled so setHeaders owns Cache-Control for plain static assets. // preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression await this.app.register(fastifyStatic, { @@ -932,7 +934,7 @@ export class WebServer extends EventEmitter { // `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and // NOT `res.setHeader()`. A v9-style body throws TypeError on every static // request, which is every page load. See the v10.0.0 release notes. - setHeaders: (reply, path) => { + setHeaders: (reply) => { // ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this // callback wrote to the raw response and Fastify's staged reply headers then // overwrote it, so a route that set its own Cache-Control before .sendFile() @@ -941,12 +943,7 @@ export class WebServer extends EventEmitter { // no-store` its route asks for — a service worker that can never update. // So: a route that already decided keeps its answer. if (reply.getHeader('Cache-Control') !== undefined) return; - // Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz - if (path.includes('.html')) { - reply.header('Cache-Control', 'no-cache'); - } else { - reply.header('Cache-Control', 'public, max-age=31536000, immutable'); - } + reply.header('Cache-Control', 'public, max-age=31536000, immutable'); }, }); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index aa1fae13..6087e7c4 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -680,6 +680,24 @@ describe('system-routes', () => { }); }); + describe('/api/screenshots deprecation', () => { + it('warns once across requests and leaves the response unchanged', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + mockedExistsSync.mockReturnValue(false); + const first = await harness.app.inject({ method: 'GET', url: '/api/screenshots' }); + const second = await harness.app.inject({ method: 'GET', url: '/api/screenshots' }); + expect(JSON.parse(first.body)).toEqual({ files: [] }); + expect(JSON.parse(second.body)).toEqual({ files: [] }); + const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes('/api/screenshots')); + expect(deprecations).toHaveLength(1); + expect(String(deprecations[0][0])).toContain('POST /api/sessions/:id/paste-image'); + } finally { + warn.mockRestore(); + } + }); + }); + // ========== GET /api/screenshots/:name ========== describe('GET /api/screenshots/:name', () => { diff --git a/test/static-cache-headers.test.ts b/test/static-cache-headers.test.ts index 9bad8785..99ca6f6d 100644 --- a/test/static-cache-headers.test.ts +++ b/test/static-cache-headers.test.ts @@ -11,7 +11,8 @@ * * That contract is load-bearing: assets are served `immutable` for a year, and * `index.html` must revalidate every time or a deploy leaves browsers on stale - * markup (see `cacheBustAssets` in server.ts). + * markup (see `cacheBustAssets` in server.ts). No HTML is left behind the static + * plugin (every page has its own route), so the HTML half is asserted on the route. * * These tests drive a REAL WebServer on purpose. Asserting against an inline * re-registration of the plugin would keep passing after a revert in server.ts. @@ -61,18 +62,6 @@ describe('static asset Cache-Control headers', () => { expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); }); - it('makes static HTML revalidate so deploys are picked up', async () => { - // ⚠️ upload.html, NOT index.html. `/index.html` has its own explicit route that - // answers from renderIndexHtml() and never reaches @fastify/static, so asserting - // on it passes even with setHeaders fully broken (verified: reverting server.ts - // to the v9 form fails the two /app.js tests and leaves an index.html assertion - // green). upload.html has no route of its own, so it is the only HTML that - // actually exercises the `.html` branch of setHeaders. - const res = await get(`${baseUrl}/upload.html`); - expect(res.status).toBe(200); - expect(res.headers.get('cache-control')).toBe('no-cache'); - }); - it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => { // Regression guard for the OTHER half of the v10 change. setHeaders runs for // sendFile() too, and v10 moved it from the raw response onto the reply, which From 9230b53ccd7f2bc150bbae7f042ae21f0b8011b7 Mon Sep 17 00:00:00 2001 From: JD Date: Thu, 8 Oct 2026 00:35:36 -0400 Subject: [PATCH 23/42] docs(wiki): say npm test is the CI gate, as CONTRIBUTING.md does The wiki's Contributing page still warned against bare npm test and pointed at test:ci, from before 947ff6f6 made npm test the CI gate and gave the browser, mobile and perf suites their own runners. It now matches .github/CONTRIBUTING.md and CLAUDE.md. --- docs/wiki/Contributing.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index c08229ee..ae03f0ee 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -43,8 +43,8 @@ npm run lint npm run format:check npm run check:frontend-syntax npm run check:browser-excludes -npm test -- test/.test.ts # one file, the normal way -npm run test:ci # the full CI sweep +npm test # the gate, exactly what CI runs +npm test -- test/.test.ts # one file ``` `npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s, @@ -53,9 +53,12 @@ something other than the checked-out HEAD, or when the tree has uncommitted chan checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a `pre-push` hook of your own is never overwritten. -**Never run bare `npm test`.** The default configuration includes browser-driven Playwright -suites that need a live server, Chromium, and environment-specific baselines; they hang or -fail on a normal machine. `test:ci` is the honest "run everything". +`npm test` runs the same suite CI runs, so a green run locally means a green run there. It +leaves out three suites that cannot pass on an arbitrary machine, each with its own command: +`npm run test:browser` (Playwright, Chromium and a live server), `npm run test:mobile` (the +same plus environment-specific screenshot baselines) and `npm run test:perf` (wall-clock +benchmarks for an otherwise idle machine). Expect those to fail where the machine cannot +provide what they need; that means "not runnable here", not a regression. Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so tests cannot touch real sessions. If you add a test that binds a port, pick a unique one at From 5a0018fc863a38b5bcc701c1383345adfd1c5670 Mon Sep 17 00:00:00 2001 From: Randalix Date: Wed, 16 Sep 2026 13:28:17 +0200 Subject: [PATCH 24/42] fix(terminal): page the CLI transcript for opencode's hollow local buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux `alternate_on=1`, `history_size=0`), so tmux keeps no history for the pane and the browser's normal buffer never grows past one screen (`baseY === 0`). The plain wheel therefore scrolled a buffer with nothing in it — dead in every opencode tab, on desktop and touch alike. opencode is not a forwarding candidate: it IGNORES SGR wheel reports (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical), but it does page its own transcript on PageUp/PageDown (`messages_page_up/down`, verified on the same pane). The hollow-buffer rescue already sends exactly those keys — it was just gated to `claude`. Widen the gate to opencode so the wheel and touch gestures reach the CLI's own transcript instead of a no-op. Every other mode stays out: shell/pi own real terminal scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (docs/scrollback-fix-plan.md). Test: test/terminal-scroll-routing.test.ts — new opencode case (Red before the fix, Green after); the "real local scrollback is untouched" case now also pins antigravity as not-paged. --- docs/architecture-invariants.md | 2 +- docs/scrollback-fix-plan.md | 2 +- src/web/public/terminal-ui.js | 45 +++++++++++++++-------- test/terminal-scroll-routing.test.ts | 55 +++++++++++++++++++++------- 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index cd49d869..75bbff65 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -221,7 +221,7 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. -**A false gate on a Claude session must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. The 1.12.0 retest reported exactly that: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the triple guard (claude mode + gate false + `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. +**A false gate on a hollow pane must not mean a DEAD gesture** (#205 round 2, `_maybePageCliTranscript`): every way `_shouldForwardWheelToApp()` returns false leaves a repaint-mode pane scrolling a buffer that has nothing in it (`baseY === 0`) — the version probe came back empty, the CLI really is older than 2.1.187, the `cliMouseTracking` flag is unset (inline claude, or fullscreen right after a server restart), or the user turned on `terminalWheelLocalScrollback`. **opencode is the fifth case, and the gate is false there by design**: its TUI runs on the ALTERNATE SCREEN (1.18.31 measured: tmux `alternate_on=1`, `history_size=0`), so the buffer is hollow, and it IGNORES SGR wheel reports entirely (six `\x1b[<64;…M` reports against an idle pane left the capture byte-identical) while still paging its transcript on PageUp/PageDown (`messages_page_up/down`) — so paging is the only gesture that can reach it, and without it the wheel was silently dead in every opencode tab. The 1.12.0 retest reported exactly that shape for Claude: a wheel that did nothing at all while Fn+Up (PageUp) paged back through intact text, which is the proof that the CLI's own history and the PTY input path were both fine. So under the guard (`_localScrollbackIsHollow()` — `claude` or `opencode`, gate false, `baseY === 0`) wheel and touch travel is translated into coalesced `\x1b[5~` / `\x1b[6~` through the same 40ms queue as the SGR reports, at half a screen of travel per page key (the key jumps a whole screen; a 1:1 mapping was unusably slow with a discrete wheel). ⚠️ `shell`/`pi` own real terminal scrollback and are never paged, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour is unverified (`docs/scrollback-fix-plan.md`). ⚠️ Shift is excluded on purpose — it is the explicit "give me local scrollback" gesture and must keep that meaning. ⚠️ `terminalWheelLocalScrollback` is deliberately NOT scoped away from repaint-mode CLIs even though it is a footgun there: that would silently override an explicit user choice, so the fallback catches it instead. **Server-side counterpart**: `getClaudeCliVersion()` caches SUCCESS for the process lifetime but must never cache FAILURE — it used to, so one timed-out or PATH-starved probe at the first Claude session start disabled wheel-forwarding for every Claude session until the server restarted (a dead wheel on phone, tablet and laptop at once, the signature of a server-side cause). Failures now retry with a 1/2/4…15min backoff; the policy is the pure `resolveClaudeCliVersion()`. Tests: `test/terminal-scroll-routing.test.ts`, `test/claude-cli-version-cache.test.ts`. **Why the wheel went where it went is LOGGED** (`_logScrollRouting`): one console line per session per distinct decision — `[scroll] <id> → forward-sgr|page-keys|local-scrollback|repull-refused-downgrade (mode=…, cliVersion=…, localScrollbackOptOut=…, mouseTracking=…, localScrollbackRows=…)`. #205 ran two rounds of remote guesswork over questions this line answers directly; keep it when touching the routing. diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index 65d8870d..497d2707 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -279,7 +279,7 @@ Touch is always-local by design, and Claude sessions keep content in the normal - The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. - 40ms SGR coalescing: never send per-event writes to the server. - Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. -- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). +- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`), which is not a strip/forward list. antigravity/grok/deepseek/omp remain unverified. - The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. ## Testing (per repo rules) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index a03bc4d1..9647927e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5485,15 +5485,29 @@ Object.assign(CodemanApp.prototype, { }, /** - * True when this session's LOCAL scrollback is structurally empty: a Claude - * pane in repaint mode, where tmux reports `history_size≈0` and every frame - * overwrites the last, so xterm's normal buffer never grows past one screen + * True when this session's LOCAL scrollback is structurally empty: a pane whose + * TUI repaints one full screen in place, so tmux keeps no history for it + * (`history_size≈0`) and xterm's normal buffer never grows past one screen * (`baseY === 0`). Scrolling that buffer is a no-op no matter how the gesture * is routed — the "wheel does nothing at all" half of the #205 retest. + * + * Two shapes, measured separately: + * - `claude` in repaint mode (the original, #205 round 2), and + * - `opencode`, whose TUI runs on the ALTERNATE SCREEN (opencode 1.18.31: tmux + * `alternate_on=1`, `history_size=0`) and so pushes nothing into the + * terminal's scrollback at all. It pages its own transcript with the same + * PageUp/PageDown keys (`messages_page_up/down`) but IGNORES SGR wheel + * reports — six `\x1b[<64;…M` reports against an idle pane left the capture + * byte-identical — so paging is the only gesture that reaches it. Without + * this the wheel was silently dead in every opencode tab. + * + * Every other mode is deliberately absent: shell/pi own real terminal + * scrollback, and codex/gemini/antigravity/grok/deepseek/omp page-key behaviour + * is unverified (docs/scrollback-fix-plan.md). */ _localScrollbackIsHollow() { const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; - if (mode !== 'claude') return false; + if (mode !== 'claude' && mode !== 'opencode') return false; const buf = this.terminal?.buffer?.active; if (!buf || buf.type === 'alternate') return false; return (buf.baseY || 0) === 0; @@ -5504,18 +5518,19 @@ Object.assign(CodemanApp.prototype, { * coalesced PageUp/PageDown key sends so the CLI pages its OWN transcript. * * The rescue path for every way `_shouldForwardWheelToApp` can come back false - * on a Claude session that has no local history to fall back on: the CLI - * version probe failed or is genuinely older than 2.1.187, the CLI's mouse - * tracking flag is unset (the inline renderer, or fullscreen right after a - * server restart), or the user turned on "Wheel scrolls local history" (which - * pins the wheel to a buffer that, for a repaint-mode CLI, is empty: the - * setting's footgun). Before this, all of those produced a completely dead - * gesture; the #205 reporter proved the keyboard route works by paging back - * through intact text with Fn+Up. + * on a session that has no local history to fall back on: the CLI version probe + * failed or is genuinely older than 2.1.187, the CLI's mouse tracking flag is + * unset (the inline renderer, or fullscreen right after a server restart), the + * user turned on "Wheel scrolls local history" (which pins the wheel to a buffer + * that, for a repaint-mode CLI, is empty: the setting's footgun), or the CLI is + * opencode, which never fills the buffer and never accepts the wheel. Before + * this, all of those produced a completely dead gesture; the #205 reporter + * proved the keyboard route works by paging back through intact text with Fn+Up. * - * Triple-guarded (claude mode + gate false + `baseY === 0`), so a session with - * real local scrollback is never touched. Shift is excluded on purpose: it is - * the explicit "give me local scrollback" gesture and must keep that meaning. + * Guarded by `_localScrollbackIsHollow()` plus a false forwarding gate, so a + * session with real local scrollback is never touched. Shift is excluded on + * purpose: it is the explicit "give me local scrollback" gesture and must keep + * that meaning. * * @returns true when the gesture was consumed here (the caller must not also * scroll locally). diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 2321d8fc..060b6668 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -46,13 +46,20 @@ function loadTerminalUiHarness() { return { app: new (CodemanApp as any)(), logs }; } -/** A Claude session whose local buffer holds exactly one screen (baseY 0). */ -function hollowClaudeApp(overrides: { cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { +/** A session whose local buffer holds exactly one screen (baseY 0) — a hollow pane. */ +function hollowApp(overrides: { mode?: string; cliVersion?: string; rows?: number; cliMouseTracking?: boolean } = {}) { const { app, logs } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; app.sessions = new Map([ - ['sess-1', { mode: 'claude', cliVersion: overrides.cliVersion, cliMouseTracking: overrides.cliMouseTracking }], + [ + 'sess-1', + { + mode: overrides.mode ?? 'claude', + cliVersion: overrides.cliVersion, + cliMouseTracking: overrides.cliMouseTracking, + }, + ], ]); app._sendInputEphemeral = (id: string, data: string) => sent.push({ id, data }); app.terminal = { @@ -135,7 +142,7 @@ describe('full-history re-pull downgrade guard (issue #205 round 2)', () => { describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2)', () => { it('pages the CLI transcript when the wheel gate is false and there is no scrollback', () => { - const { app, sent } = hollowClaudeApp(); // cliVersion unknown → gate false + const { app, sent } = hollowApp(); // cliVersion unknown → gate false // Half a screen of travel (rows 36 → 18 lines) buys exactly one PageUp. expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); @@ -149,7 +156,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('accumulates sub-page travel instead of dropping or over-sending it', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); expect(app._maybePageCliTranscript({ shiftKey: false }, -10)).toBe(true); // consumed… app._flushWheelSgrQueue(); @@ -161,15 +168,34 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('caps the keys one gesture batch can emit', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -1000); // 55 pages of travel app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]); }); + it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => { + // OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux + // `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at + // one screen exactly like a repaint-mode Claude pane. The difference is that + // OpenCode IGNORES SGR wheel reports (verified against an idle pane: six + // `\x1b[<64;…M` reports left the capture byte-identical), so PageUp/PageDown + // — its `messages_page_up/down` binds — is the ONLY gesture that reaches its + // transcript. Without this the wheel was silently dead in every OpenCode tab. + const { app, sent } = hollowApp({ mode: 'opencode' }); + + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + + app._maybePageCliTranscript({ shiftKey: false }, 18); + app._flushWheelSgrQueue(); + expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' }); + }); + it('leaves every session that has real local scrollback alone', () => { - const { app } = hollowClaudeApp(); + const { app } = hollowApp(); // Shift is the explicit "give me local scrollback" gesture — never paged. expect(app._maybePageCliTranscript({ shiftKey: true }, -18)).toBe(false); @@ -179,12 +205,15 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); app.terminal.buffer.active.baseY = 0; - // Non-Claude modes keep their existing behavior (shell scrolls tmux history - // through the alt-screen strip; codex/gemini page keys are unverified). + // Modes with real terminal scrollback keep their existing behavior (shell/pi + // own tmux history through the alt-screen strip; codex/gemini/antigravity/… + // page-key behaviour is unverified — docs/scrollback-fix-plan.md). app.sessions = new Map([['sess-1', { mode: 'shell' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); app.sessions = new Map([['sess-1', { mode: 'codex' }]]); expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); + app.sessions = new Map([['sess-1', { mode: 'antigravity' }]]); + expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false); // An alternate-screen pane belongs to xterm's own alt-scroll handling. app.sessions = new Map([['sess-1', { mode: 'claude' }]]); @@ -197,7 +226,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 // repaint-mode CLI, is empty — a user who flipped it while hunting for a fix // on 1.11.x would have ended up with a completely dead wheel on 1.12.0. // Version and tracking both qualify, so the opt-out is the only thing saying no. - const { app, sent } = hollowClaudeApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… + const { app, sent } = hollowApp({ cliVersion: '2.1.223', cliMouseTracking: true }); // gate would forward… app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); // …but the opt-out wins @@ -207,7 +236,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('drops travel accumulated on another tab', () => { - const { app, sent } = hollowClaudeApp(); + const { app, sent } = hollowApp(); app._maybePageCliTranscript({ shiftKey: false }, -17); // just short of a page app.activeSessionId = 'sess-2'; @@ -228,7 +257,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 describe('scroll routing diagnostic (issue #205 round 2)', () => { it('prints the decision and its inputs once per session, and again when it changes', () => { - const { app, logs } = hollowClaudeApp({ cliVersion: '2.1.100' }); + const { app, logs } = hollowApp({ cliVersion: '2.1.100' }); app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); app._logScrollRouting('local-scrollback'); @@ -255,7 +284,7 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { }); it('reports an unknown CLI version, the false-path that disables forwarding', () => { - const { app, logs } = hollowClaudeApp(); // no cliVersion — the probe failed + const { app, logs } = hollowApp(); // no cliVersion — the probe failed app._logScrollRouting('page-keys'); expect(logs[0]).toContain('cliVersion=unknown'); }); From 5ba729fcbb768b9d89a9226677511e1b13c1719a Mon Sep 17 00:00:00 2001 From: Randalix <j.heintz.90@gmail.com> Date: Thu, 8 Oct 2026 14:55:02 +0200 Subject: [PATCH 25/42] fix(terminal): strip opencode's mouse DECSETs so a drag selects text again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode's TUI enables mouse tracking. tmux runs with `mouse off`, so it passes the PANE's DECSETs straight through to the tmux client, and the browser's xterm obeyed them: `mouseTrackingMode` flipped to 'any' (measured 62 none / 18 any over 16s) and xterm then reported DRAGS to the TUI instead of selecting locally. In that state marking text produced no selection at all, so copy-on-select silently did nothing (5/5 dead drags while `any`), and the obvious fallback — Ctrl+C — is opencode's `app_exit`, which ended the session. Both were hit here. opencode needs the middle strip: alt-screen toggles AND mouse DECSETs, but NOT `3J` (a TUI is not a `clear` consumer). That is `altScreen: 'strip-mux-and-mouse'` + `isMuxMouseStripMode`, applied to the live stream (session.ts) and the replay of a stored buffer, now the exported `stripReplayBuffer()` (session-routes.ts). The browser's mouse-report gate keeps no mode list any more: `_shouldReportMouseToCli()` reads only `cliMouseTracking`. The server sets that flag solely in the mouse-strip branch (`_recordStrippedMouseMode`, one caller), so it can only be true for a mode whose DECSETs are stripped, and whichever modes the registry strips, the browser follows. Clicks still reach opencode through the hand-encoded SGR tap it gates. The `altScreen` JSDoc gets the decision table its three independent choices need (alt-screen / `3J` / mouse DECSETs), written from the predicates, including that `preserve` and `strip-mux-only` take the same runtime row. The table is pinned for every stock CLI, with and without tmux, on both the live strip and the replay strip, plus the published flag (test/claude-scrollback-strip.test.ts), so the two halves cannot drift and a mis-ordered replay branch fails. Docs and comments that still said opencode keeps its mouse reporting or gets the narrow strip are updated (CLAUDE.md, architecture-invariants, scrollback and copy-shortcut plans, session.ts, terminal-ui.js). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 10 +- docs/scrollback-fix-plan.md | 4 +- docs/terminal-copy-shortcut-plan.md | 5 +- src/config/cli-registry/schema.ts | 2 +- src/config/cli-registry/stock.ts | 2 +- src/config/cli-registry/types.ts | 37 ++++++- src/session.ts | 70 ++++++++++--- src/web/public/terminal-ui.js | 44 ++++---- src/web/routes/session-routes.ts | 50 ++++++--- test/claude-scrollback-strip.test.ts | 148 +++++++++++++++++++++++++-- test/terminal-touch-tap.test.ts | 29 +++++- 12 files changed, 318 insertions(+), 85 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a665ae61..cb11f11b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,7 +284,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, text goes through `terminal.paste()` so bracketed-paste markers survive. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](docs/architecture-invariants.md#terminal-paste-ctrlv) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity/omp get a NARROW strip (alt-screen toggles only). ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) +**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode gets the MIDDLE strip (alt-screen toggles + mouse DECSETs, `3J` kept); every other tmux-backed mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (alt-screen toggles only). Table: `CliCapabilities.altScreen` JSDoc, pinned in test/claude-scrollback-strip.test.ts. ⚠️ Gated on `useMux`: direct-PTY sessions must keep the alt screen. Wheel and touch forward to the CLI for **claude ≥ 2.1.187 ONLY, and only while it has mouse tracking on** (`cliMouseTracking`: fullscreen claude sets it, its default inline renderer does not and scrolls locally like codex); ⚠️ never re-add codex without a fresh measurement (it ignores SGR wheel reports). ⚠️ `getClaudeCliVersion()` must never cache a FAILED probe. ⚠️ Hand-report clicks only while the CLI has mouse tracking on: `_shouldReportMouseToCli()` gates all three report sites on `cliMouseTracking` (from `_recordStrippedMouseMode()`, session.ts), or a plain shell prints the reports as literal text. Read `_logScrollRouting()` before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding) **Detached start + service install**: `codeman web -d` relaunches the same entry script `detached:true` (setsid); `nohup` is not what makes it survive. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile + `/api/status` probe), or a second instance attaches to the first one's live sessions. ⚠️ Never report success not observed: poll `/api/status` until the child answers or dies. `--stop` must verify the pid still looks like Codeman (`ps -o command=`) before signalling. Unit/label names live only in `config/service-names.ts`. `service install` bakes the installing shell's PATH into the unit and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install) **Self-update** (App Settings → System → Updates): in-app updater for git-clone installs under a supervisor (`systemd`, `launchd`, `launchd-daemon`, `docker-compose`, else `none`). The work runs in a DETACHED `scripts/self-update.sh` writing `update-status.json`, polled across the restart; pure helpers in `src/web/self-update.ts`. ⚠️ Compose: the restart kills the script, so nothing may be appended after the `restarting` marker; the repo must stay a host bind mount over `/opt/codeman` and the image must keep devDependencies + toolchain. ⚠️ `evaluateEnvironmentGate()` refuses releases that change `server.Dockerfile`/`docker-compose.yaml` or add `.env.example` keys, re-evaluated on `POST /api/system/update`; unknowns fail OPEN, but the exit-to-restart needs `--restart-by-exit 1` (`CODEMAN_RESTART_BY_EXIT=1` only in the Compose file). ⚠️ Keep the agent CLIs in `server.Dockerfile` pinned. → [docs/docker-self-update.md](docs/docker-self-update.md), [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 75bbff65..6594f653 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -37,7 +37,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP) -**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. +**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `--config model_reasoning_effort=<level>` from `reasoningEffort`, `--config tui.animations=<bool>` from `animations`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, which is now `isMuxMouseStripMode`, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`. **DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it. @@ -55,7 +55,7 @@ Pure helpers unit-tested in `test/base-path.test.ts` + `test/webview-proxy.test. ⚠️ **The resolver needs the strictest identity probe of any CLI**, because `dsh` is not merely a squattable npm name: Debian ships an unrelated `dsh` (dancer's shell, `apt install dsh`) that would answer a version probe convincingly. `probeDeepSeekVersion()` therefore checks `dsh --help` against `DEEPSEEK_IDENTITY_REGEX` (`DeepSeek Harness`) FIRST and only then reads a version, and `test/deepseek-cli-resolver.test.ts` pins both the rejection and the VITEST hermeticity gate with a real executable fixture. `DEEPSEEK_VERSION_REGEX` keeps the prerelease tail (`0.1.1-rc.2`), since truncating it would report an rc as a release; it is shared with the `dsh` dependency-registry entry so doctor and run mode agree about the version even though the resolver is stricter about identity. -Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. +Model is NOT a session field: it is a composition entry in the profile's config tree (`agent-default-model`), configured in `~/.dsh/settings.yaml` + `cordis.patch.yml`, so both create paths deliberately resolve no model for this mode. Env allowlist: `DSH_*` + `DEEPSEEK_*`; provider keys named by a settings-file `apiKeyEnv` stay OUT, which is pi's 34-provider-key problem in a new shape and gets the same answer. Docker seeds `~/.dsh` per-file (`.env`, `settings.yaml`, `cordis.patch.yml`) and the image installs its OWN profile, because `profiles/` is a per-profile `node_modules` tree — host-arch-specific and far too large to copy per container start. Stays OUT of `isAltScreenStripMode()` (third-party fullscreen TUI — the opencode case, i.e. `isMuxMouseStripMode` is the shape to measure here too). ⚠️ `classifyProfile()` reads the profile's BUNDLES, and "unknown means launchable" is deliberate (anyone can publish an app bundle), but it has one knowably-wrong case: `readProfile()` returns an empty bundle list for a `package.json` with no `dsh.profile.bundles`, which made the SHIPPED `web`/`headless` profiles look third-party and launchable. The directory name is therefore consulted as a LAST resort (`STOCK_NON_INTERACTIVE_PROFILES`), after the bundle patterns, so real bundle evidence always wins over a name the user chose. The loose `tui` arm carries word boundaries for the same reason: it decides which profile boots by default, and matching the middle of `intuition` is not a rule anyone could predict. ⚠️ The generated shim is written **temp + rename**, not in place: the TUI can be exec'ing that exact path while an upgraded Codeman refreshes it, and a half-written file is a syntax error the caller then retries four times per state change forever. Bump `SHIM_VERSION` whenever `SHIM_SOURCE` changes, or an existing shim keeps matching the embedded marker and is never refreshed. Availability via `GET /api/deepseek/status`, the widest per-CLI status shape (`available`/`runnable`/`path`/`version`/`dshHome`/`defaultProfile`/`profiles`); `POST /api/deepseek/install-profile` bootstraps a profile and is the only endpoint in Codeman that installs third-party code — regex-confined specifier, argv-array spawn, privileged grant required in multi-user mode, and the held-open request is bounded by a HAND-ROLLED timeout over a `detached: true` process group (negative-pid SIGTERM→SIGKILL, as `runGit()` does in git-clone.ts). ⚠️ Node's own `spawn` `timeout` is NOT enough: a plugin install fans out into package-manager children, the built-in timeout signals only the direct child, and the survivors hold the inherited stdio pipes open so `close` never fires and the request leaks forever. User guide: `docs/deepseek-integration.md`. Tests: `test/deepseek-mode.test.ts`, `test/deepseek-cli-resolver.test.ts`. **Pi specifics** (#206, `docs/pi-integration.md`): command built by `buildPiCommand()` (`--model` — the only builder whose model regex admits `:` and `/`, for `sonnet:high` and `openai/gpt-4o` — plus `--provider`, `--thinking`, `--session <id>` / `-c`, and the TRI-STATE `--approve`/`--no-approve`). ⚠️ **Pi has no permission prompts and no sandbox**, so there is no `--dangerously-skip-permissions` analog and Codeman must not invent one; the privilege-shaped knob is `approveProjectTrust`, which makes pi LOAD AND EXECUTE repo-local `.pi/extensions` TypeScript and npm-install missing project packages. It therefore joins `clampExternalCliBypassForOwner()`'s **materialize** branch (gemini's, not codex/antigravity's only-if-sent one): an absent config still yields `--no-approve` for a non-granted owner, because pi's own default is an interactive prompt the session user could answer themselves. ⚠️ `--api-key` is NEVER wired — it would put a provider secret on the spawn command line. ⚠️ Pi stays **out** of `isAltScreenStripMode()`: its default TUI renders into the main screen with terminal-owned scrollback (nothing to strip), and since 0.84.0 the user can flip to a fullscreen TUI at runtime via `/settings`, where the alt screen is load-bearing — being out of the list is exactly what makes that switch safe. ⚠️ Only the `PI_*` env prefix was added; pi's ~34 provider keys share no prefix and `ALLOWED_ENV_PREFIXES` is a single GLOBAL list with no mode context, so admitting them would widen the allowlist for every mode at once (a mode-aware allowlist is the tracked follow-up). ⚠️ `pi` is a short, GENERIC binary name, so unlike the sibling resolvers `pi-cli-resolver.ts` sanity-probes `pi --version` (cached, vitest-skipped) and requires semver-shaped output; `GET /api/pi/status` carries `version` on top of the sibling `{available, path}` shape so a misresolution is diagnosable. Local echo: pi lands on the `'buffer'` overlay via the fallthrough in `_updateLocalEchoState` (pinned in `test/local-echo-codex-gating.test.ts`); if pi's live composer turns out to fight it the way codex's did, the fallback is one `'off'` branch. Tests: `test/pi-mode.test.ts`, `test/routes/external-cli-bypass-clamp.test.ts` (first-ever coverage of the clamp). @@ -213,11 +213,11 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` ### Terminal scrollback: strip flavors and wheel/touch forwarding -**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`. +**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in the mouse-strip branch, so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. ⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. -**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (strip mode AND the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. +**Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. **Wheel/touch forwarding is NOT gated on viewport-at-bottom** (#205, `terminal-ui.js:_shouldForwardWheelToApp`): for sessions verified to scroll their own transcript on SGR wheel reports (claude ≥ 2.1.187 while `cliMouseTracking` is true, i.e. fullscreen; version via the local/docker/remote `--version` probes), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches, 5-tick cap, 512-byte queue bound). It used to gate on the viewport being at the bottom so both scrollbacks stayed reachable, but a repaint-mode CLI keeps NO terminal scrollback of its own — xterm's buffer holds only replayed repaint frames, so local scrolling drags the CLI's pinned prompt box up the screen over stale frames; and `scrollToLastNonEmptyLine()` routinely parked the viewport off-bottom, silently pinning the wheel to local. Forwarding now snaps the viewport home first (SGR coordinates address the LIVE screen — a report computed from a scrolled-up viewport would hit-test the wrong row). Local scrollback remains on Shift+wheel and the `terminalWheelLocalScrollback` opt-out (both also cover touch via the shared gate; touch has no Shift, so the setting is its only local pin). `_wheelScrollLines()` normalizes `deltaMode` (Firefox fires LINE deltas ≈3/notch — read as pixels that rounded to 0 and fell to the ±1 fallback, ~4× too slow; PAGE deltas scale by `terminal.rows`) while keeping the #154 Shift-axis trap (macOS trackpads put Shift+scroll magnitude on deltaX). Tests: `test/terminal-touch-tap.test.ts`. @@ -674,7 +674,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise 2. `copyTerminalSelection` is a registry `action` **deliberately missing from `SHORTCUT_ACTIONS`** (same trick as `command-palette`): the entry stays rebindable and disableable in App Settings, while the generic document-capture loop, which `preventDefault()`s every match it dispatches, skips it and lets the terminal handler decide. 3. The gate is keydown-only (the custom handler also runs for `keypress`/`keyup`; that is safe here only because xterm drops a Ctrl keypress itself, see the keypress rule above), and `Ctrl+Shift+C` never falls through to the PTY: an "explicit copy" chord that interrupts a running agent because the selection happened to be empty is a footgun with no upside. -Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses). +Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. `opencode` used to be in that list and no longer is: its mouse DECSETs are stripped server-side (`isMuxMouseStripMode`, see the strip-flavours invariant), so a plain drag selects there. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses). **The main terminal's four copy paths clean the selection first** (`CodemanCopySelection.clean` in constants.js, pure; `cleanedTerminalSelection()` in terminal-ui.js is the half that reads the live terminal). Those four are the `Ctrl+C` chord, right-click, the phone selection button and Auto Copy. ⚠️ Three routes still copy the RAW padded rows, all of them predating the clean: the browser's own Edit → Copy, which xterm's own `copy` listener on the terminal element serves with `selectionText` directly; a `copy-selection` shortcut the user disabled in App Settings, where nothing calls `preventDefault()` and that native listener runs; and the subagent/teammate windows, which build their own `Terminal` in panels-ui.js with no copy wiring at all. xterm hands back whole screen ROWS and its own trim drops only cells that were never written to, so the real spaces a full-screen TUI paints across the unused part of a row count as content and reach the clipboard. Measured against Claude Code in a 282-column pane, single lines arrived carrying 138 trailing spaces on top of the two-space transcript indent. The clean drops each line's trailing run, and strips a LEADING margin when the session's CLI declares one. Four rules keep it honest: diff --git a/docs/scrollback-fix-plan.md b/docs/scrollback-fix-plan.md index 497d2707..79210941 100644 --- a/docs/scrollback-fix-plan.md +++ b/docs/scrollback-fix-plan.md @@ -278,8 +278,8 @@ Touch is always-local by design, and Claude sessions keep content in the normal - The `terminalWheelLocalScrollback` opt-out setting keeps working (pins plain wheel to local). - The viewport-at-bottom gate stays: once the user scrolled up locally, wheel stays local until they return to bottom. - 40ms SGR coalescing: never send per-event writes to the server. -- Strip parity triangle: `session.ts` live strip ↔ `session-routes.ts` replay strip ↔ `_sessionUsesServerMouseStrip()` in the frontend. If you touch mode lists, update all three. -- Don't add `opencode`/`antigravity` to any strip/forward list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`), which is not a strip/forward list. antigravity/grok/deepseek/omp remain unverified. +- Strip parity: `session.ts` live strip ↔ `stripReplayBuffer()` in `session-routes.ts`, both driven by the registry's `altScreen` value and pinned together for every stock CLI in `test/claude-scrollback-strip.test.ts`. The frontend keeps no mode list: `_shouldReportMouseToCli()` reads only the server-published `cliMouseTracking`. +- Don't add `opencode`/`antigravity` to the wheel-FORWARD list; their TUI wheel behavior is unverified (documented at `_shouldForwardWheelToApp`). ⚠️ 2026-09-16: opencode's half is now MEASURED — 1.18.31 ignores SGR wheel reports but pages its transcript on PageUp/PageDown — so it belongs in the **paging** list (`_localScrollbackIsHollow`). ⚠️ It also joined a STRIP list that same day, for a different reason: `isMuxMouseStripMode` removes its mouse DECSETs so a drag selects text again (see `docs/architecture-invariants.md` §Three strip flavors). antigravity/grok/deepseek/omp remain unverified. - The chunk-boundary sequence carry in `_handleTerminalOutput` must not be weakened. ## Testing (per repo rules) diff --git a/docs/terminal-copy-shortcut-plan.md b/docs/terminal-copy-shortcut-plan.md index 898bc096..6598be93 100644 --- a/docs/terminal-copy-shortcut-plan.md +++ b/docs/terminal-copy-shortcut-plan.md @@ -82,6 +82,7 @@ This is exactly how `command-palette` already behaves: it is a full registry ent - The server strips mouse-tracking DECSETs for `claude`, `codex`, and `gemini` (`isAltScreenStripMode`, `src/session.ts:179`), which is why plain drag-select works in those tabs even though the TUI has mouse tracking on. - `shell`, `opencode`, and `antigravity` keep mouse reporting, so xterm requires `Shift`+drag to force a selection there. Worth one line in the docs, it is not a code change. + ⚠️ **Corrected 2026-09-16:** `opencode` no longer keeps mouse reporting in the browser. Its TUI enables tracking DECSETs, tmux `mouse off` passes them through to the tmux client, and xterm then reported DRAGS to the TUI instead of selecting — so `Shift`+drag was the only way to select, and a plain drag silently copied nothing (measured 62 `none` / 18 `any` over 16s; 5/5 dead drags while `any`). The server now strips those DECSETs (`isMuxMouseStripMode`), so a plain drag selects in opencode. `shell` and `antigravity` are unchanged. - Touch devices deliberately disable selection entirely (`body.touch-device .terminal-container .xterm{user-select:none !important}`, `styles.css:3196`), and phones have no Ctrl key. This feature is desktop and hardware-keyboard only, with no mobile regression surface. ### 2.6 Helpers that already exist and should be reused @@ -245,7 +246,7 @@ The shortcut overlay (`Ctrl+?`) and App Settings -> Shortcuts are registry-drive | Whitespace-only or empty selection | `getSelection()` empty string is treated as "no selection", so Ctrl+C still interrupts | | macOS Cmd+C | registry treats ctrl/meta as interchangeable, so with a selection it takes our path (same visible result as today's native copy), without one it falls through | | Chrome/Firefox `Ctrl+Shift+C` is the devtools inspect chord | browser-level and may still toggle devtools, our copy runs regardless. Document as a caveat, `Ctrl+C` is the primary path | -| Selection in a tab whose TUI owns the mouse (`shell`/`opencode`/`antigravity`) | unchanged, `Shift`+drag selects, then Ctrl+C copies | +| Selection in a tab whose TUI owns the mouse (`shell`/`antigravity`; `opencode` left this list on 2026-09-16 — its DECSETs are stripped now) | unchanged, `Shift`+drag selects, then Ctrl+C copies | | Web tab (iframe dashboard) focused | xterm handler never runs, browser-native copy inside the iframe | | Teammate/subagent terminals (`panels-ui.js:2268`, `onData` wired) | same limitation exists there, out of scope for this PR (section 8) | @@ -281,7 +282,7 @@ Against a throwaway session on the live instance (`curl -sk https://localhost:30 3. Type a few characters with local echo on (phone or `localEchoEnabled` forced), press Ctrl+C with no selection, confirm buffered text plus interrupt behave as before. 4. Uncheck the shortcut in App Settings -> Shortcuts, confirm Ctrl+C always interrupts even with a selection. 5. Rebind it, confirm the new chord copies and Ctrl+C reverts to pure interrupt. -6. Repeat 1 and 2 in an `opencode` or `shell` tab using Shift+drag to select. +6. Repeat 1 and 2 in a `shell` or `antigravity` tab using Shift+drag to select (`opencode` selects with a plain drag since 2026-09-16). 7. Load over plain HTTP (`--host` LAN or `http://127.0.0.1:<port>`) and confirm the `execCommand` fallback copies and focus returns to the terminal. 8. Mobile smoke: confirm nothing changed (selection is CSS-disabled, no Ctrl key). diff --git a/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 5391c5ac..4abc8a6f 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -289,7 +289,7 @@ const capabilitiesSchema = z requiresMux: z.boolean(), hooks: z.enum(['none', 'always', 'supervised']), transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']), - altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']), + altScreen: z.enum(['strip-full', 'strip-mux-only', 'strip-mux-and-mouse', 'preserve']), echo: echoSchema, wheelForward: z .object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() }) diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 45c40c7a..75d4c739 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -491,7 +491,7 @@ const OPENCODE: CliEntry = { }, capabilities: { ...agentDefaults(), - altScreen: 'strip-mux-only', + altScreen: 'strip-mux-and-mouse', echo: { policy: 'buffer', anchor: { kind: 'cursor' }, predictProfile: undefined }, // opencode's global config dir is xdg-basedir's `$XDG_CONFIG_HOME/opencode`. mcpConfig: { diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 147188c5..721e882b 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -436,11 +436,40 @@ export interface CliCapabilities { */ transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none'; /** - * 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs). - * 'strip-mux-only' — only tmux's own attach-time smcup (the safe default). - * 'preserve' — leave everything (a direct-PTY shell running vim/less/htop). + * What the server strips from this CLI's output stream before the browser sees it. + * The value encodes three independent choices (predicates in session.ts): + * + * | value | alt-screen toggles | `3J` (erase scrollback) | mouse DECSETs | + * |-----------------------|---------------------|-------------------------|---------------------| + * | `strip-full` | stripped | stripped | stripped | + * | `strip-mux-and-mouse` | stripped under tmux | kept | stripped under tmux | + * | `strip-mux-only` | stripped under tmux | kept | kept | + * | `preserve` | stripped under tmux | kept | kept | + * + * `strip-full` is `isAltScreenStripMode`; `strip-mux-and-mouse` is `isMuxMouseStripMode`; + * every other value takes `isMuxAltScreenOnlyStripMode`, so at runtime `preserve` and + * `strip-mux-only` are the same row — `preserve` only says what such a CLI's pane + * holds (terminal-owned scrollback: a shell, pi), not a different strip. + * + * - alt-screen: the tmux CLIENT emits `smcup` as its first bytes at attach, parking + * xterm in the scrollback-less alternate buffer; a pane program's own toggles never + * reach the client (tmux repaints instead). "Under tmux" means `useMux`: on a + * direct-PTY fallback the `?1049h` is the program's own and must stay. + * - `3J`: a user's `clear` is a deliberate scrollback wipe; only an Ink TUI's + * redraw-driven `3J` (strip-full) is noise. + * - mouse DECSETs: stripping them keeps a drag a local selection instead of a report + * to the TUI. The browser then hand-encodes clicks (`_sendSyntheticSgrTap`), gated + * on the `cliMouseTracking` the server records as it strips. Kept where a program's + * own mouse support must work in the pane (htop/vim in a shell). + * + * Stock CLIs: `strip-full` = claude, codex, gemini (Ink TUIs); `strip-mux-and-mouse` = + * opencode (a full-screen TUI that enables tracking itself); `strip-mux-only` = + * antigravity, grok, deepseek, omp; `preserve` = shell, pi. + * + * A fourth combination is the point to split this into flags; three is still cheaper + * as an enum. */ - altScreen: 'strip-full' | 'strip-mux-only' | 'preserve'; + altScreen: 'strip-full' | 'strip-mux-only' | 'strip-mux-and-mouse' | 'preserve'; echo: { policy: 'buffer' | 'predict' | 'off'; /** How the local-echo overlay locates the composer row. */ diff --git a/src/session.ts b/src/session.ts index 7e92df81..075ff2a5 100644 --- a/src/session.ts +++ b/src/session.ts @@ -265,10 +265,11 @@ function cliExportsTruecolor(mode: SessionMode): boolean { * Codex, Claude Code, and Gemini are known, controlled (Ink/React) TUIs that * repaint via cursor positioning, so dropping the alt-screen switch is safe — * content stays in the normal buffer. Excluded: `shell` (arbitrary programs like - * vim/less/htop legitimately need the alt screen), `opencode` (renders its own - * TUI that may rely on it), `pi` (below) and `grok` (a fullscreen alt-screen TUI - * with mouse support, i.e. the opencode case, not the Ink case). Keep parity - * with the replay-side strip in session-routes.ts. + * vim/less/htop legitimately need the alt screen), `opencode` (its own MIDDLE strip, + * isMuxMouseStripMode), `pi` (below) and `grok` (a fullscreen alt-screen TUI with + * mouse support). Keep parity with the replay-side strip (`stripReplayBuffer` in + * session-routes.ts); the table in `CliCapabilities.altScreen` is pinned for every + * stock CLI in test/claude-scrollback-strip.test.ts. * * ⚠️ Being excluded here does NOT preserve the alt screen. Every excluded mode * falls through to isMuxAltScreenOnlyStripMode(), which strips the alt-screen @@ -288,8 +289,10 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { /** * Modes that need the NARROW strip: alt-screen toggles only, leaving `\x1b[3J` - * and the mouse-tracking DECSETs alone. Applies to every mode `isAltScreenStripMode` - * excludes, but ONLY when the session is tmux-backed (`useMux`). + * and the mouse-tracking DECSETs alone. Applies to every mode that is neither + * `strip-full` (isAltScreenStripMode) nor `strip-mux-and-mouse` (isMuxMouseStripMode), + * so `strip-mux-only` and `preserve` alike, but ONLY when the session is tmux-backed + * (`useMux`). * * The bug (issue #205): the tmux CLIENT emits `smcup` (`\x1b[?1049h`) as its first * bytes on attach, before any program has run. Unstripped, xterm.js parks in the @@ -314,7 +317,31 @@ export function isAltScreenStripMode(mode: SessionMode): boolean { * `\x1b[3J` from a user's own `clear` is a deliberate "wipe my scrollback". */ export function isMuxAltScreenOnlyStripMode(mode: SessionMode, useMux: boolean): boolean { - return useMux && !isAltScreenStripMode(mode); + if (!useMux) return false; + const altScreen = getCli(mode)?.capabilities.altScreen; + return altScreen !== 'strip-full' && altScreen !== 'strip-mux-and-mouse'; +} + +/** + * Modes whose mouse-tracking DECSETs must be stripped, leaving `3J` alone: + * `altScreen: 'strip-mux-and-mouse'`, i.e. a mouse-capable full-screen TUI. + * + * Why this exists (opencode, measured 2026-09-16): the TUI enables tracking + * DECSETs, tmux runs with `mouse off` and therefore passes the PANE's DECSETs + * straight through to the tmux client, and the browser's xterm obeyed them — + * `mouseTrackingMode` flipped to `'any'` and xterm then reported DRAGS to the TUI + * instead of selecting locally. "Mark text, copy on select" silently did nothing + * (measured 62 `none` / 18 `any` over 16s, and 5/5 dead drags while `any`), and + * the obvious fallback — Ctrl+C — is opencode's `app_exit`, so the failure also + * ended sessions. Stripping at the source keeps xterm in selection mode; clicks + * still reach the CLI through the browser's hand-encoded tap, which this strip + * publishes as `cliMouseTracking` (`_recordStrippedMouseMode`). + * + * Gated on `useMux` for the same reason as the narrow strip: on the direct-PTY + * fallback the program's own DECSETs really do reach xterm and must be honoured. + */ +export function isMuxMouseStripMode(mode: SessionMode, useMux: boolean): boolean { + return useMux && getCli(mode)?.capabilities.altScreen === 'strip-mux-and-mouse'; } // Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv) @@ -2489,14 +2516,21 @@ export class Session extends EventEmitter { // redraws overwrite only the cells they target, so non-erased rows keep // their content. Gated to Codex/Claude/Gemini (isAltScreenStripMode). // - // Every OTHER mode (shell/opencode/antigravity) gets the NARROW strip when it - // is tmux-backed: alt-screen toggles only, because the sequence that breaks + // Every OTHER mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip + // when it is tmux-backed: alt-screen toggles only, because the sequence that breaks // scrollback there is tmux's own client-side smcup at attach, not anything the // program in the pane emitted (issue #205, see isMuxAltScreenOnlyStripMode). // 3J and the mouse DECSETs stay, so `clear` and mouse-aware TUIs keep working. + // + // The MIDDLE case (isMuxMouseStripMode) is a mouse-capable full-screen TUI: + // it needs smcup AND the mouse DECSETs gone — otherwise the pane's tracking + // reaches xterm and every drag becomes a mouse report instead of a text + // selection, which is what killed mark-and-copy in opencode — while 3J stays, + // because a TUI is not a `clear` consumer. const fullStrip = isAltScreenStripMode(this.mode); - const altOnlyStrip = !fullStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); - if (fullStrip || altOnlyStrip) { + const mouseStrip = isMuxMouseStripMode(this.mode, this._useMux); + const altOnlyStrip = !fullStrip && !mouseStrip && isMuxAltScreenOnlyStripMode(this.mode, this._useMux); + if (fullStrip || mouseStrip || altOnlyStrip) { // Reassemble sequences split across PTY chunk boundaries first: a chunk // ending mid-sequence ('\x1b[?104' now, '9h' next) would slip past the // strip below and leave xterm stuck in the scrollback-less alt buffer @@ -2515,14 +2549,18 @@ export class Session extends EventEmitter { // eslint-disable-next-line no-control-regex data = data.replace(/\x1b\[\?(?:47|1047|1049)[hl]/g, ''); if (fullStrip) { - data = data + // eslint-disable-next-line no-control-regex + data = data.replace(/\x1b\[3J/g, ''); + } + if (fullStrip || mouseStrip) { + data = data.replace( // eslint-disable-next-line no-control-regex - .replace(/\x1b\[3J/g, '') - // eslint-disable-next-line no-control-regex - .replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => { + /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, + (seq) => { this._recordStrippedMouseMode(seq); return ''; - }); + } + ); } } diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 9647927e..030a3643 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1265,7 +1265,8 @@ Object.assign(CodemanApp.prototype, { // A real mouse click normally reaches the PTY through xterm's own mouse // encoder, but that encoder only runs while mouseTrackingMode is ON — and // the server strips the enabling DECSETs from claude/codex/gemini output - // (isAltScreenStripMode, session.ts) so the wheel keeps scrolling + // (isAltScreenStripMode, session.ts) and from opencode's (isMuxMouseStripMode, + // so a drag selects text) so the wheel keeps scrolling // scrollback. Desktop clicks therefore stopped reporting entirely (the // same breakage the mobile touchend tap branch above works around). // Hand-encode the SGR report for plain left-clicks on those sessions. @@ -5271,36 +5272,32 @@ Object.assign(CodemanApp.prototype, { } }, - // Mirror of the server's isAltScreenStripMode (session.ts): session modes whose - // output stream has mouse-tracking DECSET sequences stripped before reaching the - // browser. For these, xterm's live mouseTrackingMode is useless as a gate — the - // PTY-side TUI keeps tracking enabled, we just never see the enable sequence. /** - * True when the browser has to hand-encode a click report for the CLI. + * True when the browser has to hand-encode a click report for the CLI: the + * server stripped this session's mouse-tracking DECSETs out of the stream (so + * xterm's own encoder is permanently idle here and something has to stand in + * for it) AND the CLI has a tracking mode on right now. * - * Two conditions, and dropping either one is a bug that has already happened: + * One flag answers both. The server sets `cliMouseTracking` only as it strips a + * tracking DECSET (`_recordStrippedMouseMode` in session.ts, called from the + * mouse-strip branch of `_handleTerminalOutput` and nowhere else), so it can + * only ever be true for a mode whose DECSETs are stripped: whichever modes the + * registry decides to strip, the browser follows, with no mode list here to + * keep in step. For a `preserve` / `strip-mux-only` mode the flag stays false + * and xterm keeps encoding its own reports. That invariant is pinned server-side + * in test/claude-scrollback-strip.test.ts. * - * 1. The session's mode is one whose mouse DECSETs the server STRIPS out of - * the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why - * xterm's own encoder is permanently idle here and something has to stand - * in for it. - * 2. The CLI actually has a mouse-tracking mode on right now. The server - * records that as it strips (`_recordStrippedMouseMode` in session.ts) and - * publishes it as `cliMouseTracking`. Without this half the browser - * reported EVERY click, so a CLI sitting at its composer with no dialog - * open, or a pane that has fallen back to a shell prompt, received mouse - * reports it never asked for. A shell prints those as literal text - * (`[<0;88;20M`) and they garble the next line typed. + * Without the flag the browser reported EVERY click, so a CLI sitting at its + * composer with no dialog open, or a pane that has fallen back to a shell + * prompt, received mouse reports it never asked for. A shell prints those as + * literal text (`[<0;88;20M`) and they garble the next line typed. * * Fails toward silence: an unknown or stale flag reports nothing rather than * injecting bytes. After a server restart the flag is false until the CLI * re-emits its DECSET, which closing and reopening a dialog does. */ _shouldReportMouseToCli() { - const session = this.sessions?.get(this.activeSessionId); - const mode = session?.mode || 'claude'; - if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false; - return session?.cliMouseTracking === true; + return this.sessions?.get(this.activeSessionId)?.cliMouseTracking === true; }, // True when xterm's viewport shows the live PTY screen (not scrolled up into @@ -5631,7 +5628,8 @@ Object.assign(CodemanApp.prototype, { * The reason is that the habit and xterm's Shift mean different things once * the DECSETs are stripped. xterm reads Shift as "force selection" ONLY while * the app actually has mouse tracking on; the server strips those DECSETs for - * claude/codex/gemini (isAltScreenStripMode), so xterm's mouseTrackingMode is + * claude/codex/gemini (isAltScreenStripMode) and opencode (isMuxMouseStripMode), + * so xterm's mouseTrackingMode is * permanently `none`, that branch is unreachable, and Shift instead falls into * `_onIncrementalClick` — EXTEND an existing selection. Extending is a no-op * when `selectionStart` is null, so the drag never anchors and no selection is diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index a21b19f4..664002bf 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -36,6 +36,7 @@ import { isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode, + isMuxMouseStripMode, } from '../../session.js'; import type { PaneCaptureOptions } from '../../mux-interface.js'; import { SseEvent } from '../sse-events.js'; @@ -224,6 +225,37 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g; // eslint-disable-next-line no-control-regex const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g; +/** + * The replay half of the strip parity triangle: what `_handleTerminalOutput` (session.ts) + * removes from the live stream, removed again from a stored buffer before it is replayed, + * because a buffer recorded before the live-side strip existed (or by an older server) + * still carries the sequences, and one replayed enable is enough to re-park xterm. + * + * - `strip-full` (claude/codex/gemini): alt-screen toggles, `3J` and mouse DECSETs. + * xterm obeys the toggles by switching to its scrollback-less alt buffer and wiping + * saved lines, so history disappeared on tab switch. + * - `strip-mux-and-mouse` (opencode, tmux-backed): alt-screen toggles and mouse DECSETs. + * A replayed tracking enable parks xterm in report mode, where a drag goes to the CLI + * instead of selecting text (and Ctrl+C without a selection is opencode's app_exit). + * `3J` stays: a TUI is not a `clear` consumer. + * - every other mode, tmux-backed: tmux's own client smcup only (#205). + * + * Exported so the parity with the live strip is a test (claude-scrollback-strip.test.ts). + */ +export function stripReplayBuffer(buffer: string, mode: SessionMode, usesMux: boolean): string { + if (isAltScreenStripMode(mode)) { + return buffer + .replace(ALT_SCREEN_TOGGLE_PATTERN, '') + .replace(ERASE_SCROLLBACK_PATTERN, '') + .replace(MOUSE_TRACKING_PATTERN, ''); + } + if (isMuxMouseStripMode(mode, usesMux)) { + return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, '').replace(MOUSE_TRACKING_PATTERN, ''); + } + if (isMuxAltScreenOnlyStripMode(mode, usesMux)) return buffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); + return buffer; +} + /** * Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer. * Ink (Claude Code's TUI) uses absolute cursor positioning (CSI n d = VPA) to animate @@ -3087,21 +3119,9 @@ export function registerSessionRoutes( ? rawBuffer : stripInkRedrawBloat(rawBuffer); - // Strip alt-screen toggles and scrollback-erase from Codex/Claude byte - // streams. xterm.js obeys them by switching to its scrollback-less alt - // buffer and wiping saved lines, so conversation history disappears on tab - // switch. Same gate as the live-stream strip in session.ts. - if (isAltScreenStripMode(session.mode)) { - strippedBuffer = strippedBuffer - .replace(ALT_SCREEN_TOGGLE_PATTERN, '') - .replace(ERASE_SCROLLBACK_PATTERN, '') - .replace(MOUSE_TRACKING_PATTERN, ''); - } else if (isMuxAltScreenOnlyStripMode(session.mode, session.usesMux)) { - // tmux-backed shell/opencode/antigravity: drop tmux's own client smcup only. - // A byte buffer recorded before the live-side strip existed can still carry - // it, and one replayed `\x1b[?1049h` re-parks xterm in the alt buffer (#205). - strippedBuffer = strippedBuffer.replace(ALT_SCREEN_TOGGLE_PATTERN, ''); - } + // Same strip as the live stream (session.ts), so a buffer recorded before the + // live-side strip existed cannot re-park xterm on replay. See stripReplayBuffer. + strippedBuffer = stripReplayBuffer(strippedBuffer, session.mode, session.usesMux); if (tailBytes > 0 && strippedBuffer.length > tailBytes) { // Fast path: tail from the end, skip expensive banner search on full 2MB buffer. diff --git a/test/claude-scrollback-strip.test.ts b/test/claude-scrollback-strip.test.ts index 80336109..9abe1323 100644 --- a/test/claude-scrollback-strip.test.ts +++ b/test/claude-scrollback-strip.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../src/session.js'; +import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode, isMuxMouseStripMode } from '../src/session.js'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; +import { stripReplayBuffer } from '../src/web/routes/session-routes.js'; type SessionInternals = { _handleTerminalOutput(data: string): void; @@ -94,18 +96,96 @@ describe('Shell terminal output on a DIRECT PTY is NOT stripped (vim/less/htop n describe('isMuxAltScreenOnlyStripMode', () => { it('covers exactly the modes the full strip does not, and only under tmux', () => { - for (const mode of ['shell', 'opencode', 'antigravity'] as const) { + for (const mode of ['shell', 'antigravity'] as const) { expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(true); // Direct-PTY fallback: the program's own alt screen really does reach xterm. expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false); } - // The full strip already owns these; never double-gate them here. - for (const mode of ['claude', 'codex', 'gemini'] as const) { + // The full strip and the mouse strip already own their modes; never double-gate. + for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) { expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false); } }); }); +/** + * opencode's TUI is a mouse-capable full-screen app: it enables tracking DECSETs, + * tmux `mouse off` passes them straight through to the tmux CLIENT, and xterm then + * reports DRAGS to the TUI instead of selecting locally. That killed "mark text, + * copy on select" intermittently — and the obvious fallback, Ctrl+C, is opencode's + * `app_exit`, so the failure also ended sessions. + * + * It needs the alt-screen strip AND the mouse strip, but NOT `3J`: opencode is a + * TUI, not a `clear` consumer, so keeping 3J is the conservative middle ground + * between the full strip and the narrow one. + */ +describe('opencode: alt-screen + mouse DECSETs stripped, 3J kept', () => { + it('is a mouse-strip mode under tmux, and only there', () => { + expect(isMuxMouseStripMode('opencode', true)).toBe(true); + // Direct-PTY fallback: the pane's own alt screen really does reach xterm. + expect(isMuxMouseStripMode('opencode', false)).toBe(false); + for (const mode of ['claude', 'codex', 'gemini', 'shell', 'antigravity'] as const) { + expect(isMuxMouseStripMode(mode, true)).toBe(false); + } + }); + + it('drops mouse tracking so xterm keeps local text selection', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + const emitted: string[] = []; + session.on('terminal', (data) => emitted.push(data)); + + handleOutput(session, '\x1b[?1003h\x1b[?1006hTUI\x1b[?1006l\x1b[?1003l'); + + expect(emitted[0]).toBe('TUI'); + expect(session.terminalBuffer).toBe('TUI'); + }); + + it('still drops tmux’s attach-time smcup', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[?1049h\x1b[22;0;0t\x1b[H\x1b[2Jprompt'); + + expect(session.terminalBuffer).toBe('\x1b[22;0;0t\x1b[H\x1b[2Jprompt'); + }); + + it('KEEPS 3J, unlike the full strip', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[3Jtext'); + + expect(session.terminalBuffer).toBe('\x1b[3Jtext'); + }); + + it('publishes cliMouseTracking so the browser can hand-encode clicks', () => { + // xterm can never see the DECSETs once they are stripped, so the click path + // (_sendSyntheticSgrTap) is the only way a click still reaches opencode. + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, '\x1b[?1003h\x1b[?1006h'); + + expect(session.toState().cliMouseTracking).toBe(true); + }); + + it('reassembles a mouse DECSET split across PTY chunks', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: true }); + + handleOutput(session, 'before\x1b[?100'); + handleOutput(session, '3h after'); + + expect(session.terminalBuffer).toBe('before after'); + expect(session.toState().cliMouseTracking).toBe(true); + }); + + it('leaves a direct-PTY opencode pane untouched', () => { + const session = new Session({ workingDir: '/tmp', mode: 'opencode', useMux: false }); + const out = '\x1b[?1049h\x1b[?1003h'; + + handleOutput(session, out); + + expect(session.terminalBuffer).toBe(out); + }); +}); + describe('tmux-backed shell: strip tmux’s own client smcup, keep everything else (#205)', () => { it('drops alt-screen toggles so xterm keeps a scrollback buffer', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); @@ -127,6 +207,17 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el expect(session.terminalBuffer).toBe('\x1b[3J\x1b[?1002h\x1b[?1006hhtop\x1b[?1006l\x1b[?1002l'); }); + it('never publishes cliMouseTracking for a mode whose DECSETs it keeps', () => { + // The browser's `_shouldReportMouseToCli()` reads only this flag, with no mode + // list: a flag set for a non-stripping mode would make it hand-encode a second + // report on top of xterm's own. Only the mouse-strip branch may set it. + for (const mode of ['shell', 'antigravity'] as const) { + const session = new Session({ workingDir: '/tmp', mode, useMux: true }); + handleOutput(session, '\x1b[?1002h\x1b[?1006hmouse app'); + expect(session.toState().cliMouseTracking, mode).toBeFalsy(); + } + }); + it('reassembles alt-screen sequences split across PTY chunk boundaries', () => { const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true }); const emitted: string[] = []; @@ -139,12 +230,10 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el expect(emitted).toEqual(['before', ' after']); }); - it('applies to opencode and antigravity too', () => { - for (const mode of ['opencode', 'antigravity'] as const) { - const session = new Session({ workingDir: '/tmp', mode, useMux: true }); - handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); - expect(session.terminalBuffer).toBe('TUI\x1b[3J'); - } + it('applies to antigravity too (opencode has its own strip — see below)', () => { + const session = new Session({ workingDir: '/tmp', mode: 'antigravity', useMux: true }); + handleOutput(session, '\x1b[?1049hTUI\x1b[3J'); + expect(session.terminalBuffer).toBe('TUI\x1b[3J'); }); }); @@ -238,3 +327,42 @@ describe('stripped mouse-tracking state', () => { expect(session.terminalBuffer).toBe('\x1b[?1002hhtop'); }); }); + +/** + * The decision table in `CliCapabilities.altScreen`'s JSDoc, pinned for every stock CLI + * with and without tmux, on both halves of the parity triangle that can drift apart: the + * live stream (`_handleTerminalOutput`) and the replay of a stored buffer + * (`stripReplayBuffer`, session-routes.ts). The frontend half reads only the published + * `cliMouseTracking`, so the last column is what keeps it right. + */ +describe('strip decision table: live stream = replay, for every stock CLI', () => { + const ALT = '\x1b[?1049h'; + const ERASE = '\x1b[3J'; + const MOUSE = '\x1b[?1002h\x1b[?1006h'; + const input = `A${ALT}B${ERASE}C${MOUSE}D`; + + /** Read straight off the table, not off the predicates under test. */ + function expected(altScreen: string, useMux: boolean): { out: string; tracking: boolean } { + const strip = { alt: false, erase: false, mouse: false }; + if (altScreen === 'strip-full') Object.assign(strip, { alt: true, erase: true, mouse: true }); + else if (useMux && altScreen === 'strip-mux-and-mouse') Object.assign(strip, { alt: true, mouse: true }); + else if (useMux) strip.alt = true; // strip-mux-only and preserve: the same runtime row + return { + out: `A${strip.alt ? '' : ALT}B${strip.erase ? '' : ERASE}C${strip.mouse ? '' : MOUSE}D`, + tracking: strip.mouse, + }; + } + + for (const entry of STOCK_CLIS) { + for (const useMux of [true, false]) { + it(`${entry.id} (${entry.capabilities.altScreen}, ${useMux ? 'tmux' : 'direct PTY'})`, () => { + const want = expected(entry.capabilities.altScreen, useMux); + const session = new Session({ workingDir: '/tmp', mode: entry.id, useMux }); + handleOutput(session, input); + expect(session.terminalBuffer).toBe(want.out); + expect(stripReplayBuffer(input, entry.id, useMux)).toBe(want.out); + expect(Boolean(session.toState().cliMouseTracking)).toBe(want.tracking); + }); + } + } +}); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index f35106b4..6974c39e 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -366,14 +366,33 @@ describe('terminal touch tap mouse guard', () => { expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']); }); - it('never hand-reports for a shell session, even with tracking somehow set', () => { - // Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns - // the mouse there and a second, hand-encoded report would double-report. + it('follows the server flag alone, with no mode list of its own', () => { + // A shell's DECSETs are not stripped, so xterm's own encoder owns the mouse there + // and a hand-encoded report would double-report. That is kept by the SERVER never + // setting the flag for a non-stripping mode (pinned in claude-scrollback-strip.test.ts), + // not by a mode check here: the browser reads only the flag. const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; - app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]); - + app.sessions = new Map([['sess-1', { mode: 'shell' }]]); expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'some-future-cli', cliMouseTracking: true }]]); + expect(app._shouldReportMouseToCli()).toBe(true); + }); + + it('hand-reports for opencode, whose DECSETs the server now strips', () => { + // opencode's TUI enables mouse tracking, tmux passes the DECSETs through, and + // xterm used to report DRAGS to the TUI instead of selecting — so marking text + // copied nothing. The server strips them now (isMuxMouseStripMode), which makes + // the hand-encoded tap the only way a click still reaches opencode. + const { app } = loadTerminalUiHarness(); + app.activeSessionId = 'sess-1'; + + app.sessions = new Map([['sess-1', { mode: 'opencode' }]]); + expect(app._shouldReportMouseToCli()).toBe(false); + + app.sessions = new Map([['sess-1', { mode: 'opencode', cliMouseTracking: true }]]); + expect(app._shouldReportMouseToCli()).toBe(true); }); it('hand-reports only while the CLI actually has mouse tracking on', () => { From 03629c966e719f37251514894c4a8ba2b2d96d34 Mon Sep 17 00:00:00 2001 From: Randalix <j.heintz.90@gmail.com> Date: Thu, 8 Oct 2026 14:56:41 +0200 Subject: [PATCH 26/42] fix(server): report the port actually bound, so `new WebServer(0)` is usable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `port: 0` already bound an ephemeral port at the socket level, but `this.port` stayed 0: the banner printed `:0`, CODEMAN_API_URL pointed panes at `:0`, the docker bridge listener and the unauthenticated-bind warnings read 0, and the route context's `port` was a by-value snapshot taken in setupRoutes(), before listen() runs, so `tunnelManager.start(ctx.port, …)` would have been handed 0. - After `app.listen()`, `this.port` takes the number from `this.app.server.address()` (string/null addresses are left alone). - The route context exposes `port` as a getter, and the cron routes get only the `cron` their CronPort declares instead of a spread copy of the context. - `get boundPort()`: the readonly accessor tests use instead of a private field. test/webserver-bound-port.test.ts compares each reader against the socket's own `address().port`; all three tests fail with only the write-back removed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/web/server.ts | 24 ++++++++++++++-- test/webserver-bound-port.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 test/webserver-bound-port.test.ts diff --git a/src/web/server.ts b/src/web/server.ts index 4442e544..2ca28689 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -323,6 +323,14 @@ export class WebServer extends EventEmitter { private store = getStore(); private tabLayouts!: TabLayoutService; private port: number; + + /** + * The port the server is actually listening on once `start()` has resolved — the + * OS-assigned one for `new WebServer(0, …)` — and the constructor's port before that. + */ + get boundPort(): number { + return this.port; + } private host: string; private https: boolean; /** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */ @@ -746,7 +754,11 @@ export class WebServer extends EventEmitter { saveRespawnConfig: this.saveRespawnConfig.bind(this), // ConfigPort store: this.store, - port: this.port, + // A getter, not a snapshot: this context is built in setupRoutes(), BEFORE + // listen() resolves an ephemeral `port: 0`, and the tunnel start reads it later. + get port() { + return self.port; + }, https: this.https, testMode: this.testMode, serverStartTime: this.serverStartTime, @@ -1154,7 +1166,9 @@ export class WebServer extends EventEmitter { // due times for any persisted jobs, then expose it to its routes. this.cronService = new CronService(ctx); this.cronService.init(); - registerCronRoutes(this.app, { ...ctx, cron: this.cronService }); + // Only what CronPort declares: a spread of ctx would copy `port` by value (the + // pre-listen 0 of an ephemeral bind) into an object nothing keeps in sync. + registerCronRoutes(this.app, { cron: this.cronService }); registerWsRoutes(this.app, ctx, () => this.getHostPolicy()); registerVoiceRoutes(this.app, ctx, () => this.getHostPolicy()); @@ -2909,6 +2923,12 @@ export class WebServer extends EventEmitter { } await this.app.listen({ port: this.port, host: this.host }); + // A `port: 0` bind gets its number from the OS. Everything below and every later + // reader (the banner, CODEMAN_API_URL, the docker bridge listener, the + // unauthenticated-bind warnings, the route context's getter) must see that number, + // not the 0 that was asked for. + const address = this.app.server.address(); + if (address !== null && typeof address === 'object') this.port = address.port; const protocol = this.https ? 'https' : 'http'; const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host; // The only startup banner: `codeman web` used to print its own copy of this diff --git a/test/webserver-bound-port.test.ts b/test/webserver-bound-port.test.ts new file mode 100644 index 00000000..e81612b9 --- /dev/null +++ b/test/webserver-bound-port.test.ts @@ -0,0 +1,46 @@ +/** + * @fileoverview `new WebServer(0, …)`: the OS picks the port and every reader of + * `this.port` sees that number, not the 0 that was asked for (#440). + * + * Port: ephemeral. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { AddressInfo } from 'node:net'; +import { WebServer } from '../src/web/server.js'; + +describe('WebServer on an ephemeral port', () => { + let server: WebServer; + /** Built BEFORE start(), as setupRoutes() builds it, so a by-value snapshot would read 0. */ + let ctx: { port: number }; + const savedApiUrl = process.env.CODEMAN_API_URL; + /** The socket's own answer, so each test stands on its own instead of trusting boundPort. */ + const socketPort = () => + ((server as unknown as { app: { server: { address(): AddressInfo } } }).app.server.address() as AddressInfo).port; + + beforeAll(async () => { + server = new WebServer(0, false, true); + ctx = (server as unknown as { createRouteContext(): { port: number } }).createRouteContext(); + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + if (savedApiUrl === undefined) delete process.env.CODEMAN_API_URL; + else process.env.CODEMAN_API_URL = savedApiUrl; + }); + + it('reports the port the OS assigned', async () => { + expect(socketPort()).toBeGreaterThan(0); + expect(server.boundPort).toBe(socketPort()); + const res = await fetch(`http://127.0.0.1:${server.boundPort}/api/status`); + expect(res.status).toBe(200); + }); + + it('the route context reads it live (the tunnel start gets the real port, not 0)', () => { + expect(ctx.port).toBe(socketPort()); + }); + + it('exports the real port to the panes as CODEMAN_API_URL', () => { + expect(process.env.CODEMAN_API_URL).toBe(`http://127.0.0.1:${socketPort()}`); + }); +}); From bb4e7943c501a76bddccb7e501198bf4ccd7c1db Mon Sep 17 00:00:00 2001 From: Randalix <j.heintz.90@gmail.com> Date: Thu, 8 Oct 2026 14:56:42 +0200 Subject: [PATCH 27/42] test: bind the port-sharing test servers to ephemeral ports; guard new fixed ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four ports were shared by two files each — 3162 (qr-auth / auth-security), 3170 (multiuser-auth / routes/ws-routes), 3230 and 3231 (cod54-hook-event-auth / routes/voice-routes). Files run serially (`fileParallelism: false`), so the pairs never met inside one run; they collide between two runs on one host, or with anything else holding the port. All six files now bind port 0 and read the number back (`boundPort` for WebServer, `server.address()` after each listen for the raw Fastify / ws servers). test/test-ports-guard.test.ts fails on a WebServer built under test/ whose port argument is not the literal 0 — `new WebServer(…)`, a subclass, or a destructured alias (`{ WebServer: T }`, as quick-start.test.ts does) — outside a legacy list of the 43 files that construct one with a non-zero port today; the follow-up sweep converts them. A converted file cannot stay listed. What it does not cover (helper parameters, `import { WebServer as X }`, raw listen sites) is written down in it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/auth-security.test.ts | 31 +++---- test/cod54-hook-event-auth.test.ts | 17 ++-- test/multiuser-auth.test.ts | 15 ++- test/qr-auth.test.ts | 12 +-- test/routes/voice-routes.test.ts | 14 ++- test/routes/ws-routes.test.ts | 9 +- test/test-ports-guard.test.ts | 141 +++++++++++++++++++++++++++++ 7 files changed, 187 insertions(+), 52 deletions(-) create mode 100644 test/test-ports-guard.test.ts diff --git a/test/auth-security.test.ts b/test/auth-security.test.ts index e7a52c25..2f6e633d 100644 --- a/test/auth-security.test.ts +++ b/test/auth-security.test.ts @@ -8,7 +8,7 @@ * 6. Logout endpoint invalidates session * 7. Settings schema rejects unknown fields * - * Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests) + * Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`) */ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { WebServer } from '../src/web/server.js'; @@ -16,11 +16,6 @@ import { TmuxManager } from '../src/tmux-manager.js'; import { SettingsUpdateSchema } from '../src/web/schemas.js'; import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js'; -const AUTH_PORT = 3160; -const NOAUTH_PORT = 3161; -const NETWORK_OVERRIDE_PORT = 3162; -const AUTH_RATE_LIMIT_PORT = 3220; -const NOAUTH_NETWORK_PORT = 3221; const TEST_USER = 'admin'; const TEST_PASS = 'test-password-12345'; @@ -30,12 +25,12 @@ function basicAuthHeader(user: string, pass: string): string { return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); } -async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> { +async function startAuthServer(): Promise<{ server: WebServer; baseUrl: string }> { process.env.CODEMAN_PASSWORD = TEST_PASS; process.env.CODEMAN_USERNAME = TEST_USER; - const server = new WebServer(port, false, true); + const server = new WebServer(0, false, true); await server.start(); - return { server, baseUrl: `http://localhost:${port}` }; + return { server, baseUrl: `http://localhost:${server.boundPort}` }; } async function getSessionCookie(baseUrl: string): Promise<string> { @@ -66,9 +61,9 @@ describe('Auth Security', () => { beforeAll(async () => { process.env.CODEMAN_PASSWORD = TEST_PASS; process.env.CODEMAN_USERNAME = TEST_USER; - server = new WebServer(AUTH_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${AUTH_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -194,7 +189,7 @@ describe('Auth Security', () => { let rateBaseUrl: string; beforeEach(async () => { - ({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT)); + ({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer()); }); afterEach(async () => { @@ -347,7 +342,7 @@ describe('No-Auth Server Startup Policy', () => { delete process.env.CODEMAN_PASSWORD; delete process.env.CODEMAN_USERNAME; delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK; - server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1'); + server = new WebServer(0, false, true, '127.0.0.1'); await server.start(); }); @@ -359,7 +354,7 @@ describe('No-Auth Server Startup Policy', () => { }); it('allows loopback requests without auth when no password is configured', async () => { - const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`); + const res = await fetch(`http://localhost:${server.boundPort}/api/status`); expect(res.status).toBe(200); }); @@ -368,10 +363,10 @@ describe('No-Auth Server Startup Policy', () => { // bind without a password no longer refuses to start — it starts and warns, // pointing at how to secure it. See docs/security-architecture.md. const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0'); + const networkServer = new WebServer(0, false, true, '0.0.0.0'); await expect(networkServer.start()).resolves.toBeUndefined(); - const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`); + const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`); expect(res.status).toBe(200); const warned = warnSpy.mock.calls.flat().join('\n'); @@ -393,10 +388,10 @@ describe('No-Auth Server Startup Policy', () => { }); it('allows non-loopback startup with the explicit unauthenticated-network override', async () => { - const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true); + const networkServer = new WebServer(0, false, true, '0.0.0.0', undefined, true); await networkServer.start(); - const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`); + const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`); expect(res.status).toBe(200); await networkServer.stop(); }); diff --git a/test/cod54-hook-event-auth.test.ts b/test/cod54-hook-event-auth.test.ts index 10144207..bba51ee1 100644 --- a/test/cod54-hook-event-auth.test.ts +++ b/test/cod54-hook-event-auth.test.ts @@ -19,7 +19,7 @@ * - tunnel NOT running + good secret → not 401 (allowed) * - rate limiting: rapid unauthorized hook POSTs eventually 429 * - * Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit) + * Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`) */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { WebServer } from '../src/web/server.js'; @@ -28,9 +28,6 @@ import { TunnelManager } from '../src/tunnel-manager.js'; import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js'; import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js'; -const TUNNEL_UP_PORT = 3230; -const TUNNEL_DOWN_PORT = 3231; -const RATE_LIMIT_PORT = 3232; const TEST_USER = 'admin'; const TEST_PASS = 'cod54-test-password'; @@ -58,9 +55,9 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => { process.env.CODEMAN_USERNAME = TEST_USER; // Force the middleware's tunnel check to report "running". isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true); - server = new WebServer(TUNNEL_UP_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TUNNEL_UP_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -97,9 +94,9 @@ describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () = process.env.CODEMAN_USERNAME = TEST_USER; // Tunnel NOT running — loopback-only normal prod case. isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false); - server = new WebServer(TUNNEL_DOWN_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -130,9 +127,9 @@ describe('COD-54 hook-event auth — rate limiting', () => { process.env.CODEMAN_USERNAME = TEST_USER; // Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted. isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true); - server = new WebServer(RATE_LIMIT_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${RATE_LIMIT_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/multiuser-auth.test.ts b/test/multiuser-auth.test.ts index 25128914..fd9c7204 100644 --- a/test/multiuser-auth.test.ts +++ b/test/multiuser-auth.test.ts @@ -1,12 +1,12 @@ /** - * @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+). + * @fileoverview Phase 2 multi-user auth integration tests (live server, ephemeral port). * * Verifies the multi-user auth branch end to end: per-user Basic verify, cookie * identity, wrong-password / disabled-user rejection, the mustChangePassword * lockbox + self-service change, per-account rate limiting, and QR identity binding * (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts. * - * Ports: 3170 (multi-user server), 3171 (rate-limit server). + * Ports: ephemeral (`new WebServer(0, …)`, read back through `boundPort`). */ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -21,9 +21,6 @@ import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js'; vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); -const PORT = 3170; -const RATE_PORT = 3171; - function basic(user: string, pass: string): string { return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'); } @@ -68,7 +65,7 @@ beforeAll(async () => { const { updateUser } = await import('../src/user-store.js'); await updateUser('carol', { disabled: true }); - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); }); @@ -84,7 +81,7 @@ afterAll(async () => { await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {}); }); -const url = (p: string) => `http://localhost:${PORT}${p}`; +const url = (p: string) => `http://localhost:${server.boundPort}${p}`; describe('multi-user auth', () => { it('rejects unauthenticated requests', async () => { @@ -161,9 +158,9 @@ describe('multi-user auth', () => { }); it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => { - rateServer = new WebServer(RATE_PORT, false, true); + rateServer = new WebServer(0, false, true); await rateServer.start(); - const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`; + const rurl = (p: string) => `http://localhost:${rateServer.boundPort}${p}`; // Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet. for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) { diff --git a/test/qr-auth.test.ts b/test/qr-auth.test.ts index a8bb9150..7cd67677 100644 --- a/test/qr-auth.test.ts +++ b/test/qr-auth.test.ts @@ -14,14 +14,12 @@ * 12. QR auth bypass in auth middleware * 13. GET /api/tunnel/qr SVG endpoint (auth/no-auth, caching, errors) * - * Port: 3162 (qr-auth tests), 3163 (qr-svg endpoint tests) + * Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`) */ import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { TunnelManager } from '../src/tunnel-manager.js'; import { WebServer } from '../src/web/server.js'; -const QR_AUTH_PORT = 3162; -const QR_SVG_PORT = 3163; const TEST_PASS = 'qr-test-pass-xyz'; const TEST_USER = 'admin'; @@ -312,9 +310,9 @@ describe('QR Auth Integration', () => { beforeAll(async () => { process.env.CODEMAN_PASSWORD = TEST_PASS; process.env.CODEMAN_USERNAME = TEST_USER; - server = new WebServer(QR_AUTH_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${QR_AUTH_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { @@ -608,9 +606,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => { beforeAll(async () => { process.env.CODEMAN_PASSWORD = TEST_PASS; process.env.CODEMAN_USERNAME = TEST_USER; - server = new WebServer(QR_SVG_PORT, false, true); + server = new WebServer(0, false, true); await server.start(); - baseUrl = `http://localhost:${QR_SVG_PORT}`; + baseUrl = `http://localhost:${server.boundPort}`; }); afterAll(async () => { diff --git a/test/routes/voice-routes.test.ts b/test/routes/voice-routes.test.ts index d1cd8886..4cef6a13 100644 --- a/test/routes/voice-routes.test.ts +++ b/test/routes/voice-routes.test.ts @@ -11,7 +11,7 @@ * - the socket refuses exactly what the status endpoint calls unavailable, * - a cross-site upgrade cannot open a stream on the operator's subscription. * - * Port: 3230 (routes), 3231 (mock upstream) + * Port: ephemeral (`port: 0` for the routes and the mock upstream) */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; @@ -20,6 +20,7 @@ import fastifyWebsocket from '@fastify/websocket'; import WebSocket, { WebSocketServer } from 'ws'; import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; +import type { AddressInfo } from 'node:net'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js'; import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js'; @@ -47,8 +48,9 @@ function removeCredentials(): void { rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true }); } -const PORT = 3230; -const UPSTREAM_PORT = 3231; +/** Both assigned by the OS on every listen (`port: 0`); see beforeEach. */ +let PORT = 0; +let UPSTREAM_PORT = 0; const TOKEN = 'sk-ant-oat01-voice-route-test'; /** State captured by the mock upstream, so tests can assert what Codeman sent. */ @@ -118,7 +120,7 @@ describe('voice-routes', () => { voiceEnabled = true; capture = { headers: {}, url: '', binaryFrames: [], textFrames: [], socket: null }; - upstream = new WebSocketServer({ port: UPSTREAM_PORT, host: '127.0.0.1' }); + upstream = new WebSocketServer({ port: 0, host: '127.0.0.1' }); upstream.on('connection', (socket, req) => { capture.headers = req.headers; capture.url = req.url ?? ''; @@ -129,6 +131,7 @@ describe('voice-routes', () => { }); }); await new Promise<void>((resolve) => upstream.once('listening', resolve)); + UPSTREAM_PORT = (upstream.address() as AddressInfo).port; process.env.CODEMAN_VOICE_STREAM_BASE = `ws://127.0.0.1:${UPSTREAM_PORT}`; writeCredentials(Date.now() + 3_600_000); @@ -138,7 +141,8 @@ describe('voice-routes', () => { ctx = createMockRouteContext(); ctx.getClaudeVoiceEnabled = (async () => voiceEnabled) as typeof ctx.getClaudeVoiceEnabled; registerVoiceRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null })); - await app.listen({ port: PORT, host: '127.0.0.1' }); + await app.listen({ port: 0, host: '127.0.0.1' }); + PORT = (app.server.address() as AddressInfo).port; }); afterEach(async () => { diff --git a/test/routes/ws-routes.test.ts b/test/routes/ws-routes.test.ts index 2ca5110d..6c478cd9 100644 --- a/test/routes/ws-routes.test.ts +++ b/test/routes/ws-routes.test.ts @@ -7,10 +7,11 @@ * * @dependency test/mocks/mock-route-context.ts (createMockRouteContext) * @dependency src/web/routes/ws-routes.ts (registerWsRoutes) - * Port: 3170 (ws-routes tests) + * Port: ephemeral (`listen({ port: 0 })`) */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { AddressInfo } from 'node:net'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyWebsocket from '@fastify/websocket'; import WebSocket from 'ws'; @@ -18,7 +19,8 @@ import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js import { registerWsRoutes } from '../../src/web/routes/ws-routes.js'; import { MAX_INPUT_LENGTH } from '../../src/config/terminal-limits.js'; -const PORT = 3170; +/** Assigned by the OS on every listen (`port: 0`); see beforeEach. */ +let PORT = 0; /** Helper: open a WebSocket connection and wait for it to reach OPEN state. */ function connectWs(path: string, timeoutMs = 5000): Promise<WebSocket> { @@ -86,7 +88,8 @@ describe('ws-routes', () => { ctx = createMockRouteContext({ sessionId: 'ws-test-session' }); registerWsRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null })); - await app.listen({ port: PORT, host: '127.0.0.1' }); + await app.listen({ port: 0, host: '127.0.0.1' }); + PORT = (app.server.address() as AddressInfo).port; }); afterEach(async () => { diff --git a/test/test-ports-guard.test.ts b/test/test-ports-guard.test.ts new file mode 100644 index 00000000..20c7d8b8 --- /dev/null +++ b/test/test-ports-guard.test.ts @@ -0,0 +1,141 @@ +/** + * @fileoverview Static guard: a test builds `WebServer` on an ephemeral port. + * + * A fixed port is a red suite on any machine where something else holds it, and a + * collision between two runs on one host (two worktrees, or CI plus a local run): the + * suite runs files serially (`fileParallelism: false`), so the four port pairs #440 + * found never met inside one run, only across runs. `new WebServer(0, …)` binds + * whatever the OS hands out and `boundPort` reads it back, so there is nothing left + * to collide on. + * + * A WebServer built under test/ whose port argument is not the literal `0` fails — + * `new WebServer(…)`, a class declared `extends WebServer`, or a destructured alias + * (`{ WebServer: T }`) — unless the file is in LEGACY_FIXED_PORT_FILES, the files that + * construct one with a non-zero port today (a few never call `start()`); the follow-up + * sweep converts them. A converted file cannot stay listed: an entry whose file no + * longer matches fails too. + * + * Not covered: a helper that takes the port as a parameter is checked at the helper, + * not at its callers (test/mobile/helpers/server.ts is listed, so the mobile tests + * calling `createTestServer(PORT)` are not checked), `import { WebServer as X }`, and + * `new mod.WebServer(…)`. Raw `listen({ port: N })` and `new WebSocketServer({ port: N })` + * belong to the sweep. + * + * Port: N/A (pure static analysis). + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const TEST_ROOT = fileURLToPath(new URL('.', import.meta.url)); + +/** Predates the guard; converted in the follow-up sweep. Shrink only. */ +const LEGACY_FIXED_PORT_FILES = new Set( + [ + 'admin-routes.test.ts', + 'base-path-server.test.ts', + 'capture-geometry-retry.browser.test.ts', + 'capture-load-window.browser.test.ts', + 'case-custom-path.browser.test.ts', + 'doctor-settings.browser.test.ts', + 'edge-cases.test.ts', + 'file-link-click.test.ts', + 'git-status.browser.test.ts', + 'hooks-config.test.ts', + 'http-contract.test.ts', + 'inline-rename.test.ts', + 'integration-flows.test.ts', + 'key-tester.browser.test.ts', + 'mobile/helpers/server.ts', + 'opencode-resize.test.ts', + 'operation-lightspeed.test.ts', + 'ownership-scoping.test.ts', + 'pane-exit-sweep.test.ts', + 'paste-image-dir-shared.test.ts', + 'perf-browser.test.ts', + 'quick-start.test.ts', + 'ralph-integration.test.ts', + 'scheduled-runs.test.ts', + 'security-regression.test.ts', + 'session-cleanup.test.ts', + 'session-pane-exit.test.ts', + 'session.test.ts', + 'shift-enter-keypress.browser.test.ts', + 'split-pane-auto-collapse.browser.test.ts', + 'split-pane-orchestration.browser.test.ts', + 'split-pane-terminal.browser.test.ts', + 'sse-cors-headers.test.ts', + 'sse-events.test.ts', + 'sse-routing-remote.test.ts', + 'sse-subscription-filter.test.ts', + 'static-cache-headers.test.ts', + 'terminal-copy-shortcut.test.ts', + 'terminal-keycode229-recovery.browser.test.ts', + 'webgl-fallback.test.ts', + 'webhook-settings.browser.test.ts', + 'webview-lost-root-frame.test.ts', + 'webview-sse.test.ts', + ].map((p) => p.split('/').join(sep)) +); + +function testFiles(dir: string): string[] { + const out: string[] = []; + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (statSync(full).isDirectory()) { + if (name !== 'node_modules') out.push(...testFiles(full)); + } else if (name.endsWith('.ts')) { + out.push(full); + } + } + return out; +} + +/** + * First argument of every `new WebServer(` in `source`, trimmed (may span lines) — and of + * every `new X(` where the same file makes X a WebServer: `class X extends WebServer`, or + * a destructured alias `{ WebServer: X }` (how `quick-start.test.ts` builds its server). + */ +function webServerPortArgs(source: string): string[] { + const classes = [ + 'WebServer', + ...[...source.matchAll(/class\s+(\w+)\s+extends\s+WebServer\b/g)].map((m) => m[1]), + ...[...source.matchAll(/\bWebServer\s*:\s*(\w+)/g)].map((m) => m[1]), + ]; + return classes.flatMap((name) => + [...source.matchAll(new RegExp(`new ${name}\\(\\s*([^,)]*)`, 'g'))].map((m) => m[1].trim()) + ); +} + +const SELF = fileURLToPath(import.meta.url); +const scanned = testFiles(TEST_ROOT) + .filter((f) => f !== SELF) + .map((file) => ({ rel: relative(TEST_ROOT, file), args: webServerPortArgs(readFileSync(file, 'utf8')) })); +const fixed = (args: string[]) => args.some((a) => a !== '0'); + +describe('test servers bind an ephemeral port', () => { + it('reads the first argument the way a reader would', () => { + expect(webServerPortArgs('new WebServer(0, false, true)')).toEqual(['0']); + expect(webServerPortArgs('new WebServer(\n PORT,\n false)')).toEqual(['PORT']); + expect(webServerPortArgs('new WebServer(3162, false)')).toEqual(['3162']); + expect(webServerPortArgs('new WebServer()')).toEqual(['']); + expect(webServerPortArgs('class T extends WebServer {}\nconst s = new T(3299, false);')).toEqual(['3299']); + expect(webServerPortArgs('const { WebServer: T } = mod;\nreturn new T(port, false);')).toEqual(['port']); + }); + + it('no test outside the legacy list builds WebServer on a fixed port', () => { + const offenders = scanned + .filter((f) => fixed(f.args) && !LEGACY_FIXED_PORT_FILES.has(f.rel)) + .map( + (f) => + `${f.rel}: new WebServer(${f.args.find((a) => a !== '0')}, …) — use new WebServer(0, …) and server.boundPort` + ); + expect(offenders).toEqual([]); + }); + + it('the legacy list only names files that still need converting', () => { + const stale = [...LEGACY_FIXED_PORT_FILES].filter((rel) => !scanned.some((f) => f.rel === rel && fixed(f.args))); + expect(stale).toEqual([]); + }); +}); From 354c4641a9608847c053ed5b209f6e0a8804602b Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Thu, 8 Oct 2026 10:05:57 -0400 Subject: [PATCH 28/42] fix(session-options): keep the external flag when App Settings resyncs the CLI catalog _syncCliLaunchCatalog() rebuilt window.__codemanCliCatalog from /api/clis rows, which carry no capabilities, so after App Settings loaded the CLI list isExternalCliSession('claude') fell back to the kind check and Respawn and Ralph disappeared again until a reload. The rebuild now carries external over from the served catalog; a newly created custom CLI has no previous entry and falls back to kind, which is right since custom entries are external. Tests pin the resync and the openSessionOptions() call site, and the /api/clis comment names the page catalog as the deliberate capabilities exception. --- src/web/public/settings-ui.js | 6 ++++++ src/web/routes/cli-registry-routes.ts | 4 +++- test/run-mode-ui.test.ts | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index e8fed034..f967a3c8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -3160,12 +3160,18 @@ Object.assign(CodemanApp.prototype, { /** Keep the launch surfaces in sync with Settings mutations without a reload. */ _syncCliLaunchCatalog() { if (!Array.isArray(this._cliList) || this._cliList.length === 0) return; + // /api/clis rows carry no capabilities, so keep the served catalog's `external` + // (isExternalCliSession() reads it). A new custom CLI has none and falls back to `kind`. + const previous = new Map( + (Array.isArray(window.__codemanCliCatalog) ? window.__codemanCliCatalog : []).map((cli) => [cli.id, cli]) + ); window.__codemanCliCatalog = this._cliList.map((cli) => ({ id: cli.id, label: cli.label, shortBadge: cli.shortBadge, order: cli.order, kind: cli.kind, + external: previous.get(cli.id)?.external, enabled: cli.enabled, available: cli.kind === 'shell' || (cli.enabled && cli.installed), })); diff --git a/src/web/routes/cli-registry-routes.ts b/src/web/routes/cli-registry-routes.ts index a4d4b136..3b75ffda 100644 --- a/src/web/routes/cli-registry-routes.ts +++ b/src/web/routes/cli-registry-routes.ts @@ -312,7 +312,9 @@ export function registerCliRegistryRoutes(app: FastifyInstance): void { // admin/settings surface; every SPAWN-time caller elsewhere uses // enabledClis() instead). Deliberately excludes launch/env/capabilities/ // overlays/discovery — the same rule every other catalogue-export surface in - // this codebase follows. + // this codebase follows, with one deliberate exception: the page catalog + // (`window.__codemanCliCatalog`, server.ts) carries `capabilities.external`, + // which Session Options reads to keep Claude's Respawn and Ralph tabs. // // NOT gated on cliManagementEnabled: reading the list is cheap and is not // the risky part. The Settings UI section simply never fetches this while diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index d237b129..b6f82047 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -556,6 +556,25 @@ describe('Codex quick start settings', () => { expect(context.isExternalCliSession('custom-agent')).toBe(true); }); + it('keeps the served external flag when App Settings resyncs the catalog from /api/clis', () => { + const catalog = CATALOG.map((cli) => ({ ...cli, external: cli.id !== 'claude' && cli.id !== 'shell' })); + const { app, context } = loadUi(undefined, catalog); + // /api/clis rows: no capabilities, so no `external`. + app._cliList = CATALOG.map((cli) => ({ ...cli, installed: true })); + app.runMode = 'claude'; + app._syncCliLaunchCatalog(); + expect(context.isExternalCliSession('claude')).toBe(false); + expect(context.isExternalCliSession('codex')).toBe(true); + }); + + it('gates Session Options on isExternalCliSession, not the launch-path check', () => { + const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + const open = src.slice(src.indexOf('\n openSessionOptions(')); + const body = open.slice(0, open.indexOf('\n },')); + expect(body).toContain('isExternalCliSession(session.mode)'); + expect(body).not.toContain('isExternalCliRunMode('); + }); + it('shows everything when the flags were never injected', () => { // A cached page from a build without the injection, or a solo popup. Hiding // every run button on a doubt would leave a working install nothing to click. From 17bc2f02e940ad687b7699a201a0b5542361e18b Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Thu, 8 Oct 2026 10:39:19 -0400 Subject: [PATCH 29/42] test(remote-wake): stop pinning the readiness budget to the millisecond ensureAwake() hands the readiness poll the caller's budget minus the wake step's own elapsed time, so on a busy runner the poll gets 39999 ms and the two tests that expected exactly REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS flaked (seen in CI on #550 and in a local gate run). Both now check the value sits within a second under the budget, the shape the host-scoped wake test already uses, which still fails if the 90 s session default leaks through. --- test/remote-wake.test.ts | 7 ++++++- test/routes/session-routes.test.ts | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/test/remote-wake.test.ts b/test/remote-wake.test.ts index f1fe7f06..22b96db6 100644 --- a/test/remote-wake.test.ts +++ b/test/remote-wake.test.ts @@ -559,9 +559,14 @@ describe('RemoteWakeRegistry', () => { h.registry.ensureAwake(h.session, { force: true, timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS }) ).resolves.toBe(true); expect(h.waitUntilReady).toHaveBeenCalledWith(remote, { - timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS, + timeoutMs: expect.any(Number), signal: expect.any(AbortSignal), }); + // Not asserted to the millisecond: the wake's own elapsed time is subtracted, so a + // slow runner lands a few ms under the budget. + const [, readyOpts] = h.waitUntilReady.mock.calls[0] as [unknown, { timeoutMs: number; signal: AbortSignal }]; + expect(readyOpts.timeoutMs).toBeLessThanOrEqual(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS); + expect(readyOpts.timeoutMs).toBeGreaterThan(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS - 1_000); }); }); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 11b1e755..466ab738 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -2182,10 +2182,15 @@ describe('session-routes', () => { // The request budget, not the 90 s session default: the reverse proxy would // cut the request at 60 s while the session was still being built. expect(wakeWaitUntilReady).toHaveBeenCalledWith(expect.objectContaining({ hostId: 'hufflepuff' }), { - timeoutMs: REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS, + timeoutMs: expect.any(Number), // The shutdown signal rides along so `WebServer.stop()` can end the poll. signal: expect.any(AbortSignal), }); + // Not asserted to the millisecond: the wake's own elapsed time is subtracted, + // so a slow runner lands a few ms under the budget. + const [, readyOpts] = wakeWaitUntilReady.mock.calls[0] as unknown as [unknown, { timeoutMs: number }]; + expect(readyOpts.timeoutMs).toBeLessThanOrEqual(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS); + expect(readyOpts.timeoutMs).toBeGreaterThan(REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS - 1_000); } finally { startShell.mockRestore(); } From c2340ae88a8dd624eb9e72f9dea80e79a8c49509 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 04:48:43 +0200 Subject: [PATCH 30/42] fix(tests): #556 landing fixes Move test/sse-tile-grid-filter.test.ts to an ephemeral port. The release added it with #561 on fixed port 3287, after #556 was cut, so it is not on the guard's LEGACY_FIXED_PORT_FILES and test/test-ports-guard.test.ts failed on the merged tree. It now builds new WebServer(0, ...) and its url() helper reads server.boundPort (only ever called inside tests, after beforeAll). Converting it is preferred over listing it, since the legacy list is shrink-only. Update the five docs that still told contributors to pick a unique fixed port, which the new guard now rejects for any WebServer test: CLAUDE.md (Adding Features and Testing), AGENTS.md, .github/CONTRIBUTING.md and the wiki's Contributing page (mirrored to the public GitHub wiki). They now say to bind port 0 and read boundPort (or address().port for a raw server), and note that the mobile suite keeps its fixed ports for now, because test/mobile/helpers/server.ts caches servers by port, so createTestServer(0) from two callers would share one server. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .github/CONTRIBUTING.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 4 ++-- docs/wiki/Contributing.md | 7 +++++-- test/sse-tile-grid-filter.test.ts | 7 +++---- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2ed1d8f1..fd80577a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -55,7 +55,7 @@ npm run test:all # literally everything, environmental failures included Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart. -If you add a test that binds a port, pick a unique one at 3150 or above (search the repo for `const PORT =` first). Never 3000. +If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000. Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions. diff --git a/AGENTS.md b/AGENTS.md index 49991d86..e0cf802b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,6 @@ Quick pointers: - Type check: `tsc --noEmit` · Lint: `npm run lint` · Format: `npm run format:check` - Tests: `npm test` (the CI gate, safe to run bare) or `npm test -- test/<file>.test.ts` for one file -- Route tests use `app.inject()`; new tests needing ports must pick a unique `const PORT =` +- Route tests use `app.inject()`; new tests needing a socket bind port 0 (`new WebServer(0, …)` + `boundPort`), except mobile tests, which keep `createTestServer(PORT)` for now - Branch off `master` for all work; Conventional Commit-style messages (`fix(mobile): ...`) - Never commit secrets or local state from `~/.codeman/` diff --git a/CLAUDE.md b/CLAUDE.md index c0e3dcdb..0c3114c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -436,7 +436,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` - **App setting**: decide per-device vs synced first. Per-device keys go in the `displayKeys` set in settings-ui.js and must NOT be added to `SettingsUpdateSchema` (it is `.strict()`). ⚠️ Anything in `PUT /api/settings` that acts on a setting (the `toggleService` watcher calls) must resolve from **`merged`** (persisted + incoming), never from the raw request body: a partial PUT omits keys it doesn't intend to change, and `body.x ?? default` turns every omission into "apply the default" and silently resets live services. Pinned by `test/routes/system-routes-settings-partial-put.test.ts`. - **Hook event**: Add to `HookEventType`, add hook in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` - **Mobile feature**: Add to relevant singleton, guard with `MobileDetection.isMobile()`. New header buttons must stay off phones (`test/mobile-header-buttons-policy.test.ts`). -- **New test**: Pick unique port (search `const PORT =`). Route tests use `app.inject()` (no port needed) — see `test/routes/_route-test-utils.ts`. +- **New test**: a test that constructs `WebServer` itself binds port 0 (`new WebServer(0, …)`, then `server.boundPort`; enforced by `test/test-ports-guard.test.ts`), and a raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` until the follow-up sweep, because that helper caches servers by port. Route tests use `app.inject()` (no port needed); see `test/routes/_route-test-utils.ts`. **Validation**: Zod v4 (different API from v3). Define schemas in `schemas.ts`, use `.parse()`/`.safeParse()`. @@ -475,7 +475,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test **Tmux safety**: under vitest (`VITEST`), `TmuxManager` no-ops ALL shell commands (`IS_TEST_MODE` in `src/tmux-manager.ts`), docker IO is no-op'd likewise, and `Session` spawns an echo PTY (`TEST_PTY_SCRIPT`) instead of attaching tmux. `test/setup.ts` gives each file a temp `HOME`/`USERPROFILE` and strips `CODEMAN_PASSWORD`/`CODEMAN_USERNAME`, `CODEMAN_GESTURE` and `CODEMAN_INSTANCE`/`CODEMAN_DATA_DIR`/`CODEMAN_TMUX_SOCKET` (pinned by `test/test-env-isolation.test.ts`). ⚠️ `CODEMAN_DATA_DIR` overrides the temp HOME, so never drop its strip; strip `CODEMAN_INSTANCE` in the setup file, never in a hook (captured at first import). ⚠️ Delete case trees only via `safeRmHomeTree()`. ⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and its isolation. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home) -**Ports**: Pick unique ports manually, 3150+. Search `const PORT =` before adding new tests. Never 3000 (the live instance). +**Ports**: a test that constructs `WebServer` binds port 0 and reads `server.boundPort`; `test/test-ports-guard.test.ts` fails any other port outside its shrink-only legacy list. A raw Fastify/`ws` server listens on `port: 0` and reads `address().port`. The mobile suite keeps fixed ports (3150+, per `test/mobile/README.md`) until the follow-up sweep, since `createTestServer()` caches servers by port. Never 3000 (the live instance). ⚠️ **Browser tests can pass vacuously on mobile input paths.** Two traps, both hit on 2026-07-27 while fixing the phone Enter button: **(1)** driving input with `app.sendInput('…')` writes PAST the `LocalEchoOverlay`, so `pendingText` stays empty and any overlay bug is invisible — type with `page.keyboard.type()` instead; **(2)** headless Chromium reports `MobileDetection.isTouchDevice()` **false even with `hasTouch: true`**, so `_localEchoEnabled` is off and the local-echo branch never executes. Force it (`app._localEchoEnabled = true`) or the test proves nothing. Assert on real state (`app._localEchoOverlay.pendingText`, plus `tmux -L codeman capture-pane -p -t <pane>` for what actually reached the PTY), not on HTTP 200. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index ae03f0ee..6872ca90 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -61,8 +61,11 @@ benchmarks for an otherwise idle machine). Expect those to fail where the machin provide what they need; that means "not runnable here", not a regression. Tests are tmux-safe by design: under vitest the tmux layer becomes an in-memory mock, so -tests cannot touch real sessions. If you add a test that binds a port, pick a unique one at -3150 or above, and never 3000. +tests cannot touch real sessions. If you add a test that binds a port, bind port 0 +(`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), +or use `app.inject()` when no socket is needed. Never 3000. Mobile tests (`test/mobile/**`, +via `createTestServer(PORT)`) keep the fixed ports in `test/mobile/README.md` for now, +because that helper caches servers by port. ## Finding your way around diff --git a/test/sse-tile-grid-filter.test.ts b/test/sse-tile-grid-filter.test.ts index b9ea7dac..aead1529 100644 --- a/test/sse-tile-grid-filter.test.ts +++ b/test/sse-tile-grid-filter.test.ts @@ -1,6 +1,6 @@ /** * @fileoverview The server's side of the tile grid's SSE filter (live server, - * multi-user mode, port 3287). + * multi-user mode, ephemeral port). * * While tiles own the terminal the page subscribes with TILE_GRID_SSE_FILTER * (constants.js), an id that names no session, so the server sends it no @@ -31,8 +31,7 @@ import { createUser, invalidateUsersCache } from '../src/user-store.js'; vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); -const PORT = 3287; -const url = (p: string) => `http://localhost:${PORT}${p}`; +const url = (p: string) => `http://localhost:${server.boundPort}${p}`; const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64'); const alice = { Authorization: basic('alice', 'alicepass1') }; @@ -124,7 +123,7 @@ beforeAll(async () => { await createUser({ username: 'root', role: 'admin', password: 'rootpass123' }); await createUser({ username: 'alice', role: 'user', password: 'alicepass1' }); await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' }); - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); }); From 7f26b4ba467e1fc4a8ff065d9e446cb9c393847c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 04:52:24 +0200 Subject: [PATCH 31/42] fix(screenshots): #551 landing fixes Pin the deprecation warning on every /api/screenshots route. The PR's test sends two GET /api/screenshots requests and checks that exactly one warning comes out, which proves the once-flag but not the individual calls: the POST and GET /:name warn calls could be deleted and it would stay green. A new it.each sends one request per route (GET list, a non-multipart POST that reaches the handler before the content-type check, and GET /:name for a missing file) on the fresh per-test harness, and each case asserts exactly one warning naming POST /api/sessions/:id/paste-image. Removing any single warn call now fails its own case (checked by deleting each call in turn). The deprecation's CHANGELOG note, required by docs/versioning-policy.md for a deprecated covered surface, rides the consolidated release changeset rather than a file here, since the PR added none. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/routes/system-routes.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 6087e7c4..0cd44d47 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -696,6 +696,31 @@ describe('system-routes', () => { warn.mockRestore(); } }); + + // One request per route on the fresh per-test harness, so each warn call is pinned on its own + // (the once-flag would let the first route's warning mask a missing call on the others). + it.each([ + { label: 'GET /api/screenshots', method: 'GET' as const, url: '/api/screenshots' }, + { + label: 'POST /api/screenshots', + method: 'POST' as const, + url: '/api/screenshots', + payload: { file: 'data' }, + headers: { 'content-type': 'application/json' }, + }, + { label: 'GET /api/screenshots/:name', method: 'GET' as const, url: '/api/screenshots/nonexistent.png' }, + ])('$label warns that it is deprecated', async ({ method, url, payload, headers }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + mockedExistsSync.mockReturnValue(false); + await harness.app.inject({ method, url, payload, headers }); + const deprecations = warn.mock.calls.filter((c) => String(c[0]).includes('/api/screenshots')); + expect(deprecations).toHaveLength(1); + expect(String(deprecations[0][0])).toContain('POST /api/sessions/:id/paste-image'); + } finally { + warn.mockRestore(); + } + }); }); // ========== GET /api/screenshots/:name ========== From 28df21f4bb1eb80b30f44a5f7c6b6ed65624188d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 04:56:29 +0200 Subject: [PATCH 32/42] fix(codex): #546 landing fixes App Settings now refuses a Default Codex model that the server would reject, before anything is written to localStorage. SettingsUpdateSchema is .strict() and checks codexModel with ^[a-zA-Z0-9._\-/]*$, so a value like gpt-oss:20b 400'd the whole settings PUT while the toast still said "Settings saved", and because the bad value was already in the local blob every later save from that device failed the same way. The client check uses the same pattern, shows an error toast, focuses the field and keeps the modal open. The toast has a zh-CN translation in i18n.js. src/web/codex-launch-defaults.ts gets an @fileoverview (fill only unset fields, re-validate persisted values, callers decide scope, never writes Codex config files), as every module in src carries one. Both new Codex rows in index.html carry has-field, like every other App Settings field row, so on phones the input and the select stack under their label instead of squeezing it into a narrow column. The Agent CLIs wiki paragraph said the defaults apply to every local launch. Scheduled (cron) codex jobs are built without a codexConfig and never get them, while Resume goes through POST /api/sessions and does, so the sentence now names the Run menu, Resume, POST /api/sessions and /api/quick-start, and says cron jobs do not use them. The Settings Reference lists the two new rows in the Agents & CLIs table. The neighbouring "Bypass approvals and sandbox" row described Pi's project trust; it is the Codex --dangerously-bypass-approvals-and-sandbox toggle, so its note says that now. The PR's own changeset is removed: the release writes one consolidated changeset at COM, and the PR's text overstated the scope (it included cron). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/codex-launch-defaults.md | 5 ----- docs/wiki/Agent-CLIs.md | 3 ++- docs/wiki/Settings-Reference.md | 4 +++- src/web/codex-launch-defaults.ts | 13 +++++++++++++ src/web/public/i18n.js | 2 ++ src/web/public/index.html | 4 ++-- src/web/public/settings-ui.js | 9 +++++++++ 7 files changed, 31 insertions(+), 9 deletions(-) delete mode 100644 .changeset/codex-launch-defaults.md diff --git a/.changeset/codex-launch-defaults.md b/.changeset/codex-launch-defaults.md deleted file mode 100644 index 66f61efd..00000000 --- a/.changeset/codex-launch-defaults.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': minor ---- - -Add synced Codex default model and reasoning effort controls to App Settings. Apply defaults to new local sessions (including WSL) while preserving explicit launch settings, custom endpoints, Docker and remote commands, and Codex configuration when defaults are empty. diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 09f0dee0..ea8bdeca 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -133,7 +133,8 @@ Integration detail: [`docs/opencode-integration.md`](https://github.com/Ark0N/Co App Settings has synced **Default Codex model** and **Default Codex reasoning effort** controls. Enter a model ID supported by your Codex provider; available reasoning levels depend on the model and CLI version. Empty defaults use Codex's own configuration. -The defaults apply when creating local sessions (including WSL), including HTTP API launches. +The defaults apply to local Codex sessions started from the Run menu, from Resume, and through +`POST /api/sessions` or `/api/quick-start`; scheduled (cron) jobs do not use them. Explicit `codexConfig.model` / `codexConfig.reasoningEffort` values take precedence. Custom model endpoints, Docker containers and remote host command overrides keep their own settings. Changing a default affects new sessions and does not edit Codex configuration files. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 4c899732..20b65e45 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -133,7 +133,9 @@ instead of its native cloud backend. See [Custom Model Endpoints](Custom-Model-E | Codeman Agent Skill | Injects the agent skill into new Claude sessions per case. Off by default. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). | | Remote auto-reconnect | Reattaches dropped remote SSH sessions. On by default. | | Nice priority / value | Runs agent processes at a lower CPU priority. | -| Bypass approvals and sandbox | Pi's project trust. Read [Agent CLIs](Agent-CLIs) before enabling. | +| Default Codex model | Model for new local Codex sessions; empty uses Codex's own config. Letters, digits, `.` `_` `-` `/` only. | +| Default Codex reasoning effort | Reasoning level for new local Codex sessions; empty uses Codex's own config. | +| Bypass approvals and sandbox | Starts new Codex sessions with `--dangerously-bypass-approvals-and-sandbox`. Read [Agent CLIs](Agent-CLIs) before enabling. | | Animated status effects | Cosmetic. | | MCP server sync | Copies the MCP servers each installed, enabled CLI (Claude, Codex, Gemini, OpenCode, Antigravity) has into the others' own config files. Synced, off by default, admin only in multi-user mode. Turn it on and save, then **Preview** shows what would change and **Sync now** applies it. It only adds missing servers, keeps the previous file as `.codeman-bak`, and leaves a file that receives env values or headers readable by you only. A config dir moved by `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME` or `GEMINI_CLI_HOME` in Codeman's own environment is followed. | diff --git a/src/web/codex-launch-defaults.ts b/src/web/codex-launch-defaults.ts index 24f25b40..43a059e9 100644 --- a/src/web/codex-launch-defaults.ts +++ b/src/web/codex-launch-defaults.ts @@ -1,3 +1,16 @@ +/** + * @fileoverview Launch-time defaults for Codex sessions. + * + * Resolves the synced App Settings `codexModel` / `codexReasoningEffort` into the + * `codexConfig` a launch uses, filling ONLY the fields the caller left unset. + * Persisted values are re-validated with `SettingsUpdateSchema`, so a hand-edited + * settings.json can never smuggle an unchecked value onto the codex command line. + * + * Scope is the caller's decision: the create and quick-start routes apply it to + * local launches only, never to remote, Docker or custom-endpoint launches. + * Nothing here writes Codex's own config files. + */ + import type { CodexConfig } from '../types.js'; import { SettingsUpdateSchema } from './schemas.js'; import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js'; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 1e0dd92a..9f791263 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -52,6 +52,8 @@ '新本地 Codex 会话(包括 WSL)使用的模型 ID。留空时使用 Codex 配置。', 'Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.': '应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。', + 'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"': + 'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"', 'Skip to terminal': '跳转到终端', 'Go to main page': '返回主页', 'Session tabs': '会话标签页', diff --git a/src/web/public/index.html b/src/web/public/index.html index c5ba68fb..f199eba2 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2586,14 +2586,14 @@ <div class="set-group" id="appSettingsCodexGroup"> <div class="set-group-head"><h4>Codex</h4><span class="set-scope">synced</span></div> <div class="set-group-body"> - <div class="set-row" data-search="codex default model"> + <div class="set-row has-field" data-search="codex default model"> <div class="set-row-text"> <span class="set-row-label">Default Codex model</span> <span class="set-row-desc">Model ID for new local Codex sessions, including WSL. Leave empty to use Codex configuration.</span> </div> <input id="appSettingsCodexModel" class="set-input" type="text" maxlength="100" aria-label="Default Codex model" placeholder="Use Codex configuration" autocomplete="off" spellcheck="false"> </div> - <div class="set-row" data-search="codex default reasoning effort thinking"> + <div class="set-row has-field" data-search="codex default reasoning effort thinking"> <div class="set-row-text"> <span class="set-row-label">Default Codex reasoning effort</span> <span class="set-row-desc">Applies to new local sessions; supported levels depend on the model and Codex version. Custom endpoints, Docker and remote sessions keep their own settings.</span> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index bafe75b4..0edbd4fd 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2607,6 +2607,15 @@ Object.assign(CodemanApp.prototype, { }, }; + // SettingsUpdateSchema is .strict() and checks codexModel with this same + // pattern, so one bad character 400s the WHOLE settings PUT while the toast + // still says "Settings saved". Refuse it here, before anything is persisted. + if (!/^[A-Za-z0-9._\/-]*$/.test(settings.codexModel)) { + this.showToast('Default Codex model may only contain letters, digits, ".", "_", "-" and "/"', 'error'); + document.getElementById('appSettingsCodexModel')?.focus(); + return; + } + // The "Token Count" / "Show Cost ($)" header toggles were removed from the // UI, but their features still read settings.showTokenCount / settings.showCost // (applyHeaderVisibilitySettings, the header cost render). saveAppSettings From ce6e4cc6e6f39de579323d0e78227f8677ec68fa Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 04:59:49 +0200 Subject: [PATCH 33/42] fix(ui): #542 landing fixes Follow the keyboard: the menu's cap now uses var(--app-height, 100dvh) instead of 100dvh. The viewport meta has no interactive-widget, so dvh does not shrink for an on-screen keyboard, while MobileDetection always sets --app-height to the visual viewport height and KeyboardHandler keeps it there while the keyboard is up (body and .app already size the same way). Subtract both safe areas from the cap: in the iPhone home-screen app the phone header grows by the top inset and the toolbar sits above the bottom inset, so without them the top of a full menu slid under the fixed header. Add overflow-x: hidden. overflow-y: auto computes overflow-x to auto, so a long nowrap custom-endpoint label showed a horizontal scrollbar that touch-action: pan-y cannot pan (the same trap the file documents for .run-mode-history). Raise the toolbar while the Run menu is open, by adding .toolbar:has(.run-mode-menu.active) to the existing popover raise rule. The menu is trapped in the toolbar's stacking context, so on a touch device the keyboard accessory bar (z 51) and the visible CJK input (z 52) covered its last rows even when scrolled to the end. This follows the rule the case settings popover and case combobox already use. Reword the rule's comment: it claimed dvh follows the keyboard and that the vh line is a fallback, and neither is true (a declaration carrying var() is never dropped at parse time). The new text has no braces and no max-height text, which the gate test's rule() slicer depends on. Pin the fixes in the gate test (the --app-height and safe-area terms, overflow-x: hidden, the toolbar raise) and retitle the cap test so it no longer names dvh as the mechanism. The test now strips CSS comments before reading the rule, since the rule's own comment names overflow-y: auto and touch-action: pan-y and would otherwise keep those assertions green after the declarations were deleted (checked by deleting them: the test now fails). Drop .changeset/run-menu-scroll.md: it repeated the false dvh claim, and the release writes one consolidated changeset at COM. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/run-menu-scroll.md | 5 ----- src/web/public/styles.css | 19 ++++++++++++++----- test/run-mode-menu-scroll.test.ts | 24 ++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 12 deletions(-) delete mode 100644 .changeset/run-menu-scroll.md diff --git a/.changeset/run-menu-scroll.md b/.changeset/run-menu-scroll.md deleted file mode 100644 index 693444f1..00000000 --- a/.changeset/run-menu-scroll.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"aicodeman": patch ---- - -The Run dropdown scrolls. It opens upward from the toolbar with no height limit, so with every CLI, the custom endpoint entries ("Claude Code (llama.cpp)" and so on), Terminal and the saved URLs it grew taller than the room above the toolbar: its top ran off-screen and the entries up there could not be reached, worst on a phone. It is now capped to the space between the header and the toolbar (`dvh`, with a `vh` fallback) and scrolls inside that, keeping the scroll from chaining to the page, and its sub-lists (history, saved URLs) are no longer squashed to nothing as the menu fills. diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 7398bdff..839b4aa1 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -5355,7 +5355,8 @@ html[data-skin="og"] .welcome-primary:hover { z-index inside the toolbar. When the popover is open, raise the toolbar above the CJK input (z-index 52) so the popover is interactable. */ .toolbar:has(.case-settings-popover:not(.hidden)), -.toolbar:has(.case-combobox-list:not(.hidden)) { +.toolbar:has(.case-combobox-list:not(.hidden)), +.toolbar:has(.run-mode-menu.active) { z-index: 100; } @@ -5651,11 +5652,19 @@ html[data-skin="og"] .welcome-primary:hover { endpoint entries, Terminal and the saved URLs it can be taller than the room above the toolbar: its top then sits off-screen with nothing to scroll, and the entries up there cannot be reached (worst on a phone). Cap it to the space between the header and the - toolbar and scroll inside it. `dvh` so an on-screen keyboard or a collapsing browser bar - shrinks the cap with it; the `vh` line is the fallback for browsers without it. */ - max-height: calc(100vh - var(--header-height) - var(--toolbar-height) - 16px); - max-height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 16px); + toolbar and scroll inside it. The cap follows --app-height, not `dvh`: the viewport meta + has no interactive-widget, so `dvh` does not shrink for an on-screen keyboard, while + MobileDetection always sets --app-height to the visual viewport height and KeyboardHandler + keeps it there while the keyboard is up. Both safe areas come off too, because the phone + header and toolbar grow by them in the iPhone home-screen app. The second line is the one + that applies (a declaration carrying var() is never dropped at parse time, so the first + line is not a working fallback for it; it stays only as the plain-viewport form). */ + max-height: calc(100vh - var(--header-height) - var(--safe-area-top) - var(--toolbar-height) - var(--safe-area-bottom) - 16px); + max-height: calc(var(--app-height, 100dvh) - var(--header-height) - var(--safe-area-top) - var(--toolbar-height) - var(--safe-area-bottom) - 16px); overflow-y: auto; + /* overflow-y: auto computes overflow-x to auto as well, so a long nowrap custom-endpoint + label would show a horizontal scrollbar that touch-action: pan-y cannot pan. */ + overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; touch-action: pan-y; diff --git a/test/run-mode-menu-scroll.test.ts b/test/run-mode-menu-scroll.test.ts index 94b807b5..0ee8243d 100644 --- a/test/run-mode-menu-scroll.test.ts +++ b/test/run-mode-menu-scroll.test.ts @@ -8,7 +8,12 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; -const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8'); +// Comments stripped: the rule's own comment names `overflow-y: auto` and `touch-action: pan-y`, +// which would otherwise satisfy the assertions below after the declarations were deleted. +const css = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf8').replace( + /\/\*[\s\S]*?\*\//g, + '' +); /** The declaration block of the FIRST rule whose selector is exactly `selector`. */ function rule(selector: string): string { @@ -27,17 +32,32 @@ describe('Run dropdown scrolls when it is taller than the room above the toolbar expect(menu).toMatch(/touch-action:\s*pan-y/); }); - it('is capped to the space between the header and the toolbar, in dvh with a vh fallback', () => { + it('is capped to the space between the header and the toolbar, following --app-height and the safe areas', () => { const caps = [...menu.matchAll(/max-height:\s*([^;]+);/g)].map((m) => m[1]); expect(caps).toHaveLength(2); expect(caps[0]).toContain('100vh'); expect(caps[1]).toContain('100dvh'); + // dvh does not shrink for the on-screen keyboard; --app-height (the visual viewport) does. + expect(caps[1]).toContain('var(--app-height'); for (const cap of caps) { expect(cap).toContain('var(--header-height)'); expect(cap).toContain('var(--toolbar-height)'); + // The phone header and toolbar grow by the safe areas in the iPhone home-screen app. + expect(cap).toContain('var(--safe-area-top)'); + expect(cap).toContain('var(--safe-area-bottom)'); } }); + it('never scrolls sideways (a long custom-endpoint label must not add a horizontal scrollbar)', () => { + expect(menu).toMatch(/overflow-x:\s*hidden/); + }); + + it('raises the toolbar while the menu is open, so its last rows clear the keyboard bar and the CJK input', () => { + // The menu is trapped in the toolbar's stacking context (backdrop-filter), so only the + // toolbar's own z-index can lift it over the accessory bar (z 51) and #cjkInput (z 52). + expect(css).toMatch(/\.toolbar:has\(\.run-mode-menu\.active\)[\s\S]*?\{\s*z-index:\s*100;/); + }); + it('does not let scrolling children (history, saved URLs) be squashed to nothing', () => { expect(rule('.run-mode-menu > *')).toMatch(/flex-shrink:\s*0/); }); From 02c65e988ab831542570963f72ce6d5700d47e22 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 05:13:06 +0200 Subject: [PATCH 34/42] fix(ci): #540 landing fixes Move the nightly cron from 03:17 to 03:23 UTC. GitHub sends scheduled-run failure notices to whoever last modified the cron line, and after the merge that is the contributor, so a maintainer commit has to touch it. The docs below give no clock time, so they cannot drift from the cron. Drop the "Keep the failure artifacts" step and the blank line before it. No browser test writes test-results/ or screenshots-echo-diag/ (only the ignore files name them), and if-no-files-found: ignore made the step upload nothing without a word. The run log already carries the failure output. Reword the workflow header. Drop the claim that the skipped suite let two semantically conflicting PRs merge green: that incident came from test/mobile/keyboard.test.ts, which this job does not run. Correct the codex-predictive-echo note: the test uses a fake key in a throwaway CODEX_HOME and skips itself when codex is missing, so it needs a codex binary, not an authenticated one. opencode-resize: record WebSocket resize frames under the socket's own URL instead of appending '#' + the session id. The URL already carries /ws/sessions/<id>/terminal, and the suffix let toContain(sessionId) pass for a resize sent on any session's socket, the bug this test exists to catch. Reduce the six session-id extractions (opencode-resize and perf-browser) to data.data?.session?.id. POST /api/sessions always answers in the { success, data: { session } } envelope, and the dead fallbacks are what hid the original breakage. split-pane-terminal: restore the browser config's 60 s test timeout (the added 20000 ms override tightened it), and replace the comment that blamed Codeman's post-create clear. Under vitest the session is an echo PTY, so that clear comes back as text; the real fix is useMux:false, since a plain prompt otherwise goes through tmux send-keys, which test mode no-ops. CLAUDE.md: the CI note now says the gate excludes the Playwright tests in BROWSER_TEST_GLOBS instead of a stale count of 14, and names browser-suite.yml; the Testing warning says the browser suite runs nightly. CONTRIBUTING.md gets the same one-line pointer under Tests. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .github/CONTRIBUTING.md | 2 ++ .github/workflows/browser-suite.yml | 23 ++++++----------------- CLAUDE.md | 4 ++-- test/opencode-resize.test.ts | 14 ++++++-------- test/perf-browser.test.ts | 2 +- test/split-pane-terminal.browser.test.ts | 8 ++++---- 6 files changed, 21 insertions(+), 32 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index fd80577a..fab29389 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -55,6 +55,8 @@ npm run test:all # literally everything, environmental failures included Expect `test:browser`/`test:mobile`/`test:perf` to fail where the machine cannot provide what they need; read that as "not runnable here", not as a regression. `config/test-suites.ts` holds the globs, and both configs derive from it, so the exclusions and those runners cannot drift apart. +The browser suite also runs nightly (and on demand) in `.github/workflows/browser-suite.yml`; it is informational, not a gate. + If you add a test that binds a port, bind port 0 (`new WebServer(0, …)` + `server.boundPort`, or `listen({ port: 0 })` + `address().port`), or use `app.inject()` when no socket is needed; `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port. Mobile tests (`test/mobile/**`, via `createTestServer(PORT)`) keep the fixed-port convention in `test/mobile/README.md` for now, because that helper caches servers by port. Never 3000. Tests are tmux-safe by design: under vitest, the tmux layer becomes an in-memory mock, so tests cannot touch real sessions. diff --git a/.github/workflows/browser-suite.yml b/.github/workflows/browser-suite.yml index 7581e07c..c5da3964 100644 --- a/.github/workflows/browser-suite.yml +++ b/.github/workflows/browser-suite.yml @@ -1,18 +1,18 @@ name: Browser suite # The per-push CI gate deliberately skips the Playwright-driven suite (config/test-suites.ts), -# which has twice let two PRs that conflict semantically merge green. This job runs it on a -# schedule and on demand, so a browser-only regression (the Shift+Enter keypress bug was one) -# is caught within a day instead of by a user. It is NOT a merge gate: a red run means "look", -# and it never blocks a push or a PR. +# so a browser-only regression can merge green. This job runs that suite on a schedule and on +# demand, so such a regression (the Shift+Enter keypress bug was one) is caught within a day +# instead of by a user. It is NOT a merge gate: a red run means "look", and it never blocks a +# push or a PR. # # Needs: chromium (installed below), tmux, and the live server the tests start themselves. # Not run here: test:mobile (per-machine PNG baselines), test:perf (wall-clock), and -# codex-predictive-echo (needs a real, authenticated codex binary). +# codex-predictive-echo (needs a real codex binary; it also skips itself without one). on: schedule: - - cron: '17 3 * * *' + - cron: '23 3 * * *' workflow_dispatch: permissions: @@ -48,14 +48,3 @@ jobs: - name: Run the browser suite run: npm run test:browser -- --exclude test/codex-predictive-echo.test.ts - - - name: Keep the failure artifacts - if: failure() - uses: actions/upload-artifact@v4 - with: - name: browser-suite-results - path: | - test-results/ - screenshots-echo-diag/ - if-no-files-found: ignore - retention-days: 7 diff --git a/CLAUDE.md b/CLAUDE.md index a87feccf..37f0ec3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` | | Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) | -**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`). +**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and the Playwright tests in `BROWSER_TEST_GLOBS`; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`). `browser-suite.yml` runs `npm run test:browser` (minus codex-predictive-echo) nightly and on demand via `workflow_dispatch`; it is informational and never gates a push or PR. **Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`. @@ -465,7 +465,7 @@ npm run test:perf # wall-clock benchmarks — need an otherwise idle machin npm run test:all # literally everything; fails ~87 tests on a clean master here, which is why it is not the default ``` -⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above). +⚠️ **`npm test` cannot see those suites**, so a change touching mobile/gesture/terminal-render behaviour needs the matching runner by hand — diff its FAIL list against master rather than reading it as pass/fail. That blind spot is what let two semantically-conflicting PRs merge green (see the on-screen-keyboard note above). The browser suite (not mobile or perf) also runs nightly in `.github/workflows/browser-suite.yml`, which catches a regression after it lands, not before. ⚠️ **A file filter must match the runner.** `npm test -- test/mobile/keyboard.test.ts` matches nothing and exits GREEN having run zero tests, because the gate's config excludes that path — an excluded file needs its own runner (`npm run test:mobile -- <file>`, `npm run test:browser -- <file>`, `npm run test:perf -- <file>`). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour. diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts index e5a40161..5a2cb696 100644 --- a/test/opencode-resize.test.ts +++ b/test/opencode-resize.test.ts @@ -119,13 +119,12 @@ describe('OpenCode session initial resize', () => { ws.on('framesent', (frame) => { try { const msg = JSON.parse(String(frame.payload)); - if (msg.t === 'z') resizeCalls.push({ url: ws.url() + '#' + sessionIdForWs, cols: msg.c, rows: msg.r }); + if (msg.t === 'z') resizeCalls.push({ url: ws.url(), cols: msg.c, rows: msg.r }); } catch { /* not JSON */ } }); }); - let sessionIdForWs = ''; await page.route('**/api/sessions/*/resize', async (route) => { const request = route.request(); const body = request.postDataJSON(); @@ -147,11 +146,10 @@ describe('OpenCode session initial resize', () => { }); const data = await res.json(); // POST /api/sessions answers in the { success, data: { session } } envelope. - return data.data?.session?.id ?? data.id ?? data.session?.id; + return data.data?.session?.id; }); expect(sessionId).toBeTruthy(); - sessionIdForWs = sessionId; // Call selectSession (which is what runOpenCode does after fix) await page.evaluate(async (sid: string) => { @@ -194,7 +192,7 @@ describe('OpenCode session initial resize', () => { }); const data = await res.json(); // POST /api/sessions answers in the { success, data: { session } } envelope. - return data.data?.session?.id ?? data.id ?? data.session?.id; + return data.data?.session?.id; }); expect(sessionId).toBeTruthy(); @@ -246,7 +244,7 @@ describe('OpenCode session initial resize', () => { }); const data = await res.json(); // POST /api/sessions answers in the { success, data: { session } } envelope. - return data.data?.session?.id ?? data.id ?? data.session?.id; + return data.data?.session?.id; }); expect(sessionId).toBeTruthy(); @@ -331,7 +329,7 @@ describe('OpenCode close modal text', () => { }); const data = await res.json(); // POST /api/sessions answers in the { success, data: { session } } envelope. - return data.data?.session?.id ?? data.id ?? data.session?.id; + return data.data?.session?.id; }); expect(sessionId).toBeTruthy(); @@ -374,7 +372,7 @@ describe('OpenCode close modal text', () => { }); const data = await res.json(); // POST /api/sessions answers in the { success, data: { session } } envelope. - return data.data?.session?.id ?? data.id ?? data.session?.id; + return data.data?.session?.id; }); expect(sessionId).toBeTruthy(); diff --git a/test/perf-browser.test.ts b/test/perf-browser.test.ts index 97c65229..0c5a2c92 100644 --- a/test/perf-browser.test.ts +++ b/test/perf-browser.test.ts @@ -70,7 +70,7 @@ async function createSession(page: Page, name: string): Promise<string> { }); const data = await res.json(); // POST /api/sessions answers in the { success, data: { session } } envelope. - return data.data?.session?.id ?? data.id ?? data.session?.id; + return data.data?.session?.id; }, name); return result as string; } diff --git a/test/split-pane-terminal.browser.test.ts b/test/split-pane-terminal.browser.test.ts index 3f032c62..5463cef4 100644 --- a/test/split-pane-terminal.browser.test.ts +++ b/test/split-pane-terminal.browser.test.ts @@ -113,9 +113,9 @@ describe('TerminalTile in a real browser', () => { // own startup can race an early write and, on this box, a startup // script issues a `clear` that erases scrollback (modern ncurses // `clear` emits \x1b[3J) if the input lands before the shell is ready. - // Codeman itself writes `clear` into a NEW shell session ~100ms after - // creating it, which can erase an early marker, so re-send until the - // marker is present in the capture rather than writing once. + // Send with useMux:false: a plain prompt otherwise goes out through + // tmux send-keys, which test mode no-ops, so the marker never reached + // the PTY. Re-sending until the capture shows it is just belt and braces. const deadline = Date.now() + 8000; for (;;) { await fetch(`/api/sessions/${id}/input`, { @@ -169,7 +169,7 @@ describe('TerminalTile in a real browser', () => { await page.evaluate(async (id) => { await fetch(`/api/sessions/${id}`, { method: 'DELETE' }); }, sessionId); - }, 20000); + }); it('gates app-level chords out of Pane B instead of forwarding their raw bytes', async () => { // Regression guard for PR #453's Ctrl+K/Alt+1/Alt+B leak: Pane B had no From 1105d646f3ccc15d990482e2cd3aa1b6b51e0d08 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 05:30:20 +0200 Subject: [PATCH 35/42] fix(terminal): #555 landing fixes Comment and doc corrections that #555 made stale, no behaviour change. - stock.ts: the grok and omp altScreen comments compared their strip to opencode's, which is now strip-mux-and-mouse rather than the narrow strip. Grok now says it shares antigravity's strip until measured, and omp drops opencode from its comparison. - terminal-ui.js: the touch-tap comment named Claude/Codex/Gemini as the stripped modes, but the gate is now the cliMouseTracking flag alone and covers opencode too, so it names the two stripping flavours instead. - src/types/session.ts: the cliMouseTracking JSDoc (the flag the browser now gates on exclusively) listed only claude/codex/gemini; it now names the strip-full and strip-mux-and-mouse modes, including opencode under tmux. - src/session.ts: the usesMux getter doc now names isMuxMouseStripMode, since the replay strip passes usesMux to it as well. - docs/architecture-invariants.md: the narrow-strip list gains grok/deepseek/omp (matching the PR's own CLAUDE.md line), the "must REMEMBER" heading covers both DECSET-stripping flavours, and the cliMouseTracking writer is described as the full-or-mouse branch it really is. - docs/wiki/The-Dashboard.md: the user manual said every non-Claude CLI scrolls locally; opencode's wheel and swipes now page its conversation. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 4 ++-- docs/wiki/The-Dashboard.md | 3 ++- src/config/cli-registry/stock.ts | 7 ++++--- src/session.ts | 2 +- src/types/session.ts | 3 ++- src/web/public/terminal-ui.js | 7 ++++--- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f11b9e55..6cc7f440 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -217,9 +217,9 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` ### Terminal scrollback: strip flavors and wheel/touch forwarding -**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in the mouse-strip branch, so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. +**Three strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. The MOUSE strip (`isMuxMouseStripMode` = opencode) removes alt-screen toggles AND the mouse DECSETs but KEEPS `3J` — the middle case, for a mouse-capable full-screen TUI: its tracking DECSETs reach the browser (tmux `mouse off` passes the pane's modes through to the client), xterm obeys them, and then every DRAG is reported to the TUI instead of selecting text — so mark-and-copy silently did nothing (measured 62 `none` / 18 `any` over 16s, 5/5 dead drags while `any`) and the obvious fallback, Ctrl+C, is opencode's `app_exit`. `3J` stays because a TUI is not a `clear` consumer. Every other mode (shell/antigravity/pi/grok/deepseek/omp) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support — which is exactly why the mouse strip is opt-in per CLI and not part of the narrow flavour). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same three branches; the frontend gate (`_shouldReportMouseToCli()`) keeps no mode list at all: it reads only the published `cliMouseTracking`, which the server sets solely in a DECSET-stripping branch (full or mouse), so it can only be true for a mode whose DECSETs are stripped — the modes where the browser's hand-encoded tap (`_sendSyntheticSgrTap`) is the only way a click still reaches the CLI. Whichever modes the registry strips, the browser follows (pinned in `test/claude-scrollback-strip.test.ts`). The chunk-boundary carry (`_altScreenSeqCarry`) runs for all three flavors. Tests: `test/claude-scrollback-strip.test.ts`, `test/terminal-touch-tap.test.ts`. -⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. +⚠️ **What a mouse-DECSET strip (full or mouse) removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously in fullscreen (so clicks keep being reported exactly as before; the default inline renderer holds none, measured on 2.1.283 even with `/model` open), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach. **Only claude ≥ 2.1.187 with mouse tracking on forwards the wheel; everything else scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. Claude repeats this exactly in its default INLINE renderer (measured on 2.1.280: `alternate_on=0`, `mouse_any_flag=0`, `history_size` grows), and swipes on iOS Safari were dead there while codex scrolled; only fullscreen claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"` in `~/.claude/settings.json`: alt screen plus modes 1003/1006) pages its transcript on wheel reports. So claude forwards only while the server-observed `cliMouseTracking` flag is true; a stale-false flag after a server restart falls through to the PageUp/PageDown fallback, never a dead wheel. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS are gated by `_shouldReportMouseToCli()` (the server-observed `cliMouseTracking` flag, recorded by `_recordStrippedMouseMode` in session.ts as it strips, so only ever true for a stripped mode): codex never enables mouse tracking, so since #325 no tap report is sent there at all — click-to-position was already a measured no-op in codex, and a pane that has fallen back to a shell no longer receives `[<0;88;20M` junk. diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 6138b476..cd9da9e2 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -218,7 +218,8 @@ Worth knowing: Claude runs fullscreen (`CLAUDE_CODE_NO_FLICKER=1`, or `"tui": "fullscreen"` in `~/.claude/settings.json`), so the wheel scrolls the conversation rather than the terminal. Claude's default inline view keeps its history in the terminal and scrolls locally. `Shift+Wheel` is - always local scrollback. Other CLIs scroll locally. + always local scrollback. OpenCode's wheel and swipes page its own conversation + (PageUp/PageDown). Other CLIs scroll locally. - **Selection copy.** `Ctrl+C` copies when text is selected and interrupts when it is not. `Ctrl+Shift+C` always copies. - **Selecting where the CLI owns the mouse.** `Shift+drag` starts a selection even in a pane diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 7caf6f8c..8126a7eb 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -1157,8 +1157,9 @@ const GROK: CliEntry = { }, capabilities: { ...agentDefaults(), - // Fullscreen alt-screen TUI with mouse support — same shape as opencode/antigravity: - // only the tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. + // Fullscreen alt-screen TUI with mouse support, same strip as antigravity until measured + // (opencode's mouse strip is #443): only the tmux-attach-time smcup strip, not Ink's full + // erase-scrollback+DECSET strip. altScreen: 'strip-mux-only', // Buffer-policy fallthrough default, unmeasured against an authenticated grok composer // (the existing hedge, preserved verbatim) — same as gemini/antigravity/pi. @@ -1491,7 +1492,7 @@ const OMP: CliEntry = { }, capabilities: { ...agentDefaults(), - // Fullscreen alt-screen TUI, same shape as opencode/antigravity/grok: only the + // Fullscreen alt-screen TUI, same shape as antigravity/grok: only the // tmux-attach-time smcup strip, not Ink's full erase-scrollback+DECSET strip. altScreen: 'strip-mux-only', // Codeman reads omp's own `~/.omp/agent/sessions/**/*.jsonl` host-side, which is what diff --git a/src/session.ts b/src/session.ts index 2cf209de..107f8d20 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1382,7 +1382,7 @@ export class Session extends EventEmitter { /** * True when this session's PTY is a tmux client rather than the program itself. * Read by the replay-side alt-screen strip, which must apply the same - * `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode). + * `useMux` gate as the live strip (isMuxAltScreenOnlyStripMode, isMuxMouseStripMode). */ get usesMux(): boolean { return this._useMux; diff --git a/src/types/session.ts b/src/types/session.ts index b0f4214d..ae62638d 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -812,7 +812,8 @@ export interface SessionState { /** * True while the CLI in the pane has a mouse-tracking DECSET on, as observed * by the server on its way out of the stream (those sequences are stripped for - * claude/codex/gemini, so the browser can never see them itself). The browser + * the strip-full and strip-mux-and-mouse modes, claude/codex/gemini and opencode + * under tmux, so the browser can never see them itself). The browser * hand-encodes a click report ONLY when this is true; without it, every click * sent mouse reports to a CLI that never asked for them. */ diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 1bbe0b99..cf63dc2a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -5304,9 +5304,10 @@ Object.assign(CodemanApp.prototype, { // follows the same path as a desktop click. this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); } else if (shouldActivate && this._shouldReportMouseToCli()) { - // Claude/Codex/Gemini DECSETs are stripped from the browser stream, so - // report directly to the PTY while retaining local touch scrollback. Only - // while the CLI actually has tracking on (see _shouldReportMouseToCli). + // This session's mouse DECSETs are stripped from the browser stream + // (strip-full or strip-mux-and-mouse), so report directly to the PTY + // while retaining local touch scrollback, and only while the CLI + // actually has tracking on (see _shouldReportMouseToCli). this._sendSyntheticSgrTap(touch.clientX, touch.clientY); } From e86c3d1ed3c417186304d378b72e77c142b64d1e Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 05:36:57 +0200 Subject: [PATCH 36/42] fix(git-status): #543 landing fixes A lone repository git could not read rendered as a clean, empty one. The panel took its single-repository view whenever the overview held one row, and the error row only exists in the list view, so it showed "Nothing uncommitted / No remote configured" with an empty header while the indicator said "? 1". The single-repository view now needs a readable repository and an untruncated overview; anything else takes the list view (headed "1 repository"), and the tooltip names the unreadable repository instead of saying "no branch". The same condition covers a limit of 1 in a folder of several projects, now that max repositories can go down to 1: the one row shown keeps the "Showing the first" notice instead of looking like the only repository. A repeated timeout query parameter reaches the route as an array, and calling trim() on it answered 500 with an internal message, before the ownership check. The route now treats a non-string timeout as "default", like an empty or absent one, and the route test pins it. The browser test gains the lone-unreadable-repository case (error row, no "Nothing uncommitted", "? 1", tooltip names the repository) and the truncated single-row case. Both fail against the unfixed panel. The git timeout input steps by 1, not 5: the save accepts any whole number of seconds and step 5 flagged values like 7 as invalid. Docs: api-reference says repoLimit is only present in the folder-of-projects case, the Settings Reference and Working With Files glyph lists mention "? N", and the module header says the repository count is the caller's maxRepos. The PR's own changeset is removed; its text goes into the single combined release changeset. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/git-status-limits.md | 5 ---- docs/api-reference.md | 2 +- docs/wiki/Settings-Reference.md | 5 ++-- docs/wiki/Working-With-Files.md | 2 +- src/git-workspace-status.ts | 4 +-- src/web/public/git-status-ui.js | 10 ++++--- src/web/public/index.html | 2 +- src/web/routes/git-status-routes.ts | 10 ++++--- test/git-status.browser.test.ts | 41 +++++++++++++++++++++++++++ test/routes/git-status-routes.test.ts | 9 ++++++ 10 files changed, 70 insertions(+), 20 deletions(-) delete mode 100644 .changeset/git-status-limits.md diff --git a/.changeset/git-status-limits.md b/.changeset/git-status-limits.md deleted file mode 100644 index 0b9e9287..00000000 --- a/.changeset/git-status-limits.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"aicodeman": patch ---- - -The Git status window's limits are settings now. **Git status: max repositories** (App Settings → Header & Panels → Bottom bar, per device, 1 to 50, default 12) is how many repositories it lists when the session's folder holds several projects, and **Git status: git timeout** (5 to 120 seconds, default 30, was a fixed 10) is how long one git command may run. A repository git could not read (a timeout on a slow network share was the usual cause) used to be dropped without a word, which left a count like "first 11" under a limit of 12; it now stays in the list with the reason, shows as `? N` in the indicator instead of letting it read ✓, and the truncation line reads "Showing the first N of more than N repositories" and points at the setting. `GET /api/sessions/:id/git-status` and `/git-diff` take `maxRepos` and `timeout` (seconds) query parameters, clamped server-side, and the overview reports `repoLimit`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 6d812dad..47c22e0b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -777,7 +777,7 @@ Admin only in multi-user mode (`403`), like `POST /api/cases/link`: it writes ou Both routes accept two optional query parameters, which the UI sends from its per-device settings and the server clamps again: `maxRepos` (1 to 50, default 12) and `timeout` (seconds one git command may run, 5 to 120, default 30). An empty or non-numeric value means the default. A repository whose `git status` fails (typically a timeout on a slow network share) is **kept in `repos[]`** with `status.state: 'error'` and the reason in `status.error`, not dropped, so it is visible that something is not being reported. -`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }`: +`data` is `{ state, repos, reposTruncated, repoLimit, checkedAt }` (`repoLimit` in the folder-of-projects case only): - `state: 'ok'`: `repos[]`, each `{ name, path, status }` where `name` is the repository folder's name, `path` its root relative to the working directory, and `status` is: `branch` (null when `detached`), `upstream`, `ahead`, `behind`, `hasRemote`, `counts` (`staged`, `unstaged`, `untracked`, `conflicted`, `uncommitted` = distinct paths, `stashes`), `files[]` (`path` relative to `repoRoot`, `origPath` for a rename, `index` and `worktree` status letters, `kind`: `staged` \| `unstaged` \| `untracked` \| `conflicted`; a file that is staged *and* modified again appears once per kind), `filesTruncated`, `unpushedCount` (exact) and `unpushed[]` (newest first: `hash`, `author`, `time` in epoch seconds, `subject`), `repoRoot`, `checkedAt`. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 23c1c67d..ff31d1d3 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -63,8 +63,9 @@ Ultracode Windows, Cron. **Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not -pushed, `⚠ N` merge conflicts, or `✓` when everything is committed and pushed. Click it for the -Git window; see [Working With Files](Working-With-Files#git-changes). **Git status: group files +pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is +committed and pushed. Click it for the Git window; see +[Working With Files](Working-With-Files#git-changes). **Git status: group files by folder** (per device, on by default) shows changed files under collapsed folders in that window; off lists every file by its full path. **Git status: max repositories** (per device, 1 to 50, default 12) is how many repositories the window lists when a session's folder holds diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index ba67cabe..97bce89c 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -168,7 +168,7 @@ surface as an artifact attachment rather than a path you have to go and find. Agents often leave work uncommitted or unpushed. Turn on **App Settings → Header & Panels → Bottom bar → Git status** (per device, off by default) and the right of the bottom bar shows the active session's repository: `● 3` uncommitted files, `↑ 2` commits not pushed, `⚠` merge -conflicts, `✓` when everything is committed and pushed. +conflicts, `? 1` a repository git could not read, `✓` when everything is committed and pushed. Click it for a draggable window, in the style of the File Viewer: diff --git a/src/git-workspace-status.ts b/src/git-workspace-status.ts index fb3d1da3..8850542a 100644 --- a/src/git-workspace-status.ts +++ b/src/git-workspace-status.ts @@ -15,8 +15,8 @@ * subfolder reports its whole enclosing repo; a nested repo below it is just an untracked folder * to the outer one, and is not scanned; * - NOT inside one (a folder that holds several projects): every repository found up to two levels - * DOWN (`MAX_REPOS` of them, skipping dot-folders, `node_modules` and the like, never following - * symlinks), each reported separately; + * DOWN (the caller's `maxRepos` of them, `MAX_REPOS` by default, skipping dot-folders, `node_modules` + * and the like, never following symlinks), each reported separately; * - a repository that merely sits ABOVE the workspace and is the home folder or higher (a dotfiles * repo in `$HOME`, or `/`) is ignored: its dirty files are not this session's work. * diff --git a/src/web/public/git-status-ui.js b/src/web/public/git-status-ui.js index b324a166..967516eb 100644 --- a/src/web/public/git-status-ui.js +++ b/src/web/public/git-status-ui.js @@ -197,7 +197,7 @@ Object.assign(CodemanApp.prototype, { if (sum.repos > 1) where = `${sum.repos} repositories`; else { const d = overview.repos[0].status; - where = d.detached ? 'detached HEAD' : d.branch || 'no branch'; + where = d.state === 'error' ? overview.repos[0].name : d.detached ? 'detached HEAD' : d.branch || 'no branch'; } return `Git (${where}): ${bits.join(', ')}. Click for details.`; }, @@ -358,13 +358,15 @@ Object.assign(CodemanApp.prototype, { } const repos = overview.repos; - if (repos.length === 1) { - // One repository: the panel is that repository, as it always was. + if (repos.length === 1 && repos[0].status.state !== 'error' && !overview.reposTruncated) { + // One repository: the panel is that repository, as it always was. One that git could not read, + // or the only one shown of several (the limit is 1), takes the list view below instead, so its + // error row or the "Showing the first" notice is not lost. const d = repos[0].status; if (head) head.textContent = d.detached ? 'detached HEAD' : d.branch || ''; this._renderGitRepoInto(body, d); } else { - if (head) head.textContent = `${repos.length} repositories`; + if (head) head.textContent = repos.length === 1 ? '1 repository' : `${repos.length} repositories`; for (const r of repos) body.append(this._gitRepoSection(r)); if (overview.reposTruncated) { body.append( diff --git a/src/web/public/index.html b/src/web/public/index.html index 9c9fdb86..b4c0e792 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2021,7 +2021,7 @@ <span class="set-row-label">Git status: git timeout <span class="set-scope">device</span></span> <span class="set-row-desc">Seconds one git command may run before that repository is reported as unreadable (5 to 120, default 30). Raise it for repositories on a slow network share.</span> </div> - <input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="5"> + <input type="number" id="appSettingsGitStatusTimeout" class="set-num" value="30" min="5" max="120" step="1"> </div> </div> </div> diff --git a/src/web/routes/git-status-routes.ts b/src/web/routes/git-status-routes.ts index 9f14d835..0d6d8cbc 100644 --- a/src/web/routes/git-status-routes.ts +++ b/src/web/routes/git-status-routes.ts @@ -39,10 +39,12 @@ const defaultDockerWorkspaces = async (): Promise<string[]> => * `maxRepos` and `timeout` (seconds) query parameters, each clamped to a safe range, so an odd value * can never cost more than the module's own ceiling. */ -function limitsFrom(query: { maxRepos?: string; timeout?: string }) { +function limitsFrom(query: { maxRepos?: unknown; timeout?: unknown }) { + // A repeated key arrives as an array: it is not a number, so it means "default" like '' and absent. + const timeout = typeof query.timeout === 'string' && query.timeout.trim() ? query.timeout : undefined; return resolveOverviewLimits({ maxRepos: query.maxRepos, - timeoutMs: query.timeout?.trim() ? Number(query.timeout) * 1000 : undefined, // '' and absent mean "default" + timeoutMs: timeout !== undefined ? Number(timeout) * 1000 : undefined, }); } @@ -54,7 +56,7 @@ export function registerGitStatusRoutes( ): void { app.get('/api/sessions/:id/git-status', async (req): Promise<ApiResponse<GitWorkspaceOverview>> => { const { id } = req.params as { id: string }; - const query = req.query as { fresh?: string; maxRepos?: string; timeout?: string }; + const query = req.query as { fresh?: string; maxRepos?: unknown; timeout?: unknown }; const { fresh } = query; const limits = limitsFrom(query); const session = findSessionOrFail(ctx, id, req); @@ -78,7 +80,7 @@ export function registerGitStatusRoutes( // repository's status is refreshed: a click must not re-read every repository in the folder. app.get('/api/sessions/:id/git-diff', async (req, reply): Promise<ApiResponse<GitFileDiff>> => { const { id } = req.params as { id: string }; - const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: string; timeout?: string }; + const query = req.query as { repo?: string; path?: string; kind?: string; maxRepos?: unknown; timeout?: unknown }; const { repo, path, kind } = query; const limits = limitsFrom(query); const session = findSessionOrFail(ctx, id, req); diff --git a/test/git-status.browser.test.ts b/test/git-status.browser.test.ts index f79c1387..e86b5953 100644 --- a/test/git-status.browser.test.ts +++ b/test/git-status.browser.test.ts @@ -469,6 +469,47 @@ describe('Git status indicator in a real browser', () => { expect(await label()).toContain('? 1'); expect(await label()).not.toContain('✓'); expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/1 repository could not be read/); + + const mockOverview = async (data: Record<string, unknown>) => { + await page.unroute('**/api/sessions/*/git-status*'); + await page.route('**/api/sessions/*/git-status*', (route) => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, data: { state: 'ok', checkedAt: Date.now(), ...data } }), + }) + ); + await refresh(); + }; + // The ONLY repository git could not read: still the error row, never a clean, empty repository. + await mockOverview({ + reposTruncated: false, + repoLimit: 12, + repos: [ + { + name: 'slow', + path: 'slow', + status: status({ state: 'error', error: 'git timed out', branch: null, hasRemote: false, upstream: null }), + }, + ], + }); + await page.waitForFunction(() => document.getElementById('gitStatusBranch')?.textContent === '1 repository'); + const lone = (await page.textContent('#gitStatusBody')) ?? ''; + expect(lone).toContain('could not read: git timed out'); + expect(lone).not.toContain('Nothing uncommitted'); + expect(await label()).toContain('? 1'); + expect(await page.getAttribute('#gitStatusBtn', 'title')).toMatch(/Git \(slow\)/); + + // A limit of 1 in a folder of several: the one row shown keeps the "Showing the first" notice. + await mockOverview({ + reposTruncated: true, + repoLimit: 1, + repos: [{ name: 'fast', path: 'fast', status: status({ repoRoot: '/x/fast' }) }], + }); + await page.waitForFunction(() => + /Showing the first 1 /.test(document.getElementById('gitStatusBody')?.textContent ?? '') + ); + expect(await page.textContent('#gitStatusBranch')).toBe('1 repository'); + await page.unroute('**/api/sessions/*/git-status*'); await setLimits('12', '30'); await refresh(); diff --git a/test/routes/git-status-routes.test.ts b/test/routes/git-status-routes.test.ts index 2ab9df37..b8f24e9d 100644 --- a/test/routes/git-status-routes.test.ts +++ b/test/routes/git-status-routes.test.ts @@ -339,6 +339,15 @@ describe('GET /api/sessions/:id/git-status limits', () => { clearGitStatusCache(); await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status?maxRepos=&timeout=&fresh=1' }); expect(seen.at(-1)).toBe(30_000); // empty means "not given", not 0 + + // A repeated key reaches the route as an array: it means "default", never a 500. + clearGitStatusCache(); + const repeated = await app.inject({ + method: 'GET', + url: '/api/sessions/test-session-1/git-status?timeout=5&timeout=6&fresh=1', + }); + expect(repeated.statusCode).toBe(200); + expect(seen.at(-1)).toBe(30_000); }); }); From d7140c32b4b84c0c6b03183a7b5590f79b5adf9e Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 05:41:37 +0200 Subject: [PATCH 37/42] fix(terminal): #541 landing fixes A composition that ends in the same task as an Enter keydown was sent twice. The keydown settled the pending edit (sending the composed word and setting _dataAlreadySent), then xterm's own keydown finalized the composition synchronously through _finalizeComposition(false), which ignores _dataAlreadySent and sent the word again. settleEdit() now takes the keydown event and, while xterm has a composition in flight (_isSendingComposition), leaves the text to xterm for any key that makes it finalize synchronously. On 229, CapsLock and the modifiers xterm keeps the composition on its async path, which honours _dataAlreadySent, so the edit still applies there. The waiting timers are cleared before that early return, so a timer cannot fire after Enter's textarea clear and send a run of DELs. The guard sits in settleEdit(), not in applyEdit() as the bot proposed. In applyEdit() it would also silence the timer path, where xterm always finalizes asynchronously and skips _dataAlreadySent, so a non-composing character typed just before a composition (the x in xword) would be lost where master and the PR head both deliver it. Two unit tests pin it, both measured: one fails without the guard (the Enter keydown sends 'ab word' instead of 'ab '), and one fails with the guard moved into applyEdit() (the timer path sends 'ab ' instead of 'ab xword'; a 229 settle must also still send the edit). The xterm private-API guard test now also checks the bundle still ships _isSendingComposition, and names it in its failure message and comment. CLAUDE.md: the surviving #441 sentence said a keydown decides before xterm's 229 rescue has run and that Enter's clear makes the pending diff emit nothing. Neither holds any more (the edit diff is settled first, and master already sent one DEL there), so it now says the edit diff is settled first at that keydown. The PR's sentence notes the composition exception. The PR's own changeset is removed; its text goes into the single combined release changeset. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/android-autocorrect-duplication.md | 5 --- CLAUDE.md | 2 +- .../public/terminal-keycode229-recovery.js | 13 +++++- test/terminal-keycode229-recovery.test.ts | 41 +++++++++++++++++++ test/xterm-private-api.test.ts | 14 ++++--- 5 files changed, 61 insertions(+), 14 deletions(-) delete mode 100644 .changeset/android-autocorrect-duplication.md diff --git a/.changeset/android-autocorrect-duplication.md b/.changeset/android-autocorrect-duplication.md deleted file mode 100644 index fe6103ff..00000000 --- a/.changeset/android-autocorrect-duplication.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"aicodeman": patch ---- - -Fix text being duplicated when an Android keyboard autocorrects while typing straight into the terminal prompt. SwiftKey and Gboard autocorrect on space by deleting the word and inserting the corrected one; xterm answered by sending the whole helper-textarea value (it diffs with `newValue.replace(oldValue, '')`, which only works for appends) and then the inserted text a second time, so `testing the peompt` + space reached the shell as `testing the peompttesting the prompt rompt `. A multi-character delete was also sent as a single DEL. The keyCode-229 controller now replaces xterm's `_handleAnyTextareaChanges` with an edit-based diff against the value already sent (DEL per deleted character, then the new text, once), and puts xterm's own handler back on teardown. diff --git a/CLAUDE.md b/CLAUDE.md index fbfb61fb..c48ffe13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -327,7 +327,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js index b2cd94d6..c6d36f0f 100644 --- a/src/web/public/terminal-keycode229-recovery.js +++ b/src/web/public/terminal-keycode229-recovery.js @@ -180,7 +180,7 @@ // It also has to happen BEFORE xterm handles THIS key: for Enter, xterm clears the textarea // in its own keydown, and a timer left pending would then diff the whole line against '' // and send one DEL per character ahead of the submitted line. - settleEdit(); + settleEdit(event); flushPending(); keydownSnapshot = canonicalCount; } @@ -266,7 +266,7 @@ }; // Apply the pending edit NOW instead of on its timer (see handleKeyEvent). - settleEdit = () => { + settleEdit = (event) => { if (waiting.size === 0) return; for (const entry of waiting) { try { @@ -275,7 +275,16 @@ // A broken timer host must not break input handling. } } + // Cleared BEFORE the return below, on purpose: left pending, the timer would fire after + // Enter clears the textarea and send one DEL per character ahead of the submitted line. waiting.clear(); + // A composition just ended and this key makes xterm finalize it SYNCHRONOUSLY, through + // `_finalizeComposition(false)`, which ignores `_dataAlreadySent`: that text is xterm's, and + // sending the edit too would deliver it twice. On 229, CapsLock and the modifiers xterm keeps + // the composition on its async path, which honours `_dataAlreadySent`, so the edit still + // applies there. Only this settle path is guarded: on the timer path xterm always finalizes + // asynchronously, and skipping the edit there would drop a byte master delivers. + if (helper._isSendingComposition && ![229, 20, 16, 17, 18].includes(event?.keyCode)) return; applyEdit(); }; diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts index 7030847d..777fc0d7 100644 --- a/test/terminal-keycode229-recovery.test.ts +++ b/test/terminal-keycode229-recovery.test.ts @@ -571,6 +571,47 @@ describe('edit-based sync of the helper textarea (autocorrect replacements)', () expect(h.sent.join('')).toBe('o'); }); + // compositionend and the Enter keydown in one task: xterm's keydown then finalizes the + // composition SYNCHRONOUSLY via `_finalizeComposition(false)`, which ignores `_dataAlreadySent`, + // so the settle must leave that text to xterm or it is sent twice. + it('does not resend a composition xterm finalizes itself at the Enter keydown', () => { + const h = editSyncHarness(); + const controller = h.create(true); + typeKeys(h, 'ab '); + h.keydown(); + h.edit('ab word'); + (h.helper as any)._isSendingComposition = true; + controller.handleKeyEvent({ type: 'keydown', key: 'Enter', keyCode: 13 }); + h.flush(); + expect(h.sent.join('')).toBe('ab '); + }); + + // On the timer path (and at a 229 keydown) xterm finalizes the composition ASYNCHRONOUSLY and + // skips `_dataAlreadySent`, so the edit must still be applied there: guarding it would drop the + // non-composing `x` typed before the composition. + it('still applies the edit while xterm finalizes a composition asynchronously', () => { + const h = editSyncHarness(); + h.create(true); + typeKeys(h, 'ab '); + h.keydown(); + h.edit('ab xword'); + (h.helper as any)._isSendingComposition = true; + h.flush(); + expect(h.sent.join('')).toBe('ab xword'); + expect(h.helper._dataAlreadySent).toBe('xword'); + + const k = editSyncHarness(); + const controller = k.create(true); + typeKeys(k, 'ab '); + k.keydown(); + k.edit('ab xword'); + (k.helper as any)._isSendingComposition = true; + controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229 }); + k.flush(); + expect(k.sent.join('')).toBe('ab xword'); + expect(k.helper._dataAlreadySent).toBe('xword'); + }); + it('control: xterm alone duplicates the line when the keyboard autocorrects', () => { const h = editSyncHarness(); h.create(false); diff --git a/test/xterm-private-api.test.ts b/test/xterm-private-api.test.ts index dda37c26..a4eee111 100644 --- a/test/xterm-private-api.test.ts +++ b/test/xterm-private-api.test.ts @@ -49,18 +49,19 @@ describe('xterm private-API dependency guard', () => { 'xterm moved off the verified version — re-verify _kickRenderer in a real browser ' + '(terminal-ui.js: _core._renderService._renderDebouncer._animationFrame) AND the ' + 'CompositionHelper fields installEditSync() uses (terminal-keycode229-recovery.js: ' + - '_handleAnyTextareaChanges, _coreService, _isComposing, _dataAlreadySent), then update ' + - 'VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' + + '_handleAnyTextareaChanges, _coreService, _isComposing, _isSendingComposition, _dataAlreadySent), ' + + 'then update VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' + 'degrades to a silent no-op and the freeze it heals comes back unnoticed.' ).toBe(VERIFIED_XTERM_VERSION); }); // terminal-keycode229-recovery.js swaps in an edit-based replacement for xterm's // CompositionHelper._handleAnyTextareaChanges (Android autocorrect = delete + insert, which xterm's - // append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing` - // and `_dataAlreadySent`; if xterm renames any of them the install quietly falls back to xterm's own - // handler and the duplication returns. Property names survive minification, so a string check on - // the shipped bundle catches a rename on upgrade. + // append-only diff duplicates). It reaches `_compositionHelper`, `_coreService`, `_isComposing`, + // `_isSendingComposition` and `_dataAlreadySent`; if xterm renames any of them the install quietly + // falls back to xterm's own handler and the duplication returns (or, for `_isSendingComposition`, a + // composition xterm finalizes at an Enter keydown is sent twice). Property names survive + // minification, so a string check on the shipped bundle catches a rename on upgrade. it('still ships the composition-helper fields the edit-based 229 sync depends on', () => { const bundle = readFileSync(resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8'); for (const name of [ @@ -68,6 +69,7 @@ describe('xterm private-API dependency guard', () => { '_compositionHelper', '_coreService', '_isComposing', + '_isSendingComposition', '_dataAlreadySent', ]) { expect( From 34f211538a0f9fa097b9174b6d7b39cadd38330c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 05:50:21 +0200 Subject: [PATCH 38/42] fix(preview): #502 landing fixes Skip zero-size spreadsheet cells in renderTile. On sheets past the 8,000,000 px scroll cap, a cell clipped to nothing at the spacer's edge (or a visible row or column the worker clamped to 0 px) was still created, and the cell padding and border drew it as a 5 px box below the spacer that grew the scroll area. The size is now computed before the element is created and such cells are skipped, the same way the heading loops already skip 0 px rows and columns. spreadsheet-preview.js is not an input of SPREADSHEET_ASSET_VERSION, so the asset token stays valid. Add zh-CN entries for the static spreadsheet preview strings (loading, too large, no visible worksheets, empty worksheet, the warnings label, timeout, failure, the four parser start and message failures, and the unavailable message from panels-ui). The file-preview body is not a skipped surface, so the exact-match entries apply with no code change. The worker's admission refusal messages and the dynamic status message stay English for a follow-up. docs/security-architecture.md described the attachment gate as a 6-extension allowlist; it now names SUPPORTED_ATTACHMENT_EXTENSIONS in src/attachment-registry.ts and what it covers, including the xlsx this PR adds. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/security-architecture.md | 5 +++-- src/web/public/i18n.js | 12 ++++++++++++ src/web/public/spreadsheet-preview.js | 12 +++++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/security-architecture.md b/docs/security-architecture.md index d1153fbf..072cd369 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -354,8 +354,9 @@ is by id (`GET /api/sessions/:id/attachments/:attachmentId/raw`, same download c the `/root` and `/etc` trees, extendable via `attachmentBlockedPaths` / `CODEMAN_ATTACHMENT_BLOCKED_PATHS`) on every request. Unlike the workspace file routes, attachments are intentionally **cross‑workspace** — so the effective gate -is the blocklist + a 6‑extension allowlist (`png/pdf/docx/pptx/md/txt`), not -realpath containment. +is the blocklist + an extension allowlist (`SUPPORTED_ATTACHMENT_EXTENSIONS` in +`src/attachment-registry.ts`: images, pdf/docx/pptx/xlsx, audio/video, md/txt and +other text), not realpath containment. Two registration paths, with **different trust**: diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 9f791263..ff810617 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -858,6 +858,18 @@ 'Wrap lines': '自动换行', 'Unsaved changes': '未保存的更改', Saved: '已保存', + 'Loading spreadsheet…': '正在加载电子表格…', + 'This workbook is too large to preview (10 MB limit).': '此工作簿太大,无法预览(上限 10 MB)。', + 'This workbook has no visible worksheets.': '此工作簿没有可见的工作表。', + 'This worksheet is empty.': '此工作表为空。', + 'Some workbook features are not shown': '部分工作簿功能未显示', + 'Spreadsheet preview timed out.': '电子表格预览超时。', + 'Spreadsheet preview failed': '电子表格预览失败', + 'Spreadsheet parser failed.': '电子表格解析器出错。', + 'Spreadsheet parser failed to start': '电子表格解析器启动失败', + 'Spreadsheet parser message failed.': '电子表格解析器消息出错。', + 'Spreadsheet parser message failed': '电子表格解析器消息出错', + 'Spreadsheet preview is unavailable.': '电子表格预览不可用。', 'Export as JSON': '导出为 JSON', 'Export as Markdown': '导出为 Markdown', 'Mark all read': '全部标为已读', diff --git a/src/web/public/spreadsheet-preview.js b/src/web/public/spreadsheet-preview.js index 2930c121..d43334a1 100644 --- a/src/web/public/spreadsheet-preview.js +++ b/src/web/public/spreadsheet-preview.js @@ -251,6 +251,13 @@ }); } for (const cell of tile.cells.slice(0, 2500)) { + const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`); + const height = rowSpan(cell.row, merge?.r2 || cell.row); + const width = colSpan(cell.col, merge?.c2 || cell.col); + // A cell clipped to nothing at the spacer's edge (or sized 0 px) is + // skipped like its heading: padding and border would still draw it as a + // small box below the spacer and grow the scroll area. + if (height <= 0 || width <= 0) continue; const element = document.createElement('div'); element.className = `spreadsheet-cell spreadsheet-style-${Number(cell.styleId) || 0}`; element.dataset.row = String(cell.row); @@ -258,9 +265,8 @@ element.textContent = String(cell.text ?? ''); element.style.top = `${rowTop(cell.row)}px`; element.style.left = `${colLeft(cell.col)}px`; - const merge = mergeByAnchor.get(`${cell.row}:${cell.col}`); - element.style.height = `${rowSpan(cell.row, merge?.r2 || cell.row)}px`; - element.style.width = `${colSpan(cell.col, merge?.c2 || cell.col)}px`; + element.style.height = `${height}px`; + element.style.width = `${width}px`; cellsLayer.appendChild(element); } // Headings take their size from the same axis math as the cells, so custom From f79f530f938c959c01d97085598e7ef81739b460 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 05:57:49 +0200 Subject: [PATCH 39/42] fix(mobile): #432 landing fixes Applies the review's landing list for the native-wrapper window bridge, with the verifier corrections. detachSession now refuses before asking the host when there is no window channel (no BroadcastChannel). Without the channel there is no roll-call liveness, so a hosted tab could never re-dock and would stay detached, and excluded from tiles and split, until the session ended. The guard sits before the host call so a channel-less host never gets a native window and a window.open as well. A single resolver, tabDetachButtonEnabled(), now lives in app.js next to hasHostWindows() and decides the host-aware pop-out default for the tab icon, App Settings and the tab action menu (which also serves the tile grid's menu). Before this the menu read the raw setting and hid "Open in a new window" under a host. The menu and both settings-ui.js sites call it optionally with a fallback, because test/session-sidebar-ux.browser.test.ts loads tab-rail-resize.js onto a bare CodemanApp without app.js, and a bare call would throw before the menu is appended. The "Close window" button on the solo session-gone overlay goes through _closeSoloWindow(), as the re-dock button already did, so it works in a host window. openWebviewExternal no longer falls through to window.open when the host refuses (in a WebView that can replace the dashboard page); it toasts instead, like the session and file-preview paths. The hasHostWindows and openInHostWindow JSDoc now say what the code does: anything but false counts as opened, and a saved web tab passes its own origin. docs/versioning-policy.md lists the window.CodemanHost bridge under experimental surfaces, so it does not read as a stable contract until the wrapper docs section lands. test/host-window-detach.test.ts gives the harness a live window channel (Object.create leaves it undefined, which the new guard would refuse) and pins the no-channel refusal. The per-PR changeset is removed; the release writes one consolidated changeset. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/host-window-popout.md | 18 ------------------ docs/versioning-policy.md | 6 ++++-- src/web/public/app.js | 27 +++++++++++++++++++++------ src/web/public/settings-ui.js | 6 ++++-- src/web/public/tab-rail-resize.js | 3 ++- src/web/public/webview-tabs.js | 8 +++++++- test/host-window-detach.test.ts | 19 ++++++++++++++++++- 7 files changed, 56 insertions(+), 31 deletions(-) delete mode 100644 .changeset/host-window-popout.md diff --git a/.changeset/host-window-popout.md b/.changeset/host-window-popout.md deleted file mode 100644 index d7e0d484..00000000 --- a/.changeset/host-window-popout.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"aicodeman": minor ---- - -feat(mobile): pop a session or a file preview out beside the dashboard from a native wrapper - -A WebView app has no browser pop-ups, so "Open in a new window" had nothing to open on a -phone, and mobile.css hid it there. An embedding app that can put a page in a window of its -own (an Android app on a foldable or in split screen) now says so with -`window.CodemanHost.openWindow(absoluteUrl)`, returning whether a window opened. When it is -present, the tab pop-out, the file viewer's detach button and a web tab's "open externally" -go through it, and the tab's pop-out icon defaults on and shows at tablet widths (phone tabs -keep their gear + close tap zones, so the host offers the pop-out from its own chrome through -`app.detachSession`). The dashboard -tracks such a window over the existing window channel, the path a reloaded dashboard already -uses, and a solo window closes and raises itself through the optional -`CodemanHost.closeWindow()` / `CodemanHost.focusWindow()`. Browsers define none of these, so -nothing changes there. diff --git a/docs/versioning-policy.md b/docs/versioning-policy.md index 4e635d07..6ec92a2f 100644 --- a/docs/versioning-policy.md +++ b/docs/versioning-policy.md @@ -57,8 +57,10 @@ These may change in a **MINOR** (or even PATCH) release without a MAJOR bump: programmatically is not supported (there is no stable library entry point). 3. **Experimental / opt-in features**, regardless of the app's version: Gesture Control (beta), Agent Teams - (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), and anything labeled experimental - in the UI or docs. These may change or be removed at any time. + (`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1`), the native-wrapper window bridge + (`window.CodemanHost.openWindow` / `closeWindow` / `focusWindow`), and + anything labeled experimental in the UI or docs. These may change or be + removed at any time. ## Deprecation policy diff --git a/src/web/public/app.js b/src/web/public/app.js index 71f1cacc..075d3c14 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1673,8 +1673,11 @@ class CodemanApp { // open the solo URL in a window of its own, beside this one on a foldable or // a split screen. There is no WindowProxy to poll, so the tab is tracked the // way a dashboard reload tracks it: the solo window's channel announcements - // plus the roll-call liveness check. - const hosted = this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id))); + // plus the roll-call liveness check. Without a channel there is no roll-call + // either, so a hosted tab could never re-dock: refuse before asking the host. + const hosted = this.hasHostWindows() && !this.windowChannel + ? false + : this.openInHostWindow(CodemanBase.url('/session/' + encodeURIComponent(id))); if (hosted !== null) { if (!hosted) { this.showToast?.('Could not open a new window for this session', 'error'); @@ -1700,8 +1703,8 @@ class CodemanApp { /** * The embedding app's window opener, when there is one. A native wrapper - * exposes `window.CodemanHost.openWindow(absoluteUrl)` (returning whether a - * window opened) to say it can put a page in a window of its own; browsers + * exposes `window.CodemanHost.openWindow(absoluteUrl)` (anything but false + * counts as opened) to say it can put a page in a window of its own; browsers * never define it. * @returns {boolean} whether a host window opener is present */ @@ -1712,7 +1715,19 @@ class CodemanApp { } /** - * Open a same-origin page in a host window. + * The tab pop-out setting, defaulting ON under a host that opens windows. + * The one resolver for the tab icon, App Settings and the tab action menu. + * @param {object} settings stored per-device App Settings + * @param {object} [defaults] the device's default settings + * @returns {boolean} whether the pop-out control shows + */ + tabDetachButtonEnabled(settings, defaults = {}) { + return settings?.showTabDetachButton ?? (this.hasHostWindows() || (defaults?.showTabDetachButton ?? false)); + } + + /** + * Open an http(s) URL in a host window, usually Codeman's own origin (a saved + * web tab passes its own). * @param {string} url absolute or base-relative URL * @returns {boolean|null} null when there is no host (use window.open), * otherwise whether the host opened a window @@ -1949,7 +1964,7 @@ class CodemanApp { el.className = 'solo-gone-overlay'; el.innerHTML = '<h2>Session unavailable</h2>' + '<p>This session has ended or is no longer available.</p>' - + '<button class="btn-primary" onclick="window.close()">Close window</button>'; + + '<button class="btn-primary" onclick="app._closeSoloWindow()">Close window</button>'; document.body.appendChild(el); document.title = (window.codemanT?.('Session ended') || 'Session ended') + ' — ' + (window.CodemanI18n?.displayName || 'Codeman'); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index c8ffd4bd..b1ec2194 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -507,7 +507,8 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsTabArrangement').value = this.resolveTabArrangement(settings); document.getElementById('appSettingsTabStateOrder').value = this.resolveTabStateOrder(settings); document.getElementById('appSettingsShowTabDetachButton').checked = - settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false)); + this.tabDetachButtonEnabled?.(settings, defaults) + ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); document.getElementById('appSettingsSessionListLayout').value = settings.sessionListLayout ?? defaults.sessionListLayout ?? 'header'; const sessionSidebarFontSize = this.resolveSessionSidebarFontSize( @@ -3725,7 +3726,8 @@ Object.assign(CodemanApp.prototype, { // Under a host that opens windows (see hasHostWindows) popping out is the // way to get two panes side by side, so the button defaults on there. const showTabDetach = - settings.showTabDetachButton ?? (this.hasHostWindows?.() ? true : (defaults.showTabDetachButton ?? false)); + this.tabDetachButtonEnabled?.(settings, defaults) + ?? (settings.showTabDetachButton ?? defaults.showTabDetachButton ?? false); document.documentElement.classList.toggle('tabs-show-detach', showTabDetach); const compactHeader = MobileDetection.getDeviceType() !== 'desktop'; const showFontControls = compactHeader ? false : (settings.showFontControls ?? defaults.showFontControls ?? false); diff --git a/src/web/public/tab-rail-resize.js b/src/web/public/tab-rail-resize.js index ae906641..81e89211 100644 --- a/src/web/public/tab-rail-resize.js +++ b/src/web/public/tab-rail-resize.js @@ -329,7 +329,8 @@ Object.assign(CodemanApp.prototype, { { label: 'Session options', run: () => this.openSessionOptions(sessionId) }, // Group placement (vertical rail with a tab layout only; [] elsewhere). ...(this._tabRefMoveActions?.({ kind: 'session', id: sessionId }) || []), - ...(settings.showTabDetachButton || this.detachedSessions?.has(sessionId) + ...((this.tabDetachButtonEnabled?.(settings) ?? settings.showTabDetachButton) || + this.detachedSessions?.has(sessionId) ? [{ label: 'Open in a new window', run: () => this.detachSession(sessionId) }] : []), { label: 'Close session', className: 'danger', run: () => this.requestCloseSession(sessionId) }, diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index 0cbf2a7d..7c3876f6 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -515,7 +515,13 @@ Object.assign(CodemanApp.prototype, { openWebviewExternal(id) { const webview = this.webviews.get(id || this.activeWebviewId); if (!webview) return; - if (this.openInHostWindow?.(webview.url)) return; + // A host that refuses must not fall through to window.open, which in a + // WebView can replace the dashboard page. + const hosted = this.openInHostWindow?.(webview.url) ?? null; + if (hosted !== null) { + if (!hosted) this.showToast('Could not open a new window for this dashboard', 'error'); + return; + } window.open(webview.url, '_blank', 'noopener'); }, diff --git a/test/host-window-detach.test.ts b/test/host-window-detach.test.ts index 59b67aa8..f840c203 100644 --- a/test/host-window-detach.test.ts +++ b/test/host-window-detach.test.ts @@ -11,7 +11,9 @@ * already uses), * 2. a host that refuses leaves the tab docked and toasts, * 3. without a host nothing changes (`openInHostWindow` returns null), - * 4. a solo window closes and raises itself through the host when it can. + * 4. a solo window closes and raises itself through the host when it can, + * 5. without a window channel (no BroadcastChannel) the host is never asked, + * since a hosted tab could not re-dock without the roll-call. * * Loaded via `vm` with a stubbed context (no jsdom — see connection-indicator.test.ts). */ @@ -58,6 +60,8 @@ function load(host?: Record<string, unknown>) { app.sessions = new Map([['s1', {}]]); app.detachedSessions = new Set(); app.detachedWindows = new Map(); + // A live window channel, as _initWindowChannel would open in a browser. + app.windowChannel = {}; app.$ = () => null; app.showToast = vi.fn(); app._postWindowMessage = vi.fn(); @@ -90,6 +94,19 @@ describe('detach through a host window opener', () => { expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error'); }); + it('refuses without a window channel, so the tab is never stuck detached', () => { + const openWindow = vi.fn().mockReturnValue(true); + const { app, windowStub } = load({ openWindow }); + app.windowChannel = null; + + app.detachSession('s1'); + + expect(openWindow).not.toHaveBeenCalled(); + expect(windowStub.open).not.toHaveBeenCalled(); + expect(app.detachedSessions.size).toBe(0); + expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('Could not open'), 'error'); + }); + it('treats a throwing host as a failed open', () => { const { app } = load({ openWindow: () => { From 432bd5fc0af656638d02758dc786f14e96518eaa Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 06:11:18 +0200 Subject: [PATCH 40/42] fix(codex): #546 landing review follow-up Pin the App Settings codexModel guard to the schema. The 28df21f4 landing fix added a client check in saveAppSettings() that copies the pattern of SettingsUpdateSchema.codexModel, so one bad character no longer 400s the whole .strict() settings PUT behind a "Settings saved" toast. Nothing tied the copy to the schema: a looser copy would bring the silent 400 back, and a stricter one would refuse valid model ids. The new test extracts the client pattern from the saveAppSettings() body, checks it agrees with the schema on eight samples (empty, dotted, slashed, colon, space, semicolon, leading dash, non-ASCII), and asserts the guard runs before the localStorage write. Length is left out on purpose, since the input's maxlength="100" covers .max(100). Both a loosened pattern and a guard moved after the write turn the test red. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .../session-routes-codex-defaults.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/test/routes/session-routes-codex-defaults.test.ts b/test/routes/session-routes-codex-defaults.test.ts index 3b7fc479..6a2ceb9c 100644 --- a/test/routes/session-routes-codex-defaults.test.ts +++ b/test/routes/session-routes-codex-defaults.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; import { mkdir, writeFile, rm } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; @@ -11,6 +12,7 @@ import { buildCodexCommand } from '../../src/tmux-manager.js'; import { Session } from '../../src/session.js'; import { safeRmHomeTree } from '../mocks/index.js'; import { getDataDir } from '../../src/config/instance.js'; +import { SettingsUpdateSchema } from '../../src/web/schemas.js'; vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => { const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>(); @@ -153,4 +155,26 @@ describe('Codex launch defaults', () => { await system.app.close(); } }); + + it('the App Settings client check mirrors SettingsUpdateSchema.codexModel and runs before the local write', () => { + // SettingsUpdateSchema is .strict(), so a codexModel the schema refuses 400s the + // WHOLE settings PUT while the toast still says "Settings saved". saveAppSettings() + // refuses it client-side with a copy of the schema's pattern; a looser copy brings + // that silent 400 back, a stricter one refuses valid model ids. Length is left + // out on purpose: the input's maxlength="100" covers .max(100). + const src = readFileSync(resolve(import.meta.dirname, '../../src/web/public/settings-ui.js'), 'utf8'); + const start = src.indexOf('async saveAppSettings() {'); + expect(start).toBeGreaterThan(-1); + const body = src.slice(start); + const m = body.match(/if \(!\/(\^\[[^\]\n]+\]\*\$)\/\.test\(settings\.codexModel\)\)/); + expect(m).not.toBeNull(); + const client = new RegExp(m![1]); + for (const v of ['', 'gpt-5.1', 'org/model_1-x', 'gpt-oss:20b', 'a b', 'bad;cmd', '-x', 'é']) { + expect(client.test(v), v).toBe(SettingsUpdateSchema.safeParse({ codexModel: v }).success); + } + const guardAt = body.indexOf(m![0]); + const writeAt = body.indexOf('this.saveAppSettingsToStorage(settings);'); + expect(writeAt).toBeGreaterThan(-1); + expect(guardAt).toBeLessThan(writeAt); + }); }); From fe1acd625e8d152fd2b5776396c1653ffe7db21c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Fri, 9 Oct 2026 06:12:18 +0200 Subject: [PATCH 41/42] fix(test): #542 landing review follow-up The Run dropdown scroll browser test built WebServer on the fixed port 3290, which the static port guard (test/test-ports-guard.test.ts, from #556) rejects for any file outside its shrink-only legacy list, so the CI gate failed on the landing branch. The test now binds an ephemeral port with new WebServer(0, ...) and navigates to server.boundPort, and its header records the new convention. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/run-mode-menu-scroll.browser.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/run-mode-menu-scroll.browser.test.ts b/test/run-mode-menu-scroll.browser.test.ts index bbb1854a..e55bc5d6 100644 --- a/test/run-mode-menu-scroll.browser.test.ts +++ b/test/run-mode-menu-scroll.browser.test.ts @@ -8,21 +8,19 @@ * Browser-driven, so excluded from `npm run test:ci` (config/test-suites.ts). Run locally: * npm run test:browser -- test/run-mode-menu-scroll.browser.test.ts * - * Port: 3290 + * Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`) */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { chromium, type Browser, type Page } from 'playwright'; import { WebServer } from '../src/web/server.js'; -const PORT = 3290; - describe('Run dropdown on a phone', () => { let server: WebServer; let browser: Browser; let page: Page; beforeAll(async () => { - server = new WebServer(PORT, false, true); + server = new WebServer(0, false, true); await server.start(); browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ @@ -32,7 +30,7 @@ describe('Run dropdown on a phone', () => { deviceScaleFactor: 2, }); page = await context.newPage(); - await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); }, 90000); From ccd52583e234936e5a8e7b9494efb3428b818541 Mon Sep 17 00:00:00 2001 From: Ark0N <Ark0N@users.noreply.github.com> Date: Fri, 9 Oct 2026 06:13:41 +0200 Subject: [PATCH 42/42] fix(ci): #540 landing review follow-up Move the nightly browser-suite cron from 03:23 to 03:29 UTC, authored as Ark0N. GitHub sends scheduled-run failure notices to whoever last modified the cron line, but its docs do not say whether that means the commit author or the pusher. The previous cron edit (02c65e98) was authored under the maintainer identity, whose noreply@anthropic.com address GitHub resolves to the unrelated login "claude", so under the author reading the nightly's failure notices would never reach the maintainer. With this commit the author, the committer and the pusher are all Ark0N, so every reading lands on the maintainer. Only the minute changes; no doc or test names a clock time. After the first scheduled run on master, confirm with gh api 'repos/Ark0N/Codeman/actions/runs?event=schedule&per_page=1' --jq '.workflow_runs[0].actor.login', which should print Ark0N. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .github/workflows/browser-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/browser-suite.yml b/.github/workflows/browser-suite.yml index c5da3964..caadba59 100644 --- a/.github/workflows/browser-suite.yml +++ b/.github/workflows/browser-suite.yml @@ -12,7 +12,7 @@ name: Browser suite on: schedule: - - cron: '23 3 * * *' + - cron: '29 3 * * *' workflow_dispatch: permissions: